diff --git a/.clang-tidy.hash b/.clang-tidy.hash index a3322ba731c..240b205158c 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -766420905c06eeb6c5f360f68fd965e5ddd9c4a5db6b823263d3ad3accb64a07 +4268ab0b5150f79ab1c317e8f3834c8bb0b4c8122da4f6b1fd67c49d0f2098c9 diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index bf7fa0c2622..84d79cda17a 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -49,7 +49,7 @@ jobs: with: python-version: "3.11" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Set TAG run: | diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f917ecd8f82..e63c3075ddb 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -58,7 +58,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@1b168cd39490f61582a9beae412bb7057a6b2c4e # v4.31.8 + uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -86,6 +86,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@1b168cd39490f61582a9beae412bb7057a6b2c4e # v4.31.8 + uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 10194aa5992..b41b118504d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -99,7 +99,7 @@ jobs: python-version: "3.11" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Log in to docker hub uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 @@ -178,7 +178,7 @@ jobs: merge-multiple: true - name: Set up Docker Buildx - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Log in to docker hub if: matrix.registry == 'dockerhub' diff --git a/.gitignore b/.gitignore index 390d1ab45b3..da568d9b832 100644 --- a/.gitignore +++ b/.gitignore @@ -91,6 +91,10 @@ venv-*/ # mypy .mypy_cache/ +# nix +/default.nix +/shell.nix + .pioenvs .piolibdeps .pio diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2f5076a6e6f..de7d30cfa27 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.14.9 + rev: v0.14.10 hooks: # Run the linter. - id: ruff diff --git a/CODEOWNERS b/CODEOWNERS index af926d2d614..941c2e28492 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -42,6 +42,7 @@ esphome/components/animation/* @syndlex esphome/components/anova/* @buxtronix esphome/components/apds9306/* @aodrenah esphome/components/api/* @esphome/core +esphome/components/aqi/* @freekode @jasstrong @ximex esphome/components/as5600/* @ammmze esphome/components/as5600/sensor/* @ammmze esphome/components/as7341/* @mrgnr @@ -215,6 +216,7 @@ esphome/components/hlk_fm22x/* @OnFreund esphome/components/hlw8032/* @rici4kubicek esphome/components/hm3301/* @freekode esphome/components/hmac_md5/* @dwmw2 +esphome/components/hmac_sha256/* @dwmw2 esphome/components/homeassistant/* @esphome/core @OttoWinter esphome/components/homeassistant/number/* @landonr esphome/components/homeassistant/switch/* @Links2004 @@ -535,6 +537,7 @@ esphome/components/version/* @esphome/core esphome/components/voice_assistant/* @jesserockz @kahrendt esphome/components/wake_on_lan/* @clydebarrow @willwill2will54 esphome/components/watchdog/* @oarcher +esphome/components/water_heater/* @dhoeben esphome/components/waveshare_epaper/* @clydebarrow esphome/components/web_server/ota/* @esphome/core esphome/components/web_server_base/* @esphome/core diff --git a/esphome/__main__.py b/esphome/__main__.py index 55fbbc6c8a6..119ab957a3d 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -518,10 +518,49 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int: rc = platformio_api.run_compile(config, CORE.verbose) if rc != 0: return rc + + # Check if firmware was rebuilt and emit build_info + create manifest + _check_and_emit_build_info() + idedata = platformio_api.get_idedata(config) return 0 if idedata is not None else 1 +def _check_and_emit_build_info() -> None: + """Check if firmware was rebuilt and emit build_info.""" + import json + + firmware_path = CORE.firmware_bin + build_info_json_path = CORE.relative_build_path("build_info.json") + + # Check if both files exist + if not firmware_path.exists() or not build_info_json_path.exists(): + return + + # Check if firmware is newer than build_info (indicating a relink occurred) + if firmware_path.stat().st_mtime <= build_info_json_path.stat().st_mtime: + return + + # Read build_info from JSON + try: + with open(build_info_json_path, 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) + return + + config_hash = build_info.get("config_hash") + build_time_str = build_info.get("build_time_str") + + if config_hash is None or build_time_str is None: + return + + # Emit build_info with human-readable time + _LOGGER.info( + "Build Info: config_hash=0x%08x build_time_str=%s", config_hash, build_time_str + ) + + def upload_using_esptool( config: ConfigType, port: str, file: str, speed: int ) -> str | int: diff --git a/esphome/components/ade7880/sensor.py b/esphome/components/ade7880/sensor.py index 39dbeb225f4..beb74d73101 100644 --- a/esphome/components/ade7880/sensor.py +++ b/esphome/components/ade7880/sensor.py @@ -227,7 +227,7 @@ CONFIG_SCHEMA = cv.All( { cv.GenerateID(): cv.declare_id(ADE7880), cv.Optional(CONF_FREQUENCY, default="50Hz"): cv.All( - cv.frequency, cv.Range(min=45.0, max=66.0) + cv.frequency, cv.float_range(min=45.0, max=66.0) ), cv.Optional(CONF_IRQ0_PIN): pins.internal_gpio_input_pin_schema, cv.Required(CONF_IRQ1_PIN): pins.internal_gpio_input_pin_schema, diff --git a/esphome/components/alarm_control_panel/alarm_control_panel.cpp b/esphome/components/alarm_control_panel/alarm_control_panel.cpp index c29e02c8efd..f938155dd3c 100644 --- a/esphome/components/alarm_control_panel/alarm_control_panel.cpp +++ b/esphome/components/alarm_control_panel/alarm_control_panel.cpp @@ -35,26 +35,12 @@ void AlarmControlPanel::publish_state(AlarmControlPanelState state) { ESP_LOGD(TAG, "Set state to: %s, previous: %s", LOG_STR_ARG(alarm_control_panel_state_to_string(state)), LOG_STR_ARG(alarm_control_panel_state_to_string(prev_state))); this->current_state_ = state; + // Single state callback - triggers check get_state() for specific states this->state_callback_.call(); #if defined(USE_ALARM_CONTROL_PANEL) && defined(USE_CONTROLLER_REGISTRY) ControllerRegistry::notify_alarm_control_panel_update(this); #endif - if (state == ACP_STATE_TRIGGERED) { - this->triggered_callback_.call(); - } else if (state == ACP_STATE_ARMING) { - this->arming_callback_.call(); - } else if (state == ACP_STATE_PENDING) { - this->pending_callback_.call(); - } else if (state == ACP_STATE_ARMED_HOME) { - this->armed_home_callback_.call(); - } else if (state == ACP_STATE_ARMED_NIGHT) { - this->armed_night_callback_.call(); - } else if (state == ACP_STATE_ARMED_AWAY) { - this->armed_away_callback_.call(); - } else if (state == ACP_STATE_DISARMED) { - this->disarmed_callback_.call(); - } - + // Cleared fires when leaving TRIGGERED state if (prev_state == ACP_STATE_TRIGGERED) { this->cleared_callback_.call(); } @@ -69,34 +55,6 @@ void AlarmControlPanel::add_on_state_callback(std::function &&callback) this->state_callback_.add(std::move(callback)); } -void AlarmControlPanel::add_on_triggered_callback(std::function &&callback) { - this->triggered_callback_.add(std::move(callback)); -} - -void AlarmControlPanel::add_on_arming_callback(std::function &&callback) { - this->arming_callback_.add(std::move(callback)); -} - -void AlarmControlPanel::add_on_armed_home_callback(std::function &&callback) { - this->armed_home_callback_.add(std::move(callback)); -} - -void AlarmControlPanel::add_on_armed_night_callback(std::function &&callback) { - this->armed_night_callback_.add(std::move(callback)); -} - -void AlarmControlPanel::add_on_armed_away_callback(std::function &&callback) { - this->armed_away_callback_.add(std::move(callback)); -} - -void AlarmControlPanel::add_on_pending_callback(std::function &&callback) { - this->pending_callback_.add(std::move(callback)); -} - -void AlarmControlPanel::add_on_disarmed_callback(std::function &&callback) { - this->disarmed_callback_.add(std::move(callback)); -} - void AlarmControlPanel::add_on_cleared_callback(std::function &&callback) { this->cleared_callback_.add(std::move(callback)); } diff --git a/esphome/components/alarm_control_panel/alarm_control_panel.h b/esphome/components/alarm_control_panel/alarm_control_panel.h index 85c2b2148e7..59ccf0e4844 100644 --- a/esphome/components/alarm_control_panel/alarm_control_panel.h +++ b/esphome/components/alarm_control_panel/alarm_control_panel.h @@ -35,54 +35,13 @@ class AlarmControlPanel : public EntityBase { */ void publish_state(AlarmControlPanelState state); - /** Add a callback for when the state of the alarm_control_panel changes + /** Add a callback for when the state of the alarm_control_panel changes. + * Triggers can check get_state() to determine the new state. * * @param callback The callback function */ void add_on_state_callback(std::function &&callback); - /** Add a callback for when the state of the alarm_control_panel chanes to triggered - * - * @param callback The callback function - */ - void add_on_triggered_callback(std::function &&callback); - - /** Add a callback for when the state of the alarm_control_panel chanes to arming - * - * @param callback The callback function - */ - void add_on_arming_callback(std::function &&callback); - - /** Add a callback for when the state of the alarm_control_panel changes to pending - * - * @param callback The callback function - */ - void add_on_pending_callback(std::function &&callback); - - /** Add a callback for when the state of the alarm_control_panel changes to armed_home - * - * @param callback The callback function - */ - void add_on_armed_home_callback(std::function &&callback); - - /** Add a callback for when the state of the alarm_control_panel changes to armed_night - * - * @param callback The callback function - */ - void add_on_armed_night_callback(std::function &&callback); - - /** Add a callback for when the state of the alarm_control_panel changes to armed_away - * - * @param callback The callback function - */ - void add_on_armed_away_callback(std::function &&callback); - - /** Add a callback for when the state of the alarm_control_panel changes to disarmed - * - * @param callback The callback function - */ - void add_on_disarmed_callback(std::function &&callback); - /** Add a callback for when the state of the alarm_control_panel clears from triggered * * @param callback The callback function @@ -172,28 +131,14 @@ class AlarmControlPanel : public EntityBase { uint32_t last_update_; // the call control function virtual void control(const AlarmControlPanelCall &call) = 0; - // state callback - CallbackManager state_callback_{}; - // trigger callback - CallbackManager triggered_callback_{}; - // arming callback - CallbackManager arming_callback_{}; - // pending callback - CallbackManager pending_callback_{}; - // armed_home callback - CallbackManager armed_home_callback_{}; - // armed_night callback - CallbackManager armed_night_callback_{}; - // armed_away callback - CallbackManager armed_away_callback_{}; - // disarmed callback - CallbackManager disarmed_callback_{}; - // clear callback - CallbackManager cleared_callback_{}; + // state callback - triggers check get_state() for specific state + LazyCallbackManager state_callback_{}; + // clear callback - fires when leaving TRIGGERED state + LazyCallbackManager cleared_callback_{}; // chime callback - CallbackManager chime_callback_{}; + LazyCallbackManager chime_callback_{}; // ready callback - CallbackManager ready_callback_{}; + LazyCallbackManager ready_callback_{}; }; } // namespace alarm_control_panel diff --git a/esphome/components/alarm_control_panel/automation.h b/esphome/components/alarm_control_panel/automation.h index db2ef781582..af4a14e27a8 100644 --- a/esphome/components/alarm_control_panel/automation.h +++ b/esphome/components/alarm_control_panel/automation.h @@ -6,6 +6,7 @@ namespace esphome { namespace alarm_control_panel { +/// Trigger on any state change class StateTrigger : public Trigger<> { public: explicit StateTrigger(AlarmControlPanel *alarm_control_panel) { @@ -13,55 +14,30 @@ class StateTrigger : public Trigger<> { } }; -class TriggeredTrigger : public Trigger<> { +/// Template trigger that fires when entering a specific state +template class StateEnterTrigger : public Trigger<> { public: - explicit TriggeredTrigger(AlarmControlPanel *alarm_control_panel) { - alarm_control_panel->add_on_triggered_callback([this]() { this->trigger(); }); + explicit StateEnterTrigger(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) { + alarm_control_panel->add_on_state_callback([this]() { + if (this->alarm_control_panel_->get_state() == State) + this->trigger(); + }); } + + protected: + AlarmControlPanel *alarm_control_panel_; }; -class ArmingTrigger : public Trigger<> { - public: - explicit ArmingTrigger(AlarmControlPanel *alarm_control_panel) { - alarm_control_panel->add_on_arming_callback([this]() { this->trigger(); }); - } -}; - -class PendingTrigger : public Trigger<> { - public: - explicit PendingTrigger(AlarmControlPanel *alarm_control_panel) { - alarm_control_panel->add_on_pending_callback([this]() { this->trigger(); }); - } -}; - -class ArmedHomeTrigger : public Trigger<> { - public: - explicit ArmedHomeTrigger(AlarmControlPanel *alarm_control_panel) { - alarm_control_panel->add_on_armed_home_callback([this]() { this->trigger(); }); - } -}; - -class ArmedNightTrigger : public Trigger<> { - public: - explicit ArmedNightTrigger(AlarmControlPanel *alarm_control_panel) { - alarm_control_panel->add_on_armed_night_callback([this]() { this->trigger(); }); - } -}; - -class ArmedAwayTrigger : public Trigger<> { - public: - explicit ArmedAwayTrigger(AlarmControlPanel *alarm_control_panel) { - alarm_control_panel->add_on_armed_away_callback([this]() { this->trigger(); }); - } -}; - -class DisarmedTrigger : public Trigger<> { - public: - explicit DisarmedTrigger(AlarmControlPanel *alarm_control_panel) { - alarm_control_panel->add_on_disarmed_callback([this]() { this->trigger(); }); - } -}; +// Type aliases for state-specific triggers +using TriggeredTrigger = StateEnterTrigger; +using ArmingTrigger = StateEnterTrigger; +using PendingTrigger = StateEnterTrigger; +using ArmedHomeTrigger = StateEnterTrigger; +using ArmedNightTrigger = StateEnterTrigger; +using ArmedAwayTrigger = StateEnterTrigger; +using DisarmedTrigger = StateEnterTrigger; +/// Trigger when leaving TRIGGERED state (alarm cleared) class ClearedTrigger : public Trigger<> { public: explicit ClearedTrigger(AlarmControlPanel *alarm_control_panel) { @@ -69,6 +45,7 @@ class ClearedTrigger : public Trigger<> { } }; +/// Trigger on chime event (zone opened while disarmed) class ChimeTrigger : public Trigger<> { public: explicit ChimeTrigger(AlarmControlPanel *alarm_control_panel) { @@ -76,6 +53,7 @@ class ChimeTrigger : public Trigger<> { } }; +/// Trigger on ready state change class ReadyTrigger : public Trigger<> { public: explicit ReadyTrigger(AlarmControlPanel *alarm_control_panel) { diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 50af5061c07..c351bc8c9c4 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -477,7 +477,7 @@ message FanCommandRequest { bool has_speed_level = 10; int32 speed_level = 11; bool has_preset_mode = 12; - string preset_mode = 13; + string preset_mode = 13 [(pointer_to_buffer) = true]; uint32 device_id = 14 [(field_ifdef) = "USE_DEVICES"]; } @@ -747,7 +747,7 @@ message NoiseEncryptionSetKeyRequest { option (source) = SOURCE_CLIENT; option (ifdef) = "USE_API_NOISE"; - bytes key = 1; + bytes key = 1 [(pointer_to_buffer) = true]; } message NoiseEncryptionSetKeyResponse { @@ -824,9 +824,9 @@ message HomeAssistantStateResponse { option (no_delay) = true; option (ifdef) = "USE_API_HOMEASSISTANT_STATES"; - string entity_id = 1; - string state = 2; - string attribute = 3; + string entity_id = 1 [(pointer_to_buffer) = true]; + string state = 2 [(pointer_to_buffer) = true]; + string attribute = 3 [(pointer_to_buffer) = true]; } // ==================== IMPORT TIME ==================== @@ -1091,16 +1091,95 @@ message ClimateCommandRequest { bool has_swing_mode = 14; ClimateSwingMode swing_mode = 15; bool has_custom_fan_mode = 16; - string custom_fan_mode = 17; + string custom_fan_mode = 17 [(pointer_to_buffer) = true]; bool has_preset = 18; ClimatePreset preset = 19; bool has_custom_preset = 20; - string custom_preset = 21; + string custom_preset = 21 [(pointer_to_buffer) = true]; bool has_target_humidity = 22; float target_humidity = 23; uint32 device_id = 24 [(field_ifdef) = "USE_DEVICES"]; } +// ==================== WATER_HEATER ==================== +enum WaterHeaterMode { + WATER_HEATER_MODE_OFF = 0; + WATER_HEATER_MODE_ECO = 1; + WATER_HEATER_MODE_ELECTRIC = 2; + WATER_HEATER_MODE_PERFORMANCE = 3; + WATER_HEATER_MODE_HIGH_DEMAND = 4; + WATER_HEATER_MODE_HEAT_PUMP = 5; + WATER_HEATER_MODE_GAS = 6; +} + +message ListEntitiesWaterHeaterResponse { + option (id) = 132; + option (base_class) = "InfoResponseProtoMessage"; + option (source) = SOURCE_SERVER; + option (ifdef) = "USE_WATER_HEATER"; + + string object_id = 1; + fixed32 key = 2; + string name = 3; + string icon = 4 [(field_ifdef) = "USE_ENTITY_ICON"]; + bool disabled_by_default = 5; + EntityCategory entity_category = 6; + uint32 device_id = 7 [(field_ifdef) = "USE_DEVICES"]; + float min_temperature = 8; + float max_temperature = 9; + float target_temperature_step = 10; + repeated WaterHeaterMode supported_modes = 11 [(container_pointer_no_template) = "water_heater::WaterHeaterModeMask"]; + // Bitmask of WaterHeaterFeature flags + uint32 supported_features = 12; +} + +message WaterHeaterStateResponse { + option (id) = 133; + option (base_class) = "StateResponseProtoMessage"; + option (source) = SOURCE_SERVER; + option (ifdef) = "USE_WATER_HEATER"; + option (no_delay) = true; + + fixed32 key = 1; + float current_temperature = 2; + float target_temperature = 3; + WaterHeaterMode mode = 4; + uint32 device_id = 5 [(field_ifdef) = "USE_DEVICES"]; + // Bitmask of current state flags (bit 0 = away, bit 1 = on) + uint32 state = 6; + float target_temperature_low = 7; + float target_temperature_high = 8; +} + +// Bitmask for WaterHeaterCommandRequest.has_fields +enum WaterHeaterCommandHasField { + WATER_HEATER_COMMAND_HAS_NONE = 0; + WATER_HEATER_COMMAND_HAS_MODE = 1; + WATER_HEATER_COMMAND_HAS_TARGET_TEMPERATURE = 2; + WATER_HEATER_COMMAND_HAS_STATE = 4; + WATER_HEATER_COMMAND_HAS_TARGET_TEMPERATURE_LOW = 8; + WATER_HEATER_COMMAND_HAS_TARGET_TEMPERATURE_HIGH = 16; +} + +message WaterHeaterCommandRequest { + option (id) = 134; + option (source) = SOURCE_CLIENT; + option (ifdef) = "USE_WATER_HEATER"; + option (no_delay) = true; + option (base_class) = "CommandProtoMessage"; + + fixed32 key = 1; + // Bitmask of which fields are set (see WaterHeaterCommandHasField) + uint32 has_fields = 2; + WaterHeaterMode mode = 3; + float target_temperature = 4; + uint32 device_id = 5 [(field_ifdef) = "USE_DEVICES"]; + // State flags bitmask (bit 0 = away, bit 1 = on) + uint32 state = 6; + float target_temperature_low = 7; + float target_temperature_high = 8; +} + // ==================== NUMBER ==================== enum NumberMode { NUMBER_MODE_AUTO = 0; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 5186e5afdab..b5628f654e9 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #ifdef USE_ESP8266 #include @@ -42,6 +43,9 @@ #ifdef USE_ZWAVE_PROXY #include "esphome/components/zwave_proxy/zwave_proxy.h" #endif +#ifdef USE_WATER_HEATER +#include "esphome/components/water_heater/water_heater.h" +#endif namespace esphome::api { @@ -93,8 +97,7 @@ static const int CAMERA_STOP_STREAM = 5000; return; #endif // USE_DEVICES -APIConnection::APIConnection(std::unique_ptr sock, APIServer *parent) - : parent_(parent), initial_state_iterator_(this), list_entities_iterator_(this) { +APIConnection::APIConnection(std::unique_ptr sock, APIServer *parent) : parent_(parent) { #if defined(USE_API_PLAINTEXT) && defined(USE_API_NOISE) auto &noise_ctx = parent->get_noise_ctx(); if (noise_ctx.has_psk()) { @@ -133,6 +136,7 @@ void APIConnection::start() { } APIConnection::~APIConnection() { + this->destroy_active_iterator_(); #ifdef USE_BLUETOOTH_PROXY if (bluetooth_proxy::global_bluetooth_proxy->get_api_connection() == this) { bluetooth_proxy::global_bluetooth_proxy->unsubscribe_api_connection(this); @@ -145,6 +149,32 @@ APIConnection::~APIConnection() { #endif } +void APIConnection::destroy_active_iterator_() { + switch (this->active_iterator_) { + case ActiveIterator::LIST_ENTITIES: + this->iterator_storage_.list_entities.~ListEntitiesIterator(); + break; + case ActiveIterator::INITIAL_STATE: + this->iterator_storage_.initial_state.~InitialStateIterator(); + break; + case ActiveIterator::NONE: + break; + } + this->active_iterator_ = ActiveIterator::NONE; +} + +void APIConnection::begin_iterator_(ActiveIterator type) { + this->destroy_active_iterator_(); + this->active_iterator_ = type; + if (type == ActiveIterator::LIST_ENTITIES) { + new (&this->iterator_storage_.list_entities) ListEntitiesIterator(this); + this->iterator_storage_.list_entities.begin(); + } else { + new (&this->iterator_storage_.initial_state) InitialStateIterator(this); + this->iterator_storage_.initial_state.begin(); + } +} + void APIConnection::loop() { if (this->flags_.next_close) { // requested a disconnect @@ -187,23 +217,35 @@ void APIConnection::loop() { this->process_batch_(); } - if (!this->list_entities_iterator_.completed()) { - this->process_iterator_batch_(this->list_entities_iterator_); - } else if (!this->initial_state_iterator_.completed()) { - this->process_iterator_batch_(this->initial_state_iterator_); - - // If we've completed initial states, process any remaining and clear the flag - if (this->initial_state_iterator_.completed()) { - // Process any remaining batched messages immediately - if (!this->deferred_batch_.empty()) { - this->process_batch_(); + switch (this->active_iterator_) { + case ActiveIterator::LIST_ENTITIES: + if (this->iterator_storage_.list_entities.completed()) { + this->destroy_active_iterator_(); + if (this->flags_.state_subscription) { + this->begin_iterator_(ActiveIterator::INITIAL_STATE); + } + } else { + this->process_iterator_batch_(this->iterator_storage_.list_entities); } - // Now that everything is sent, enable immediate sending for future state changes - this->flags_.should_try_send_immediately = true; - // Release excess memory from buffers that grew during initial sync - this->deferred_batch_.release_buffer(); - this->helper_->release_buffers(); - } + break; + case ActiveIterator::INITIAL_STATE: + if (this->iterator_storage_.initial_state.completed()) { + this->destroy_active_iterator_(); + // Process any remaining batched messages immediately + if (!this->deferred_batch_.empty()) { + this->process_batch_(); + } + // Now that everything is sent, enable immediate sending for future state changes + this->flags_.should_try_send_immediately = true; + // Release excess memory from buffers that grew during initial sync + this->deferred_batch_.release_buffer(); + this->helper_->release_buffers(); + } else { + this->process_iterator_batch_(this->iterator_storage_.initial_state); + } + break; + case ActiveIterator::NONE: + break; } if (this->flags_.sent_ping) { @@ -228,33 +270,17 @@ void APIConnection::loop() { } } -#ifdef USE_CAMERA - if (this->image_reader_ && this->image_reader_->available() && this->helper_->can_write_without_blocking()) { - uint32_t to_send = std::min((size_t) MAX_BATCH_PACKET_SIZE, this->image_reader_->available()); - bool done = this->image_reader_->available() == to_send; - - CameraImageResponse msg; - 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 - msg.device_id = camera::Camera::instance()->get_device_id(); -#endif - - if (this->send_message_(msg, CameraImageResponse::MESSAGE_TYPE)) { - this->image_reader_->consume_data(to_send); - if (done) { - this->image_reader_->return_image(); - } - } - } -#endif - #ifdef USE_API_HOMEASSISTANT_STATES if (state_subs_at_ >= 0) { this->process_state_subscriptions_(); } #endif + +#ifdef USE_CAMERA + // Process camera last - state updates are higher priority + // (missing a frame is fine, missing a state update is not) + this->try_send_camera_image_(); +#endif } bool APIConnection::send_disconnect_response(const DisconnectRequest &msg) { @@ -447,7 +473,7 @@ void APIConnection::fan_command(const FanCommandRequest &msg) { if (msg.has_direction) call.set_direction(static_cast(msg.direction)); if (msg.has_preset_mode) - call.set_preset_mode(msg.preset_mode); + call.set_preset_mode(reinterpret_cast(msg.preset_mode), msg.preset_mode_len); call.perform(); } #endif @@ -712,11 +738,11 @@ void APIConnection::climate_command(const ClimateCommandRequest &msg) { if (msg.has_fan_mode) call.set_fan_mode(static_cast(msg.fan_mode)); if (msg.has_custom_fan_mode) - call.set_fan_mode(msg.custom_fan_mode); + call.set_fan_mode(reinterpret_cast(msg.custom_fan_mode), msg.custom_fan_mode_len); if (msg.has_preset) call.set_preset(static_cast(msg.preset)); if (msg.has_custom_preset) - call.set_preset(msg.custom_preset); + call.set_preset(reinterpret_cast(msg.custom_preset), msg.custom_preset_len); if (msg.has_swing_mode) call.set_swing_mode(static_cast(msg.swing_mode)); call.perform(); @@ -1057,6 +1083,36 @@ void APIConnection::media_player_command(const MediaPlayerCommandRequest &msg) { #endif #ifdef USE_CAMERA +void APIConnection::try_send_camera_image_() { + if (!this->image_reader_) + return; + + // Send as many chunks as possible without blocking + while (this->image_reader_->available()) { + if (!this->helper_->can_write_without_blocking()) + return; + + uint32_t to_send = std::min((size_t) MAX_BATCH_PACKET_SIZE, this->image_reader_->available()); + bool done = this->image_reader_->available() == to_send; + + CameraImageResponse msg; + 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 + msg.device_id = camera::Camera::instance()->get_device_id(); +#endif + + if (!this->send_message_(msg, CameraImageResponse::MESSAGE_TYPE)) { + return; // Send failed, try again later + } + this->image_reader_->consume_data(to_send); + if (done) { + this->image_reader_->return_image(); + return; + } + } +} void APIConnection::set_camera_state(std::shared_ptr image) { if (!this->flags_.state_subscription) return; @@ -1064,8 +1120,11 @@ void APIConnection::set_camera_state(std::shared_ptr image) return; if (this->image_reader_->available()) return; - if (image->was_requested_by(esphome::camera::API_REQUESTER) || image->was_requested_by(esphome::camera::IDLE)) + 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_(); + } } uint16_t APIConnection::try_send_camera_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, bool is_single) { @@ -1306,6 +1365,57 @@ void APIConnection::alarm_control_panel_command(const AlarmControlPanelCommandRe } #endif +#ifdef USE_WATER_HEATER +bool APIConnection::send_water_heater_state(water_heater::WaterHeater *water_heater) { + return this->send_message_smart_(water_heater, &APIConnection::try_send_water_heater_state, + WaterHeaterStateResponse::MESSAGE_TYPE, WaterHeaterStateResponse::ESTIMATED_SIZE); +} +uint16_t APIConnection::try_send_water_heater_state(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, + bool is_single) { + auto *wh = static_cast(entity); + WaterHeaterStateResponse resp; + resp.mode = static_cast(wh->get_mode()); + resp.current_temperature = wh->get_current_temperature(); + resp.target_temperature = wh->get_target_temperature(); + resp.target_temperature_low = wh->get_target_temperature_low(); + resp.target_temperature_high = wh->get_target_temperature_high(); + resp.state = wh->get_state(); + resp.key = wh->get_object_id_hash(); + + return encode_message_to_buffer(resp, WaterHeaterStateResponse::MESSAGE_TYPE, conn, remaining_size, is_single); +} +uint16_t APIConnection::try_send_water_heater_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, + bool is_single) { + auto *wh = static_cast(entity); + ListEntitiesWaterHeaterResponse msg; + auto traits = wh->get_traits(); + msg.min_temperature = traits.get_min_temperature(); + msg.max_temperature = traits.get_max_temperature(); + msg.target_temperature_step = traits.get_target_temperature_step(); + msg.supported_modes = &traits.get_supported_modes(); + msg.supported_features = traits.get_feature_flags(); + return fill_and_encode_entity_info(wh, msg, ListEntitiesWaterHeaterResponse::MESSAGE_TYPE, conn, remaining_size, + is_single); +} + +void APIConnection::on_water_heater_command_request(const WaterHeaterCommandRequest &msg) { + ENTITY_COMMAND_MAKE_CALL(water_heater::WaterHeater, water_heater, water_heater) + if (msg.has_fields & enums::WATER_HEATER_COMMAND_HAS_MODE) + call.set_mode(static_cast(msg.mode)); + if (msg.has_fields & enums::WATER_HEATER_COMMAND_HAS_TARGET_TEMPERATURE) + call.set_target_temperature(msg.target_temperature); + if (msg.has_fields & enums::WATER_HEATER_COMMAND_HAS_TARGET_TEMPERATURE_LOW) + call.set_target_temperature_low(msg.target_temperature_low); + if (msg.has_fields & enums::WATER_HEATER_COMMAND_HAS_TARGET_TEMPERATURE_HIGH) + call.set_target_temperature_high(msg.target_temperature_high); + if (msg.has_fields & enums::WATER_HEATER_COMMAND_HAS_STATE) { + call.set_away((msg.state & water_heater::WATER_HEATER_STATE_AWAY) != 0); + call.set_on((msg.state & water_heater::WATER_HEATER_STATE_ON) != 0); + } + call.perform(); +} +#endif + #ifdef USE_EVENT void APIConnection::send_event(event::Event *event, const char *event_type) { this->send_message_smart_(event, MessageCreator(event_type), EventResponse::MESSAGE_TYPE, @@ -1472,7 +1582,10 @@ bool APIConnection::send_device_info_response(const DeviceInfoRequest &msg) { resp.set_esphome_version(ESPHOME_VERSION_REF); - resp.set_compilation_time(App.get_compilation_time_ref()); + // Stack buffer for build time string + char build_time_str[Application::BUILD_TIME_STR_SIZE]; + App.get_build_time_string(build_time_str); + resp.set_compilation_time(StringRef(build_time_str)); // Manufacturer string - define once, handle ESP8266 PROGMEM separately #if defined(USE_ESP8266) || defined(USE_ESP32) @@ -1579,15 +1692,29 @@ bool APIConnection::send_device_info_response(const DeviceInfoRequest &msg) { #ifdef USE_API_HOMEASSISTANT_STATES void APIConnection::on_home_assistant_state_response(const HomeAssistantStateResponse &msg) { - for (auto &it : this->parent_->get_state_subs()) { - // Compare entity_id and attribute with message fields - bool entity_match = (strcmp(it.entity_id, msg.entity_id.c_str()) == 0); - bool attribute_match = (it.attribute != nullptr && strcmp(it.attribute, msg.attribute.c_str()) == 0) || - (it.attribute == nullptr && msg.attribute.empty()); + // Skip if entity_id is empty (invalid message) + if (msg.entity_id_len == 0) { + return; + } - if (entity_match && attribute_match) { - it.callback(msg.state); + for (auto &it : this->parent_->get_state_subs()) { + // Compare entity_id: check length matches and content matches + size_t entity_id_len = strlen(it.entity_id); + if (entity_id_len != msg.entity_id_len || memcmp(it.entity_id, msg.entity_id, msg.entity_id_len) != 0) { + continue; } + + // Compare attribute: either both have matching attribute, or both have none + size_t sub_attr_len = it.attribute != nullptr ? strlen(it.attribute) : 0; + if (sub_attr_len != msg.attribute_len || + (sub_attr_len > 0 && memcmp(it.attribute, msg.attribute, sub_attr_len) != 0)) { + continue; + } + + // Create temporary string for callback (callback takes const std::string &) + // Handle empty state (nullptr with len=0) + std::string state(msg.state_len > 0 ? reinterpret_cast(msg.state) : "", msg.state_len); + it.callback(state); } } #endif @@ -1663,13 +1790,13 @@ bool APIConnection::send_noise_encryption_set_key_response(const NoiseEncryption resp.success = false; psk_t psk{}; - if (msg.key.empty()) { + if (msg.key_len == 0) { if (this->parent_->clear_noise_psk(true)) { resp.success = true; } else { ESP_LOGW(TAG, "Failed to clear encryption key"); } - } else if (base64_decode(msg.key, psk.data(), psk.size()) != psk.size()) { + } else if (base64_decode(msg.key, msg.key_len, psk.data(), psk.size()) != psk.size()) { ESP_LOGW(TAG, "Invalid encryption key length"); } else if (!this->parent_->save_noise_psk(psk, true)) { ESP_LOGW(TAG, "Failed to save encryption key"); diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index b50be5d0d42..63631169003 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -176,6 +176,11 @@ class APIConnection final : public APIServerConnection { void alarm_control_panel_command(const AlarmControlPanelCommandRequest &msg) override; #endif +#ifdef USE_WATER_HEATER + bool send_water_heater_state(water_heater::WaterHeater *water_heater); + void on_water_heater_command_request(const WaterHeaterCommandRequest &msg) override; +#endif + #ifdef USE_EVENT void send_event(event::Event *event, const char *event_type); #endif @@ -203,10 +208,14 @@ class APIConnection final : public APIServerConnection { bool send_disconnect_response(const DisconnectRequest &msg) override; bool send_ping_response(const PingRequest &msg) override; bool send_device_info_response(const DeviceInfoRequest &msg) override; - void list_entities(const ListEntitiesRequest &msg) override { this->list_entities_iterator_.begin(); } + void list_entities(const ListEntitiesRequest &msg) override { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); } void subscribe_states(const SubscribeStatesRequest &msg) override { this->flags_.state_subscription = true; - this->initial_state_iterator_.begin(); + // Start initial state iterator only if no iterator is active + // If list_entities is running, we'll start initial_state when it completes + if (this->active_iterator_ == ActiveIterator::NONE) { + this->begin_iterator_(ActiveIterator::INITIAL_STATE); + } } void subscribe_logs(const SubscribeLogsRequest &msg) override { this->flags_.log_subscription = msg.level; @@ -287,6 +296,10 @@ class APIConnection final : public APIServerConnection { // Helper function to handle authentication completion void complete_authentication_(); +#ifdef USE_CAMERA + void try_send_camera_image_(); +#endif + #ifdef USE_API_HOMEASSISTANT_STATES void process_state_subscriptions_(); #endif @@ -310,17 +323,10 @@ class APIConnection final : public APIServerConnection { APIConnection *conn, uint32_t remaining_size, bool is_single) { // Set common fields that are shared by all entity types msg.key = entity->get_object_id_hash(); - // Try to use static reference first to avoid allocation - StringRef static_ref = entity->get_object_id_ref_for_api_(); - // Store dynamic string outside the if-else to maintain lifetime - std::string object_id; - if (!static_ref.empty()) { - msg.set_object_id(static_ref); - } else { - // Dynamic case - need to allocate - object_id = entity->get_object_id(); - msg.set_object_id(StringRef(object_id)); - } + // Get object_id with zero heap allocation + // Static case returns direct reference, dynamic case uses buffer + char object_id_buf[OBJECT_ID_MAX_LEN]; + msg.set_object_id(entity->get_object_id_to(object_id_buf)); if (entity->has_own_name()) { msg.set_name(entity->get_name()); @@ -456,6 +462,12 @@ class APIConnection final : public APIServerConnection { static uint16_t try_send_alarm_control_panel_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, bool is_single); #endif +#ifdef USE_WATER_HEATER + static uint16_t try_send_water_heater_state(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, + bool is_single); + static uint16_t try_send_water_heater_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, + bool is_single); +#endif #ifdef USE_EVENT static uint16_t try_send_event_response(event::Event *event, const char *event_type, APIConnection *conn, uint32_t remaining_size, bool is_single); @@ -490,10 +502,22 @@ class APIConnection final : public APIServerConnection { std::unique_ptr helper_; APIServer *parent_; - // Group 2: Larger objects (must be 4-byte aligned) - // These contain vectors/pointers internally, so putting them early ensures good alignment - InitialStateIterator initial_state_iterator_; - ListEntitiesIterator list_entities_iterator_; + // Group 2: Iterator union (saves ~16 bytes vs separate iterators) + // These iterators are never active simultaneously - list_entities runs to completion + // before initial_state begins, so we use a union with explicit construction/destruction. + enum class ActiveIterator : uint8_t { NONE, LIST_ENTITIES, INITIAL_STATE }; + + union IteratorUnion { + ListEntitiesIterator list_entities; + InitialStateIterator initial_state; + // Constructor/destructor do nothing - use placement new/explicit destructor + IteratorUnion() {} + ~IteratorUnion() {} + } iterator_storage_; + + // Helper methods for iterator lifecycle management + void destroy_active_iterator_(); + void begin_iterator_(ActiveIterator type); #ifdef USE_CAMERA std::unique_ptr image_reader_; #endif @@ -608,7 +632,9 @@ class APIConnection final : public APIServerConnection { // 2-byte types immediately after flags_ (no padding between them) uint16_t client_api_version_major_{0}; uint16_t client_api_version_minor_{0}; - // Total: 2 (flags) + 2 + 2 = 6 bytes, then 2 bytes padding to next 4-byte boundary + // 1-byte type to fill padding + ActiveIterator active_iterator_{ActiveIterator::NONE}; + // Total: 2 (flags) + 2 + 2 + 1 = 7 bytes, then 1 byte padding to next 4-byte boundary uint32_t get_batch_delay_ms_() const; // Message will use 8 more bytes than the minimum size, and typical diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 4a89ee78e13..3376b022c5d 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -124,12 +124,12 @@ void DeviceInfoResponse::encode(ProtoWriteBuffer buffer) const { #endif #ifdef USE_DEVICES for (const auto &it : this->devices) { - buffer.encode_message(20, it, true); + buffer.encode_message(20, it); } #endif #ifdef USE_AREAS for (const auto &it : this->areas) { - buffer.encode_message(21, it, true); + buffer.encode_message(21, it); } #endif #ifdef USE_AREAS @@ -447,9 +447,12 @@ bool FanCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { } bool FanCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 13: - this->preset_mode = value.as_string(); + case 13: { + // Use raw data directly to avoid allocation + this->preset_mode = value.data(); + this->preset_mode_len = value.size(); break; + } default: return false; } @@ -855,9 +858,12 @@ void SubscribeLogsResponse::calculate_size(ProtoSize &size) const { #ifdef USE_API_NOISE bool NoiseEncryptionSetKeyRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 1: - this->key = value.as_string(); + case 1: { + // Use raw data directly to avoid allocation + this->key = value.data(); + this->key_len = value.size(); break; + } default: return false; } @@ -878,13 +884,13 @@ void HomeassistantServiceMap::calculate_size(ProtoSize &size) const { void HomeassistantActionRequest::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->service_ref_); for (auto &it : this->data) { - buffer.encode_message(2, it, true); + buffer.encode_message(2, it); } for (auto &it : this->data_template) { - buffer.encode_message(3, it, true); + buffer.encode_message(3, it); } for (auto &it : this->variables) { - buffer.encode_message(4, it, true); + buffer.encode_message(4, it); } buffer.encode_bool(5, this->is_event); #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES @@ -960,15 +966,24 @@ void SubscribeHomeAssistantStateResponse::calculate_size(ProtoSize &size) const } bool HomeAssistantStateResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 1: - this->entity_id = value.as_string(); + case 1: { + // Use raw data directly to avoid allocation + this->entity_id = value.data(); + this->entity_id_len = value.size(); break; - case 2: - this->state = value.as_string(); + } + case 2: { + // Use raw data directly to avoid allocation + this->state = value.data(); + this->state_len = value.size(); break; - case 3: - this->attribute = value.as_string(); + } + case 3: { + // Use raw data directly to avoid allocation + this->attribute = value.data(); + this->attribute_len = value.size(); break; + } default: return false; } @@ -1011,7 +1026,7 @@ void ListEntitiesServicesResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->name_ref_); buffer.encode_fixed32(2, this->key); for (auto &it : this->args) { - buffer.encode_message(3, it, true); + buffer.encode_message(3, it); } buffer.encode_uint32(4, static_cast(this->supports_response)); } @@ -1392,12 +1407,18 @@ bool ClimateCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) } bool ClimateCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 17: - this->custom_fan_mode = value.as_string(); + case 17: { + // Use raw data directly to avoid allocation + this->custom_fan_mode = value.data(); + this->custom_fan_mode_len = value.size(); break; - case 21: - this->custom_preset = value.as_string(); + } + case 21: { + // Use raw data directly to avoid allocation + this->custom_preset = value.data(); + this->custom_preset_len = value.size(); break; + } default: return false; } @@ -1426,6 +1447,114 @@ bool ClimateCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { return true; } #endif +#ifdef USE_WATER_HEATER +void ListEntitiesWaterHeaterResponse::encode(ProtoWriteBuffer buffer) const { + buffer.encode_string(1, this->object_id_ref_); + buffer.encode_fixed32(2, this->key); + buffer.encode_string(3, this->name_ref_); +#ifdef USE_ENTITY_ICON + buffer.encode_string(4, this->icon_ref_); +#endif + buffer.encode_bool(5, this->disabled_by_default); + buffer.encode_uint32(6, static_cast(this->entity_category)); +#ifdef USE_DEVICES + buffer.encode_uint32(7, this->device_id); +#endif + buffer.encode_float(8, this->min_temperature); + buffer.encode_float(9, this->max_temperature); + buffer.encode_float(10, this->target_temperature_step); + for (const auto &it : *this->supported_modes) { + buffer.encode_uint32(11, static_cast(it), true); + } + buffer.encode_uint32(12, this->supported_features); +} +void ListEntitiesWaterHeaterResponse::calculate_size(ProtoSize &size) const { + size.add_length(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_length(1, this->name_ref_.size()); +#ifdef USE_ENTITY_ICON + size.add_length(1, this->icon_ref_.size()); +#endif + size.add_bool(1, this->disabled_by_default); + size.add_uint32(1, static_cast(this->entity_category)); +#ifdef USE_DEVICES + size.add_uint32(1, this->device_id); +#endif + size.add_float(1, this->min_temperature); + size.add_float(1, this->max_temperature); + size.add_float(1, this->target_temperature_step); + if (!this->supported_modes->empty()) { + for (const auto &it : *this->supported_modes) { + size.add_uint32_force(1, static_cast(it)); + } + } + size.add_uint32(1, this->supported_features); +} +void WaterHeaterStateResponse::encode(ProtoWriteBuffer buffer) const { + buffer.encode_fixed32(1, this->key); + buffer.encode_float(2, this->current_temperature); + buffer.encode_float(3, this->target_temperature); + buffer.encode_uint32(4, static_cast(this->mode)); +#ifdef USE_DEVICES + buffer.encode_uint32(5, this->device_id); +#endif + buffer.encode_uint32(6, this->state); + buffer.encode_float(7, this->target_temperature_low); + buffer.encode_float(8, this->target_temperature_high); +} +void WaterHeaterStateResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32(1, this->key); + size.add_float(1, this->current_temperature); + size.add_float(1, this->target_temperature); + size.add_uint32(1, static_cast(this->mode)); +#ifdef USE_DEVICES + size.add_uint32(1, this->device_id); +#endif + size.add_uint32(1, this->state); + size.add_float(1, this->target_temperature_low); + size.add_float(1, this->target_temperature_high); +} +bool WaterHeaterCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { + switch (field_id) { + case 2: + this->has_fields = value.as_uint32(); + break; + case 3: + this->mode = static_cast(value.as_uint32()); + break; +#ifdef USE_DEVICES + case 5: + this->device_id = value.as_uint32(); + break; +#endif + case 6: + this->state = value.as_uint32(); + break; + default: + return false; + } + return true; +} +bool WaterHeaterCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { + switch (field_id) { + case 1: + this->key = value.as_fixed32(); + break; + case 4: + this->target_temperature = value.as_float(); + break; + case 7: + this->target_temperature_low = value.as_float(); + break; + case 8: + this->target_temperature_high = value.as_float(); + break; + default: + return false; + } + return true; +} +#endif #ifdef USE_NUMBER void ListEntitiesNumberResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->object_id_ref_); @@ -1867,7 +1996,7 @@ void ListEntitiesMediaPlayerResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(7, static_cast(this->entity_category)); buffer.encode_bool(8, this->supports_pause); for (auto &it : this->supported_formats) { - buffer.encode_message(9, it, true); + buffer.encode_message(9, it); } #ifdef USE_DEVICES buffer.encode_uint32(10, this->device_id); @@ -1987,7 +2116,7 @@ void BluetoothLERawAdvertisement::calculate_size(ProtoSize &size) const { } void BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer buffer) const { for (uint16_t i = 0; i < this->advertisements_len; i++) { - buffer.encode_message(1, this->advertisements[i], true); + buffer.encode_message(1, this->advertisements[i]); } } void BluetoothLERawAdvertisementsResponse::calculate_size(ProtoSize &size) const { @@ -2060,7 +2189,7 @@ void BluetoothGATTCharacteristic::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(2, this->handle); buffer.encode_uint32(3, this->properties); for (auto &it : this->descriptors) { - buffer.encode_message(4, it, true); + buffer.encode_message(4, it); } buffer.encode_uint32(5, this->short_uuid); } @@ -2081,7 +2210,7 @@ void BluetoothGATTService::encode(ProtoWriteBuffer buffer) const { } buffer.encode_uint32(2, this->handle); for (auto &it : this->characteristics) { - buffer.encode_message(3, it, true); + buffer.encode_message(3, it); } buffer.encode_uint32(4, this->short_uuid); } @@ -2097,7 +2226,7 @@ void BluetoothGATTService::calculate_size(ProtoSize &size) const { void BluetoothGATTGetServicesResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint64(1, this->address); for (auto &it : this->services) { - buffer.encode_message(2, it, true); + buffer.encode_message(2, it); } } void BluetoothGATTGetServicesResponse::calculate_size(ProtoSize &size) const { @@ -2557,7 +2686,7 @@ bool VoiceAssistantConfigurationRequest::decode_length(uint32_t field_id, ProtoL } void VoiceAssistantConfigurationResponse::encode(ProtoWriteBuffer buffer) const { for (auto &it : this->available_wake_words) { - buffer.encode_message(1, it, true); + buffer.encode_message(1, it); } for (const auto &it : *this->active_wake_words) { buffer.encode_string(2, it, true); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index f23a62fc3c6..2111c2a8950 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -129,6 +129,25 @@ enum ClimatePreset : uint32_t { CLIMATE_PRESET_ACTIVITY = 7, }; #endif +#ifdef USE_WATER_HEATER +enum WaterHeaterMode : uint32_t { + WATER_HEATER_MODE_OFF = 0, + WATER_HEATER_MODE_ECO = 1, + WATER_HEATER_MODE_ELECTRIC = 2, + WATER_HEATER_MODE_PERFORMANCE = 3, + WATER_HEATER_MODE_HIGH_DEMAND = 4, + WATER_HEATER_MODE_HEAT_PUMP = 5, + WATER_HEATER_MODE_GAS = 6, +}; +#endif +enum WaterHeaterCommandHasField : uint32_t { + WATER_HEATER_COMMAND_HAS_NONE = 0, + WATER_HEATER_COMMAND_HAS_MODE = 1, + WATER_HEATER_COMMAND_HAS_TARGET_TEMPERATURE = 2, + WATER_HEATER_COMMAND_HAS_STATE = 4, + WATER_HEATER_COMMAND_HAS_TARGET_TEMPERATURE_LOW = 8, + WATER_HEATER_COMMAND_HAS_TARGET_TEMPERATURE_HIGH = 16, +}; #ifdef USE_NUMBER enum NumberMode : uint32_t { NUMBER_MODE_AUTO = 0, @@ -765,7 +784,7 @@ class FanStateResponse final : public StateResponseProtoMessage { class FanCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 31; - static constexpr uint8_t ESTIMATED_SIZE = 38; + static constexpr uint8_t ESTIMATED_SIZE = 48; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "fan_command_request"; } #endif @@ -778,7 +797,8 @@ class FanCommandRequest final : public CommandProtoMessage { bool has_speed_level{false}; int32_t speed_level{0}; bool has_preset_mode{false}; - std::string preset_mode{}; + const uint8_t *preset_mode{nullptr}; + uint16_t preset_mode_len{0}; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1053,11 +1073,12 @@ class SubscribeLogsResponse final : public ProtoMessage { class NoiseEncryptionSetKeyRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 124; - static constexpr uint8_t ESTIMATED_SIZE = 9; + static constexpr uint8_t ESTIMATED_SIZE = 19; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "noise_encryption_set_key_request"; } #endif - std::string key{}; + const uint8_t *key{nullptr}; + uint16_t key_len{0}; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1201,13 +1222,16 @@ class SubscribeHomeAssistantStateResponse final : public ProtoMessage { class HomeAssistantStateResponse final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 40; - static constexpr uint8_t ESTIMATED_SIZE = 27; + static constexpr uint8_t ESTIMATED_SIZE = 57; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "home_assistant_state_response"; } #endif - std::string entity_id{}; - std::string state{}; - std::string attribute{}; + const uint8_t *entity_id{nullptr}; + uint16_t entity_id_len{0}; + const uint8_t *state{nullptr}; + uint16_t state_len{0}; + const uint8_t *attribute{nullptr}; + uint16_t attribute_len{0}; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1475,7 +1499,7 @@ class ClimateStateResponse final : public StateResponseProtoMessage { class ClimateCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 48; - static constexpr uint8_t ESTIMATED_SIZE = 84; + static constexpr uint8_t ESTIMATED_SIZE = 104; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "climate_command_request"; } #endif @@ -1492,11 +1516,13 @@ class ClimateCommandRequest final : public CommandProtoMessage { bool has_swing_mode{false}; enums::ClimateSwingMode swing_mode{}; bool has_custom_fan_mode{false}; - std::string custom_fan_mode{}; + const uint8_t *custom_fan_mode{nullptr}; + uint16_t custom_fan_mode_len{0}; bool has_preset{false}; enums::ClimatePreset preset{}; bool has_custom_preset{false}; - std::string custom_preset{}; + const uint8_t *custom_preset{nullptr}; + uint16_t custom_preset_len{0}; bool has_target_humidity{false}; float target_humidity{0.0f}; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -1509,6 +1535,70 @@ class ClimateCommandRequest final : public CommandProtoMessage { bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; #endif +#ifdef USE_WATER_HEATER +class ListEntitiesWaterHeaterResponse final : public InfoResponseProtoMessage { + public: + static constexpr uint8_t MESSAGE_TYPE = 132; + static constexpr uint8_t ESTIMATED_SIZE = 63; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *message_name() const override { return "list_entities_water_heater_response"; } +#endif + float min_temperature{0.0f}; + float max_temperature{0.0f}; + float target_temperature_step{0.0f}; + const water_heater::WaterHeaterModeMask *supported_modes{}; + uint32_t supported_features{0}; + void encode(ProtoWriteBuffer buffer) const override; + void calculate_size(ProtoSize &size) const override; +#ifdef HAS_PROTO_MESSAGE_DUMP + void dump_to(std::string &out) const override; +#endif + + protected: +}; +class WaterHeaterStateResponse final : public StateResponseProtoMessage { + public: + static constexpr uint8_t MESSAGE_TYPE = 133; + static constexpr uint8_t ESTIMATED_SIZE = 35; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *message_name() const override { return "water_heater_state_response"; } +#endif + float current_temperature{0.0f}; + float target_temperature{0.0f}; + enums::WaterHeaterMode mode{}; + uint32_t state{0}; + float target_temperature_low{0.0f}; + float target_temperature_high{0.0f}; + void encode(ProtoWriteBuffer buffer) const override; + void calculate_size(ProtoSize &size) const override; +#ifdef HAS_PROTO_MESSAGE_DUMP + void dump_to(std::string &out) const override; +#endif + + protected: +}; +class WaterHeaterCommandRequest final : public CommandProtoMessage { + public: + static constexpr uint8_t MESSAGE_TYPE = 134; + static constexpr uint8_t ESTIMATED_SIZE = 34; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *message_name() const override { return "water_heater_command_request"; } +#endif + uint32_t has_fields{0}; + enums::WaterHeaterMode mode{}; + float target_temperature{0.0f}; + uint32_t state{0}; + float target_temperature_low{0.0f}; + float target_temperature_high{0.0f}; +#ifdef HAS_PROTO_MESSAGE_DUMP + void dump_to(std::string &out) const override; +#endif + + protected: + bool decode_32bit(uint32_t field_id, Proto32Bit value) override; + bool decode_varint(uint32_t field_id, ProtoVarInt value) override; +}; +#endif #ifdef USE_NUMBER class ListEntitiesNumberResponse final : public InfoResponseProtoMessage { public: diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 5e271f41cb6..9faf39e29e9 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -348,6 +348,47 @@ template<> const char *proto_enum_to_string(enums::Climate } } #endif +#ifdef USE_WATER_HEATER +template<> const char *proto_enum_to_string(enums::WaterHeaterMode value) { + switch (value) { + case enums::WATER_HEATER_MODE_OFF: + return "WATER_HEATER_MODE_OFF"; + case enums::WATER_HEATER_MODE_ECO: + return "WATER_HEATER_MODE_ECO"; + case enums::WATER_HEATER_MODE_ELECTRIC: + return "WATER_HEATER_MODE_ELECTRIC"; + case enums::WATER_HEATER_MODE_PERFORMANCE: + return "WATER_HEATER_MODE_PERFORMANCE"; + case enums::WATER_HEATER_MODE_HIGH_DEMAND: + return "WATER_HEATER_MODE_HIGH_DEMAND"; + case enums::WATER_HEATER_MODE_HEAT_PUMP: + return "WATER_HEATER_MODE_HEAT_PUMP"; + case enums::WATER_HEATER_MODE_GAS: + return "WATER_HEATER_MODE_GAS"; + default: + return "UNKNOWN"; + } +} +#endif +template<> +const char *proto_enum_to_string(enums::WaterHeaterCommandHasField value) { + switch (value) { + case enums::WATER_HEATER_COMMAND_HAS_NONE: + return "WATER_HEATER_COMMAND_HAS_NONE"; + case enums::WATER_HEATER_COMMAND_HAS_MODE: + return "WATER_HEATER_COMMAND_HAS_MODE"; + case enums::WATER_HEATER_COMMAND_HAS_TARGET_TEMPERATURE: + return "WATER_HEATER_COMMAND_HAS_TARGET_TEMPERATURE"; + case enums::WATER_HEATER_COMMAND_HAS_STATE: + return "WATER_HEATER_COMMAND_HAS_STATE"; + case enums::WATER_HEATER_COMMAND_HAS_TARGET_TEMPERATURE_LOW: + return "WATER_HEATER_COMMAND_HAS_TARGET_TEMPERATURE_LOW"; + case enums::WATER_HEATER_COMMAND_HAS_TARGET_TEMPERATURE_HIGH: + return "WATER_HEATER_COMMAND_HAS_TARGET_TEMPERATURE_HIGH"; + default: + return "UNKNOWN"; + } +} #ifdef USE_NUMBER template<> const char *proto_enum_to_string(enums::NumberMode value) { switch (value) { @@ -923,7 +964,9 @@ void FanCommandRequest::dump_to(std::string &out) const { dump_field(out, "has_speed_level", this->has_speed_level); dump_field(out, "speed_level", this->speed_level); dump_field(out, "has_preset_mode", this->has_preset_mode); - dump_field(out, "preset_mode", this->preset_mode); + out.append(" preset_mode: "); + out.append(format_hex_pretty(this->preset_mode, this->preset_mode_len)); + out.append("\n"); #ifdef USE_DEVICES dump_field(out, "device_id", this->device_id); #endif @@ -1113,7 +1156,7 @@ void SubscribeLogsResponse::dump_to(std::string &out) const { void NoiseEncryptionSetKeyRequest::dump_to(std::string &out) const { MessageDumpHelper helper(out, "NoiseEncryptionSetKeyRequest"); out.append(" key: "); - out.append(format_hex_pretty(reinterpret_cast(this->key.data()), this->key.size())); + out.append(format_hex_pretty(this->key, this->key_len)); out.append("\n"); } void NoiseEncryptionSetKeyResponse::dump_to(std::string &out) const { dump_field(out, "success", this->success); } @@ -1182,9 +1225,15 @@ void SubscribeHomeAssistantStateResponse::dump_to(std::string &out) const { } void HomeAssistantStateResponse::dump_to(std::string &out) const { MessageDumpHelper helper(out, "HomeAssistantStateResponse"); - dump_field(out, "entity_id", this->entity_id); - dump_field(out, "state", this->state); - dump_field(out, "attribute", this->attribute); + out.append(" entity_id: "); + out.append(format_hex_pretty(this->entity_id, this->entity_id_len)); + out.append("\n"); + out.append(" state: "); + out.append(format_hex_pretty(this->state, this->state_len)); + out.append("\n"); + out.append(" attribute: "); + out.append(format_hex_pretty(this->attribute, this->attribute_len)); + out.append("\n"); } #endif void GetTimeRequest::dump_to(std::string &out) const { out.append("GetTimeRequest {}"); } @@ -1374,11 +1423,15 @@ void ClimateCommandRequest::dump_to(std::string &out) const { dump_field(out, "has_swing_mode", this->has_swing_mode); dump_field(out, "swing_mode", static_cast(this->swing_mode)); dump_field(out, "has_custom_fan_mode", this->has_custom_fan_mode); - dump_field(out, "custom_fan_mode", this->custom_fan_mode); + out.append(" custom_fan_mode: "); + out.append(format_hex_pretty(this->custom_fan_mode, this->custom_fan_mode_len)); + out.append("\n"); dump_field(out, "has_preset", this->has_preset); dump_field(out, "preset", static_cast(this->preset)); dump_field(out, "has_custom_preset", this->has_custom_preset); - dump_field(out, "custom_preset", this->custom_preset); + out.append(" custom_preset: "); + out.append(format_hex_pretty(this->custom_preset, this->custom_preset_len)); + out.append("\n"); dump_field(out, "has_target_humidity", this->has_target_humidity); dump_field(out, "target_humidity", this->target_humidity); #ifdef USE_DEVICES @@ -1386,6 +1439,55 @@ void ClimateCommandRequest::dump_to(std::string &out) const { #endif } #endif +#ifdef USE_WATER_HEATER +void ListEntitiesWaterHeaterResponse::dump_to(std::string &out) const { + MessageDumpHelper helper(out, "ListEntitiesWaterHeaterResponse"); + dump_field(out, "object_id", this->object_id_ref_); + dump_field(out, "key", this->key); + dump_field(out, "name", this->name_ref_); +#ifdef USE_ENTITY_ICON + dump_field(out, "icon", this->icon_ref_); +#endif + dump_field(out, "disabled_by_default", this->disabled_by_default); + dump_field(out, "entity_category", static_cast(this->entity_category)); +#ifdef USE_DEVICES + dump_field(out, "device_id", this->device_id); +#endif + dump_field(out, "min_temperature", this->min_temperature); + dump_field(out, "max_temperature", this->max_temperature); + dump_field(out, "target_temperature_step", this->target_temperature_step); + for (const auto &it : *this->supported_modes) { + dump_field(out, "supported_modes", static_cast(it), 4); + } + dump_field(out, "supported_features", this->supported_features); +} +void WaterHeaterStateResponse::dump_to(std::string &out) const { + MessageDumpHelper helper(out, "WaterHeaterStateResponse"); + dump_field(out, "key", this->key); + dump_field(out, "current_temperature", this->current_temperature); + dump_field(out, "target_temperature", this->target_temperature); + dump_field(out, "mode", static_cast(this->mode)); +#ifdef USE_DEVICES + dump_field(out, "device_id", this->device_id); +#endif + dump_field(out, "state", this->state); + dump_field(out, "target_temperature_low", this->target_temperature_low); + dump_field(out, "target_temperature_high", this->target_temperature_high); +} +void WaterHeaterCommandRequest::dump_to(std::string &out) const { + MessageDumpHelper helper(out, "WaterHeaterCommandRequest"); + dump_field(out, "key", this->key); + dump_field(out, "has_fields", this->has_fields); + dump_field(out, "mode", static_cast(this->mode)); + dump_field(out, "target_temperature", this->target_temperature); +#ifdef USE_DEVICES + dump_field(out, "device_id", this->device_id); +#endif + dump_field(out, "state", this->state); + dump_field(out, "target_temperature_low", this->target_temperature_low); + dump_field(out, "target_temperature_high", this->target_temperature_high); +} +#endif #ifdef USE_NUMBER void ListEntitiesNumberResponse::dump_to(std::string &out) const { MessageDumpHelper helper(out, "ListEntitiesNumberResponse"); diff --git a/esphome/components/api/api_pb2_includes.h b/esphome/components/api/api_pb2_includes.h index 55d95304b19..f45e091c6f4 100644 --- a/esphome/components/api/api_pb2_includes.h +++ b/esphome/components/api/api_pb2_includes.h @@ -10,6 +10,10 @@ #include "esphome/components/climate/climate_traits.h" #endif +#ifdef USE_WATER_HEATER +#include "esphome/components/water_heater/water_heater.h" +#endif + #ifdef USE_LIGHT #include "esphome/components/light/light_traits.h" #endif diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 45f6ecd30e7..984cb0bb6e7 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -621,6 +621,17 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, this->on_homeassistant_action_response(msg); break; } +#endif +#ifdef USE_WATER_HEATER + case WaterHeaterCommandRequest::MESSAGE_TYPE: { + WaterHeaterCommandRequest msg; + msg.decode(msg_data, msg_size); +#ifdef HAS_PROTO_MESSAGE_DUMP + ESP_LOGVV(TAG, "on_water_heater_command_request: %s", msg.dump().c_str()); +#endif + this->on_water_heater_command_request(msg); + break; + } #endif default: break; diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index 6d94046a23a..261d9fbd278 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -91,6 +91,10 @@ class APIServerConnectionBase : public ProtoService { virtual void on_climate_command_request(const ClimateCommandRequest &value){}; #endif +#ifdef USE_WATER_HEATER + virtual void on_water_heater_command_request(const WaterHeaterCommandRequest &value){}; +#endif + #ifdef USE_NUMBER virtual void on_number_command_request(const NumberCommandRequest &value){}; #endif diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index b1a5ee5d57a..7a03d8f8ad6 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -335,6 +335,10 @@ API_DISPATCH_UPDATE(valve::Valve, valve) API_DISPATCH_UPDATE(media_player::MediaPlayer, media_player) #endif +#ifdef USE_WATER_HEATER +API_DISPATCH_UPDATE(water_heater::WaterHeater, water_heater) +#endif + #ifdef USE_EVENT // Event is a special case - unlike other entities with simple state fields, // events store their state in a member accessed via obj->get_last_event_type() diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index ad7d8bf63d1..96c56fd08a5 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -133,6 +133,9 @@ class APIServer : public Component, #ifdef USE_MEDIA_PLAYER void on_media_player_update(media_player::MediaPlayer *obj) override; #endif +#ifdef USE_WATER_HEATER + void on_water_heater_update(water_heater::WaterHeater *obj) override; +#endif #ifdef USE_API_HOMEASSISTANT_SERVICES void send_homeassistant_action(const HomeassistantActionRequest &call); diff --git a/esphome/components/api/list_entities.cpp b/esphome/components/api/list_entities.cpp index b4d14541538..2470899c93e 100644 --- a/esphome/components/api/list_entities.cpp +++ b/esphome/components/api/list_entities.cpp @@ -73,6 +73,9 @@ LIST_ENTITIES_HANDLER(media_player, media_player::MediaPlayer, ListEntitiesMedia LIST_ENTITIES_HANDLER(alarm_control_panel, alarm_control_panel::AlarmControlPanel, ListEntitiesAlarmControlPanelResponse) #endif +#ifdef USE_WATER_HEATER +LIST_ENTITIES_HANDLER(water_heater, water_heater::WaterHeater, ListEntitiesWaterHeaterResponse) +#endif #ifdef USE_EVENT LIST_ENTITIES_HANDLER(event, event::Event, ListEntitiesEventResponse) #endif diff --git a/esphome/components/api/list_entities.h b/esphome/components/api/list_entities.h index 4c90dbbad80..04e6525eb0a 100644 --- a/esphome/components/api/list_entities.h +++ b/esphome/components/api/list_entities.h @@ -82,6 +82,9 @@ class ListEntitiesIterator : public ComponentIterator { #ifdef USE_ALARM_CONTROL_PANEL bool on_alarm_control_panel(alarm_control_panel::AlarmControlPanel *entity) override; #endif +#ifdef USE_WATER_HEATER + bool on_water_heater(water_heater::WaterHeater *entity) override; +#endif #ifdef USE_EVENT bool on_event(event::Event *entity) override; #endif diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 83b6922be1a..efdab9341cf 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -334,7 +334,7 @@ class ProtoWriteBuffer { void encode_sint64(uint32_t field_id, int64_t value, bool force = false) { this->encode_uint64(field_id, encode_zigzag64(value), force); } - void encode_message(uint32_t field_id, const ProtoMessage &value, bool force = false); + void encode_message(uint32_t field_id, const ProtoMessage &value); std::vector *get_buffer() const { return buffer_; } protected: @@ -795,7 +795,7 @@ class ProtoSize { }; // Implementation of encode_message - must be after ProtoMessage is defined -inline void ProtoWriteBuffer::encode_message(uint32_t field_id, const ProtoMessage &value, bool force) { +inline void ProtoWriteBuffer::encode_message(uint32_t field_id, const ProtoMessage &value) { this->encode_field_raw(field_id, 2); // type 2: Length-delimited message // Calculate the message size first diff --git a/esphome/components/api/subscribe_state.cpp b/esphome/components/api/subscribe_state.cpp index 3a563f2221f..4bbc17018e9 100644 --- a/esphome/components/api/subscribe_state.cpp +++ b/esphome/components/api/subscribe_state.cpp @@ -60,6 +60,9 @@ INITIAL_STATE_HANDLER(media_player, media_player::MediaPlayer) #ifdef USE_ALARM_CONTROL_PANEL INITIAL_STATE_HANDLER(alarm_control_panel, alarm_control_panel::AlarmControlPanel) #endif +#ifdef USE_WATER_HEATER +INITIAL_STATE_HANDLER(water_heater, water_heater::WaterHeater) +#endif #ifdef USE_UPDATE INITIAL_STATE_HANDLER(update, update::UpdateEntity) #endif diff --git a/esphome/components/api/subscribe_state.h b/esphome/components/api/subscribe_state.h index 2c22c322eca..9230000acef 100644 --- a/esphome/components/api/subscribe_state.h +++ b/esphome/components/api/subscribe_state.h @@ -76,6 +76,9 @@ class InitialStateIterator : public ComponentIterator { #ifdef USE_ALARM_CONTROL_PANEL bool on_alarm_control_panel(alarm_control_panel::AlarmControlPanel *entity) override; #endif +#ifdef USE_WATER_HEATER + bool on_water_heater(water_heater::WaterHeater *entity) override; +#endif #ifdef USE_EVENT bool on_event(event::Event *event) override { return true; }; #endif diff --git a/esphome/components/aqi/__init__.py b/esphome/components/aqi/__init__.py new file mode 100644 index 00000000000..4b979ab406f --- /dev/null +++ b/esphome/components/aqi/__init__.py @@ -0,0 +1,14 @@ +import esphome.codegen as cg + +CODEOWNERS = ["@jasstrong", "@ximex", "@freekode"] + +aqi_ns = cg.esphome_ns.namespace("aqi") +AQICalculatorType = aqi_ns.enum("AQICalculatorType") + +CONF_AQI = "aqi" +CONF_CALCULATION_TYPE = "calculation_type" + +AQI_CALCULATION_TYPE = { + "CAQI": AQICalculatorType.CAQI_TYPE, + "AQI": AQICalculatorType.AQI_TYPE, +} diff --git a/esphome/components/hm3301/abstract_aqi_calculator.h b/esphome/components/aqi/abstract_aqi_calculator.h similarity index 64% rename from esphome/components/hm3301/abstract_aqi_calculator.h rename to esphome/components/aqi/abstract_aqi_calculator.h index 038828e9de1..7836c76cdc1 100644 --- a/esphome/components/hm3301/abstract_aqi_calculator.h +++ b/esphome/components/aqi/abstract_aqi_calculator.h @@ -2,13 +2,11 @@ #include -namespace esphome { -namespace hm3301 { +namespace esphome::aqi { class AbstractAQICalculator { public: virtual uint16_t get_aqi(uint16_t pm2_5_value, uint16_t pm10_0_value) = 0; }; -} // namespace hm3301 -} // namespace esphome +} // namespace esphome::aqi diff --git a/esphome/components/hm3301/aqi_calculator.h b/esphome/components/aqi/aqi_calculator.h similarity index 94% rename from esphome/components/hm3301/aqi_calculator.h rename to esphome/components/aqi/aqi_calculator.h index aa01060d2c8..959d6a2438a 100644 --- a/esphome/components/hm3301/aqi_calculator.h +++ b/esphome/components/aqi/aqi_calculator.h @@ -1,10 +1,11 @@ #pragma once + #include #include "abstract_aqi_calculator.h" + // https://document.airnow.gov/technical-assistance-document-for-the-reporting-of-daily-air-quailty.pdf -namespace esphome { -namespace hm3301 { +namespace esphome::aqi { class AQICalculator : public AbstractAQICalculator { public: @@ -28,6 +29,9 @@ class AQICalculator : public AbstractAQICalculator { int calculate_index_(uint16_t value, int array[AMOUNT_OF_LEVELS][2]) { int grid_index = get_grid_index_(value, array); + if (grid_index == -1) { + return -1; + } int aqi_lo = index_grid_[grid_index][0]; int aqi_hi = index_grid_[grid_index][1]; int conc_lo = array[grid_index][0]; @@ -46,5 +50,4 @@ class AQICalculator : public AbstractAQICalculator { } }; -} // namespace hm3301 -} // namespace esphome +} // namespace esphome::aqi diff --git a/esphome/components/hm3301/aqi_calculator_factory.h b/esphome/components/aqi/aqi_calculator_factory.h similarity index 55% rename from esphome/components/hm3301/aqi_calculator_factory.h rename to esphome/components/aqi/aqi_calculator_factory.h index 55608b6e51e..db7eaab1bbc 100644 --- a/esphome/components/hm3301/aqi_calculator_factory.h +++ b/esphome/components/aqi/aqi_calculator_factory.h @@ -3,8 +3,7 @@ #include "caqi_calculator.h" #include "aqi_calculator.h" -namespace esphome { -namespace hm3301 { +namespace esphome::aqi { enum AQICalculatorType { CAQI_TYPE = 0, AQI_TYPE = 1 }; @@ -12,18 +11,17 @@ class AQICalculatorFactory { public: AbstractAQICalculator *get_calculator(AQICalculatorType type) { if (type == 0) { - return caqi_calculator_; + return &this->caqi_calculator_; } else if (type == 1) { - return aqi_calculator_; + return &this->aqi_calculator_; } return nullptr; } protected: - CAQICalculator *caqi_calculator_ = new CAQICalculator(); - AQICalculator *aqi_calculator_ = new AQICalculator(); + CAQICalculator caqi_calculator_; + AQICalculator aqi_calculator_; }; -} // namespace hm3301 -} // namespace esphome +} // namespace esphome::aqi diff --git a/esphome/components/hm3301/caqi_calculator.h b/esphome/components/aqi/caqi_calculator.h similarity index 94% rename from esphome/components/hm3301/caqi_calculator.h rename to esphome/components/aqi/caqi_calculator.h index 3f338776d88..d493dcdf39c 100644 --- a/esphome/components/hm3301/caqi_calculator.h +++ b/esphome/components/aqi/caqi_calculator.h @@ -3,8 +3,7 @@ #include "esphome/core/log.h" #include "abstract_aqi_calculator.h" -namespace esphome { -namespace hm3301 { +namespace esphome::aqi { class CAQICalculator : public AbstractAQICalculator { public: @@ -48,5 +47,4 @@ class CAQICalculator : public AbstractAQICalculator { } }; -} // namespace hm3301 -} // namespace esphome +} // namespace esphome::aqi diff --git a/esphome/components/bme68x_bsec2_i2c/__init__.py b/esphome/components/bme68x_bsec2_i2c/__init__.py index d6fb7fa9be8..c8ca0ba022c 100644 --- a/esphome/components/bme68x_bsec2_i2c/__init__.py +++ b/esphome/components/bme68x_bsec2_i2c/__init__.py @@ -11,6 +11,7 @@ CODEOWNERS = ["@neffs", "@kbx81"] AUTO_LOAD = ["bme68x_bsec2"] DEPENDENCIES = ["i2c"] +MULTI_CONF = True bme68x_bsec2_i2c_ns = cg.esphome_ns.namespace("bme68x_bsec2_i2c") BME68xBSEC2I2CComponent = bme68x_bsec2_i2c_ns.class_( diff --git a/esphome/components/button/button.h b/esphome/components/button/button.h index 18122f6f2f6..be6e080917b 100644 --- a/esphome/components/button/button.h +++ b/esphome/components/button/button.h @@ -41,7 +41,7 @@ class Button : public EntityBase, public EntityBase_DeviceClass { */ virtual void press_action() = 0; - CallbackManager press_callback_{}; + LazyCallbackManager press_callback_{}; }; } // namespace esphome::button diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 4eb00835b1f..e1f92d2d2b4 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -65,12 +65,6 @@ void CaptivePortal::start() { this->base_->init(); if (!this->initialized_) { this->base_->add_handler(this); -#ifdef USE_ESP32 - // Enable LRU socket purging to handle captive portal detection probe bursts - // OS captive portal detection makes many simultaneous HTTP requests which can - // exhaust sockets. LRU purging automatically closes oldest idle connections. - this->base_->get_server()->set_lru_purge_enable(true); -#endif } network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip(); diff --git a/esphome/components/captive_portal/captive_portal.h b/esphome/components/captive_portal/captive_portal.h index ae9b9dfba0f..f48c286f0ce 100644 --- a/esphome/components/captive_portal/captive_portal.h +++ b/esphome/components/captive_portal/captive_portal.h @@ -40,10 +40,6 @@ class CaptivePortal : public AsyncWebHandler, public Component { void end() { this->active_ = false; this->disable_loop(); // Stop processing DNS requests -#ifdef USE_ESP32 - // Disable LRU socket purging now that captive portal is done - this->base_->get_server()->set_lru_purge_enable(false); -#endif this->base_->deinit(); if (this->dns_server_ != nullptr) { this->dns_server_->stop(); diff --git a/esphome/components/cc1101/__init__.py b/esphome/components/cc1101/__init__.py index e6b31b84f81..c205ff2f695 100644 --- a/esphome/components/cc1101/__init__.py +++ b/esphome/components/cc1101/__init__.py @@ -1,9 +1,17 @@ -from esphome import automation +from esphome import automation, pins from esphome.automation import maybe_simple_id import esphome.codegen as cg from esphome.components import spi +from esphome.components.const import CONF_CRC_ENABLE, CONF_ON_PACKET import esphome.config_validation as cv -from esphome.const import CONF_CHANNEL, CONF_FREQUENCY, CONF_ID, CONF_WAIT_TIME +from esphome.const import ( + CONF_CHANNEL, + CONF_DATA, + CONF_FREQUENCY, + CONF_ID, + CONF_WAIT_TIME, +) +from esphome.core import ID CODEOWNERS = ["@lygris", "@gabest11"] DEPENDENCIES = ["spi"] @@ -29,7 +37,6 @@ CONF_MANCHESTER = "manchester" CONF_NUM_PREAMBLE = "num_preamble" CONF_SYNC1 = "sync1" CONF_SYNC0 = "sync0" -CONF_PKTLEN = "pktlen" CONF_MAGN_TARGET = "magn_target" CONF_MAX_LNA_GAIN = "max_lna_gain" CONF_MAX_DVGA_GAIN = "max_dvga_gain" @@ -41,6 +48,12 @@ CONF_FILTER_LENGTH_ASK_OOK = "filter_length_ask_ook" CONF_FREEZE = "freeze" CONF_HYST_LEVEL = "hyst_level" +# Packet mode config keys +CONF_PACKET_MODE = "packet_mode" +CONF_PACKET_LENGTH = "packet_length" +CONF_WHITENING = "whitening" +CONF_GDO0_PIN = "gdo0_pin" + # Enums SyncMode = ns.enum("SyncMode", True) SYNC_MODE = { @@ -147,45 +160,89 @@ HYST_LEVEL = { "High": HystLevel.HYST_LEVEL_HIGH, } -# Config key -> Validator mapping +# Optional settings to generate setter calls for CONFIG_MAP = { - CONF_OUTPUT_POWER: cv.float_range(min=-30.0, max=11.0), - CONF_RX_ATTENUATION: cv.enum(RX_ATTENUATION, upper=False), - CONF_DC_BLOCKING_FILTER: cv.boolean, - CONF_FREQUENCY: cv.All(cv.frequency, cv.float_range(min=300000000, max=928000000)), - CONF_IF_FREQUENCY: cv.All(cv.frequency, cv.float_range(min=25000, max=788000)), - CONF_FILTER_BANDWIDTH: cv.All(cv.frequency, cv.float_range(min=58000, max=812000)), - CONF_CHANNEL: cv.uint8_t, - CONF_CHANNEL_SPACING: cv.All(cv.frequency, cv.float_range(min=25000, max=405000)), - CONF_FSK_DEVIATION: cv.All(cv.frequency, cv.float_range(min=1500, max=381000)), - CONF_MSK_DEVIATION: cv.int_range(min=1, max=8), - CONF_SYMBOL_RATE: cv.float_range(min=600, max=500000), - CONF_SYNC_MODE: cv.enum(SYNC_MODE, upper=False), - CONF_CARRIER_SENSE_ABOVE_THRESHOLD: cv.boolean, - CONF_MODULATION_TYPE: cv.enum(MODULATION, upper=False), - CONF_MANCHESTER: cv.boolean, - CONF_NUM_PREAMBLE: cv.int_range(min=0, max=7), - CONF_SYNC1: cv.hex_uint8_t, - CONF_SYNC0: cv.hex_uint8_t, - CONF_PKTLEN: cv.uint8_t, - CONF_MAGN_TARGET: cv.enum(MAGN_TARGET, upper=False), - CONF_MAX_LNA_GAIN: cv.enum(MAX_LNA_GAIN, upper=False), - CONF_MAX_DVGA_GAIN: cv.enum(MAX_DVGA_GAIN, upper=False), - CONF_CARRIER_SENSE_ABS_THR: cv.int_range(min=-8, max=7), - CONF_CARRIER_SENSE_REL_THR: cv.enum(CARRIER_SENSE_REL_THR, upper=False), - CONF_LNA_PRIORITY: cv.boolean, - CONF_FILTER_LENGTH_FSK_MSK: cv.enum(FILTER_LENGTH_FSK_MSK, upper=False), - CONF_FILTER_LENGTH_ASK_OOK: cv.enum(FILTER_LENGTH_ASK_OOK, upper=False), - CONF_FREEZE: cv.enum(FREEZE, upper=False), - CONF_WAIT_TIME: cv.enum(WAIT_TIME, upper=False), - CONF_HYST_LEVEL: cv.enum(HYST_LEVEL, upper=False), + cv.Optional(CONF_OUTPUT_POWER, default=10): cv.float_range(min=-30.0, max=11.0), + cv.Optional(CONF_RX_ATTENUATION, default="0dB"): cv.enum( + RX_ATTENUATION, upper=False + ), + cv.Optional(CONF_DC_BLOCKING_FILTER, default=True): cv.boolean, + cv.Optional(CONF_FREQUENCY, default="433.92MHz"): cv.All( + cv.frequency, cv.float_range(min=300.0e6, max=928.0e6) + ), + cv.Optional(CONF_IF_FREQUENCY, default="153kHz"): cv.All( + cv.frequency, cv.float_range(min=25000, max=788000) + ), + cv.Optional(CONF_FILTER_BANDWIDTH, default="203kHz"): cv.All( + cv.frequency, cv.float_range(min=58000, max=812000) + ), + cv.Optional(CONF_CHANNEL, default=0): cv.uint8_t, + cv.Optional(CONF_CHANNEL_SPACING, default="200kHz"): cv.All( + cv.frequency, cv.float_range(min=25000, max=405000) + ), + cv.Optional(CONF_FSK_DEVIATION): cv.All( + cv.frequency, cv.float_range(min=1500, max=381000) + ), + cv.Optional(CONF_MSK_DEVIATION): cv.int_range(min=1, max=8), + cv.Optional(CONF_SYMBOL_RATE, default=5000): cv.float_range(min=600, max=500000), + cv.Optional(CONF_SYNC_MODE, default="16/16"): cv.enum(SYNC_MODE, upper=False), + cv.Optional(CONF_CARRIER_SENSE_ABOVE_THRESHOLD, default=False): cv.boolean, + cv.Optional(CONF_MODULATION_TYPE, default="ASK/OOK"): cv.enum( + MODULATION, upper=False + ), + cv.Optional(CONF_MANCHESTER, default=False): cv.boolean, + cv.Optional(CONF_NUM_PREAMBLE, default=2): cv.int_range(min=0, max=7), + cv.Optional(CONF_SYNC1, default=0xD3): cv.hex_uint8_t, + cv.Optional(CONF_SYNC0, default=0x91): cv.hex_uint8_t, + cv.Optional(CONF_MAGN_TARGET, default="42dB"): cv.enum(MAGN_TARGET, upper=False), + cv.Optional(CONF_MAX_LNA_GAIN, default="Default"): cv.enum( + MAX_LNA_GAIN, upper=False + ), + cv.Optional(CONF_MAX_DVGA_GAIN, default="-3"): cv.enum(MAX_DVGA_GAIN, upper=False), + cv.Optional(CONF_CARRIER_SENSE_ABS_THR): cv.int_range(min=-8, max=7), + cv.Optional(CONF_CARRIER_SENSE_REL_THR): cv.enum( + CARRIER_SENSE_REL_THR, upper=False + ), + cv.Optional(CONF_LNA_PRIORITY, default=False): cv.boolean, + cv.Optional(CONF_FILTER_LENGTH_FSK_MSK): cv.enum( + FILTER_LENGTH_FSK_MSK, upper=False + ), + cv.Optional(CONF_FILTER_LENGTH_ASK_OOK): cv.enum( + FILTER_LENGTH_ASK_OOK, upper=False + ), + 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_PACKET_MODE, default=False): cv.boolean, + cv.Optional(CONF_PACKET_LENGTH): cv.uint8_t, + cv.Optional(CONF_CRC_ENABLE, default=False): cv.boolean, + cv.Optional(CONF_WHITENING, default=False): cv.boolean, } -CONFIG_SCHEMA = ( - cv.Schema({cv.GenerateID(): cv.declare_id(CC1101Component)}) - .extend({cv.Optional(key): validator for key, validator in CONFIG_MAP.items()}) + +def _validate_packet_mode(config): + if config.get(CONF_PACKET_MODE, False): + if CONF_GDO0_PIN not in config: + raise cv.Invalid("gdo0_pin is required when packet_mode is enabled") + if CONF_PACKET_LENGTH not in config: + raise cv.Invalid("packet_length is required when packet_mode is enabled") + if config[CONF_PACKET_LENGTH] > 64: + raise cv.Invalid("packet_length must be <= 64 (FIFO size)") + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(CC1101Component), + cv.Optional(CONF_GDO0_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_ON_PACKET): automation.validate_automation(single=True), + } + ) + .extend(CONFIG_MAP) .extend(cv.COMPONENT_SCHEMA) - .extend(spi.spi_device_schema(cs_pin_required=True)) + .extend(spi.spi_device_schema(cs_pin_required=True)), + _validate_packet_mode, ) @@ -194,16 +251,34 @@ async def to_code(config): await cg.register_component(var, config) await spi.register_spi_device(var, config) - for key in CONFIG_MAP: + for opt in CONFIG_MAP: + key = opt.schema if key in config: cg.add(getattr(var, f"set_{key}")(config[key])) + if CONF_GDO0_PIN in config: + gdo0_pin = await cg.gpio_pin_expression(config[CONF_GDO0_PIN]) + cg.add(var.set_gdo0_pin(gdo0_pin)) + if CONF_ON_PACKET in config: + await automation.build_automation( + var.get_packet_trigger(), + [ + (cg.std_vector.template(cg.uint8), "x"), + (cg.float_, "rssi"), + (cg.uint8, "lqi"), + ], + config[CONF_ON_PACKET], + ) + # Actions BeginTxAction = ns.class_("BeginTxAction", automation.Action) BeginRxAction = ns.class_("BeginRxAction", automation.Action) ResetAction = ns.class_("ResetAction", automation.Action) SetIdleAction = ns.class_("SetIdleAction", automation.Action) +SendPacketAction = ns.class_( + "SendPacketAction", automation.Action, cg.Parented.template(CC1101Component) +) CC1101_ACTION_SCHEMA = cv.Schema( maybe_simple_id({cv.GenerateID(CONF_ID): cv.use_id(CC1101Component)}) @@ -218,3 +293,42 @@ async def cc1101_action_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var + + +def validate_raw_data(value): + if isinstance(value, str): + return value.encode("utf-8") + if isinstance(value, list): + return cv.Schema([cv.hex_uint8_t])(value) + raise cv.Invalid( + "data must either be a string wrapped in quotes or a list of bytes" + ) + + +SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(CC1101Component), + cv.Required(CONF_DATA): cv.templatable(validate_raw_data), + }, + key=CONF_DATA, +) + + +@automation.register_action( + "cc1101.send_packet", SendPacketAction, SEND_PACKET_ACTION_SCHEMA +) +async def send_packet_action_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + data = config[CONF_DATA] + if isinstance(data, bytes): + data = list(data) + if cg.is_template(data): + templ = await cg.templatable(data, args, cg.std_vector.template(cg.uint8)) + cg.add(var.set_data_template(templ)) + else: + # Generate static array in flash to avoid RAM copy + arr_id = ID(f"{action_id}_data", is_declaration=True, type=cg.uint8) + arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*data)) + cg.add(var.set_data_static(arr, len(data))) + return var diff --git a/esphome/components/cc1101/cc1101.cpp b/esphome/components/cc1101/cc1101.cpp index 3cbf09ded87..f98afd94a19 100644 --- a/esphome/components/cc1101/cc1101.cpp +++ b/esphome/components/cc1101/cc1101.cpp @@ -98,25 +98,8 @@ CC1101Component::CC1101Component() { this->state_.LENGTH_CONFIG = 2; this->state_.FS_AUTOCAL = 1; - // Default Settings - this->set_frequency(433920); - this->set_if_frequency(153); - this->set_filter_bandwidth(203); - this->set_channel(0); - this->set_channel_spacing(200); - this->set_symbol_rate(5000); - this->set_sync_mode(SyncMode::SYNC_MODE_NONE); - this->set_carrier_sense_above_threshold(true); - this->set_modulation_type(Modulation::MODULATION_ASK_OOK); - this->set_magn_target(MagnTarget::MAGN_TARGET_42DB); - this->set_max_lna_gain(MaxLnaGain::MAX_LNA_GAIN_DEFAULT); - this->set_max_dvga_gain(MaxDvgaGain::MAX_DVGA_GAIN_MINUS_3); - this->set_lna_priority(false); - this->set_wait_time(WaitTime::WAIT_TIME_32_SAMPLES); - // CRITICAL: Initialize PA Table to avoid transmitting 0 power (Silence) memset(this->pa_table_, 0, sizeof(this->pa_table_)); - this->set_output_power(10.0f); } void CC1101Component::setup() { @@ -143,6 +126,11 @@ void CC1101Component::setup() { return; } + // Setup GDO0 pin if configured + if (this->gdo0_pin_ != nullptr) { + this->gdo0_pin_->setup(); + } + this->initialized_ = true; for (uint8_t i = 0; i <= static_cast(Register::TEST0); i++) { @@ -151,8 +139,69 @@ void CC1101Component::setup() { } this->write_(static_cast(i)); } - this->write_(Register::PATABLE, this->pa_table_, sizeof(this->pa_table_)); + this->set_output_power(this->output_power_requested_); this->strobe_(Command::RX); + + // Defer pin mode setup until after all components have completed setup() + // This handles the case where remote_transmitter runs after CC1101 and changes pin mode + if (this->gdo0_pin_ != nullptr) { + this->defer([this]() { this->gdo0_pin_->pin_mode(gpio::FLAG_INPUT); }); + } +} + +void CC1101Component::loop() { + if (this->state_.PKT_FORMAT != static_cast(PacketFormat::PACKET_FORMAT_FIFO) || this->gdo0_pin_ == nullptr || + !this->gdo0_pin_->digital_read()) { + return; + } + + // Read state + this->read_(Register::RXBYTES); + uint8_t rx_bytes = this->state_.NUM_RXBYTES; + bool overflow = this->state_.RXFIFO_OVERFLOW; + if (overflow || rx_bytes == 0) { + ESP_LOGW(TAG, "RX FIFO overflow, flushing"); + this->enter_idle_(); + this->strobe_(Command::FRX); + this->strobe_(Command::RX); + this->wait_for_state_(State::RX); + return; + } + + // Read packet + uint8_t payload_length; + if (this->state_.LENGTH_CONFIG == static_cast(LengthConfig::LENGTH_CONFIG_VARIABLE)) { + this->read_(Register::FIFO, &payload_length, 1); + } else { + payload_length = this->state_.PKTLEN; + } + if (payload_length == 0 || payload_length > 64) { + ESP_LOGW(TAG, "Invalid payload length: %u", payload_length); + this->enter_idle_(); + this->strobe_(Command::FRX); + this->strobe_(Command::RX); + this->wait_for_state_(State::RX); + return; + } + this->packet_.resize(payload_length); + this->read_(Register::FIFO, this->packet_.data(), payload_length); + + // Read status and trigger + uint8_t status[2]; + this->read_(Register::FIFO, status, 2); + int8_t rssi_raw = static_cast(status[0]); + float rssi = (rssi_raw * RSSI_STEP) - RSSI_OFFSET; + bool crc_ok = (status[1] & STATUS_CRC_OK_MASK) != 0; + uint8_t lqi = status[1] & STATUS_LQI_MASK; + if (this->state_.CRC_EN == 0 || crc_ok) { + this->packet_trigger_->trigger(this->packet_, rssi, lqi); + } + + // Return to rx + this->enter_idle_(); + this->strobe_(Command::FRX); + this->strobe_(Command::RX); + this->wait_for_state_(State::RX); } void CC1101Component::dump_config() { @@ -177,9 +226,12 @@ void CC1101Component::dump_config() { } void CC1101Component::begin_tx() { - // Ensure Packet Format is 3 (Async Serial), use GDO0 as input during TX + // Ensure Packet Format is 3 (Async Serial) this->write_(Register::PKTCTRL0, 0x32); ESP_LOGV(TAG, "Beginning TX sequence"); + if (this->gdo0_pin_ != nullptr) { + this->gdo0_pin_->pin_mode(gpio::FLAG_OUTPUT); + } this->strobe_(Command::TX); if (!this->wait_for_state_(State::TX, 50)) { ESP_LOGW(TAG, "Timed out waiting for TX state!"); @@ -188,6 +240,9 @@ void CC1101Component::begin_tx() { void CC1101Component::begin_rx() { ESP_LOGV(TAG, "Beginning RX sequence"); + if (this->gdo0_pin_ != nullptr) { + this->gdo0_pin_->pin_mode(gpio::FLAG_INPUT); + } this->strobe_(Command::RX); } @@ -201,20 +256,6 @@ void CC1101Component::set_idle() { this->enter_idle_(); } -void CC1101Component::set_gdo0_config(uint8_t value) { - this->state_.GDO0_CFG = value; - if (this->initialized_) { - this->write_(Register::IOCFG0); - } -} - -void CC1101Component::set_gdo2_config(uint8_t value) { - this->state_.GDO2_CFG = value; - if (this->initialized_) { - this->write_(Register::IOCFG2); - } -} - bool CC1101Component::wait_for_state_(State target_state, uint32_t timeout_ms) { uint32_t start = millis(); while (millis() - start < timeout_ms) { @@ -282,6 +323,33 @@ void CC1101Component::read_(Register reg, uint8_t *buffer, size_t length) { this->disable(); } +CC1101Error CC1101Component::transmit_packet(const std::vector &packet) { + if (this->state_.PKT_FORMAT != static_cast(PacketFormat::PACKET_FORMAT_FIFO)) { + return CC1101Error::PARAMS; + } + + // Write packet + this->enter_idle_(); + this->strobe_(Command::FTX); + if (this->state_.LENGTH_CONFIG == static_cast(LengthConfig::LENGTH_CONFIG_VARIABLE)) { + this->write_(Register::FIFO, static_cast(packet.size())); + } + this->write_(Register::FIFO, packet.data(), packet.size()); + this->strobe_(Command::TX); + if (!this->wait_for_state_(State::IDLE, 1000)) { + ESP_LOGW(TAG, "TX timeout"); + this->enter_idle_(); + this->strobe_(Command::RX); + this->wait_for_state_(State::RX); + return CC1101Error::TIMEOUT; + } + + // Return to rx + this->strobe_(Command::RX); + this->wait_for_state_(State::RX); + return CC1101Error::NONE; +} + // Setters void CC1101Component::set_output_power(float value) { this->output_power_requested_ = value; @@ -428,6 +496,7 @@ void CC1101Component::set_modulation_type(Modulation value) { this->state_.PA_POWER = value == Modulation::MODULATION_ASK_OOK ? 1 : 0; if (this->initialized_) { this->enter_idle_(); + this->set_output_power(this->output_power_requested_); this->write_(Register::MDMCFG2); this->write_(Register::FREND0); this->strobe_(Command::RX); @@ -462,13 +531,6 @@ void CC1101Component::set_sync0(uint8_t value) { } } -void CC1101Component::set_pktlen(uint8_t value) { - this->state_.PKTLEN = value; - if (this->initialized_) { - this->write_(Register::PKTLEN); - } -} - void CC1101Component::set_magn_target(MagnTarget value) { this->state_.MAGN_TARGET = static_cast(value); if (this->initialized_) { @@ -546,4 +608,50 @@ void CC1101Component::set_hyst_level(HystLevel value) { } } +void CC1101Component::set_packet_mode(bool value) { + this->state_.PKT_FORMAT = + static_cast(value ? PacketFormat::PACKET_FORMAT_FIFO : PacketFormat::PACKET_FORMAT_ASYNC_SERIAL); + if (value) { + // Configure GDO0 for FIFO status (asserts on RX FIFO threshold or end of packet) + this->state_.GDO0_CFG = 0x01; + // Set max RX FIFO threshold to ensure we only trigger on end-of-packet + this->state_.FIFO_THR = 15; + } else { + // Configure GDO0 for serial data (async serial mode) + this->state_.GDO0_CFG = 0x0D; + } + if (this->initialized_) { + this->write_(Register::PKTCTRL0); + this->write_(Register::IOCFG0); + this->write_(Register::FIFOTHR); + } +} + +void CC1101Component::set_packet_length(uint8_t value) { + if (value == 0) { + this->state_.LENGTH_CONFIG = static_cast(LengthConfig::LENGTH_CONFIG_VARIABLE); + } else { + this->state_.LENGTH_CONFIG = static_cast(LengthConfig::LENGTH_CONFIG_FIXED); + this->state_.PKTLEN = value; + } + if (this->initialized_) { + this->write_(Register::PKTCTRL0); + this->write_(Register::PKTLEN); + } +} + +void CC1101Component::set_crc_enable(bool value) { + this->state_.CRC_EN = value ? 1 : 0; + if (this->initialized_) { + this->write_(Register::PKTCTRL0); + } +} + +void CC1101Component::set_whitening(bool value) { + this->state_.WHITE_DATA = value ? 1 : 0; + if (this->initialized_) { + this->write_(Register::PKTCTRL0); + } +} + } // namespace esphome::cc1101 diff --git a/esphome/components/cc1101/cc1101.h b/esphome/components/cc1101/cc1101.h index 65aeb2ea82c..b896f7e9742 100644 --- a/esphome/components/cc1101/cc1101.h +++ b/esphome/components/cc1101/cc1101.h @@ -5,9 +5,12 @@ #include "esphome/components/spi/spi.h" #include "esphome/core/automation.h" #include "cc1101defs.h" +#include namespace esphome::cc1101 { +enum class CC1101Error { NONE = 0, TIMEOUT, PARAMS, CRC_ERROR, FIFO_OVERFLOW }; + class CC1101Component : public Component, public spi::SPIDevice { @@ -15,6 +18,7 @@ class CC1101Component : public Component, CC1101Component(); void setup() override; + void loop() override; void dump_config() override; // Actions @@ -24,8 +28,7 @@ class CC1101Component : public Component, void set_idle(); // GDO Pin Configuration - void set_gdo0_config(uint8_t value); - void set_gdo2_config(uint8_t value); + void set_gdo0_pin(InternalGPIOPin *pin) { this->gdo0_pin_ = pin; } // Configuration Setters void set_output_power(float value); @@ -48,7 +51,6 @@ class CC1101Component : public Component, void set_num_preamble(uint8_t value); void set_sync1(uint8_t value); void set_sync0(uint8_t value); - void set_pktlen(uint8_t value); // AGC settings void set_magn_target(MagnTarget value); @@ -63,6 +65,16 @@ class CC1101Component : public Component, void set_wait_time(WaitTime value); void set_hyst_level(HystLevel value); + // Packet mode settings + void set_packet_mode(bool value); + void set_packet_length(uint8_t value); + void set_crc_enable(bool value); + void set_whitening(bool value); + + // Packet mode operations + CC1101Error transmit_packet(const std::vector &packet); + Trigger, float, uint8_t> *get_packet_trigger() const { return this->packet_trigger_; } + protected: uint16_t chip_id_{0}; bool initialized_{false}; @@ -73,6 +85,13 @@ class CC1101Component : public Component, CC1101State state_; + // GDO pin for packet reception + InternalGPIOPin *gdo0_pin_{nullptr}; + + // Packet handling + Trigger, float, uint8_t> *packet_trigger_{new Trigger, float, uint8_t>()}; + std::vector packet_; + // Low-level Helpers uint8_t strobe_(Command cmd); void write_(Register reg); @@ -107,4 +126,28 @@ template class SetIdleAction : public Action, public Pare void play(const Ts &...x) override { this->parent_->set_idle(); } }; +template class SendPacketAction : 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) { + this->data_static_ = data; + this->data_static_len_ = len; + } + + void play(const Ts &...x) override { + if (this->data_func_) { + auto data = this->data_func_(x...); + this->parent_->transmit_packet(data); + } else if (this->data_static_ != nullptr) { + std::vector data(this->data_static_, this->data_static_ + this->data_static_len_); + this->parent_->transmit_packet(data); + } + } + + protected: + std::function(Ts...)> data_func_{}; + const uint8_t *data_static_{nullptr}; + size_t data_static_len_{0}; +}; + } // namespace esphome::cc1101 diff --git a/esphome/components/cc1101/cc1101defs.h b/esphome/components/cc1101/cc1101defs.h index afeb5f1d77f..1bc42f58599 100644 --- a/esphome/components/cc1101/cc1101defs.h +++ b/esphome/components/cc1101/cc1101defs.h @@ -6,6 +6,12 @@ namespace esphome::cc1101 { static constexpr float XTAL_FREQUENCY = 26000000; +static constexpr float RSSI_OFFSET = 74.0f; +static constexpr float RSSI_STEP = 0.5f; + +static constexpr uint8_t STATUS_CRC_OK_MASK = 0x80; +static constexpr uint8_t STATUS_LQI_MASK = 0x7F; + static constexpr uint8_t BUS_BURST = 0x40; static constexpr uint8_t BUS_READ = 0x80; static constexpr uint8_t BUS_WRITE = 0x00; @@ -134,6 +140,10 @@ enum class SyncMode : uint8_t { SYNC_MODE_15_16, SYNC_MODE_16_16, SYNC_MODE_30_32, + SYNC_MODE_NONE_CS, + SYNC_MODE_15_16_CS, + SYNC_MODE_16_16_CS, + SYNC_MODE_30_32_CS, }; enum class Modulation : uint8_t { @@ -218,6 +228,19 @@ enum class HystLevel : uint8_t { HYST_LEVEL_HIGH, }; +enum class PacketFormat : uint8_t { + PACKET_FORMAT_FIFO, + PACKET_FORMAT_SYNC_SERIAL, + PACKET_FORMAT_RANDOM_TX, + PACKET_FORMAT_ASYNC_SERIAL, +}; + +enum class LengthConfig : uint8_t { + LENGTH_CONFIG_FIXED, + LENGTH_CONFIG_VARIABLE, + LENGTH_CONFIG_INFINITE, +}; + struct __attribute__((packed)) CC1101State { // Byte array accessors for bulk SPI transfers uint8_t *regs() { return reinterpret_cast(this); } diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index b8e49db6c09..2150a30c3e4 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -117,9 +117,7 @@ CONF_MIN_HUMIDITY = "min_humidity" CONF_MAX_HUMIDITY = "max_humidity" CONF_TARGET_HUMIDITY = "target_humidity" -visual_temperature = cv.float_with_unit( - "visual_temperature", "(°C|° C|°|C|°K|° K|K|°F|° F|F)?" -) +visual_temperature = cv.float_with_unit("visual_temperature", "(°|(° ?)?[CKF])?") VISUAL_TEMPERATURE_STEP_SCHEMA = cv.Schema( diff --git a/esphome/components/climate/climate.cpp b/esphome/components/climate/climate.cpp index 3bc20a17c6e..229862ce013 100644 --- a/esphome/components/climate/climate.cpp +++ b/esphome/components/climate/climate.cpp @@ -2,6 +2,7 @@ #include "esphome/core/defines.h" #include "esphome/core/controller_registry.h" #include "esphome/core/macros.h" +#include namespace esphome::climate { @@ -190,24 +191,30 @@ ClimateCall &ClimateCall::set_fan_mode(ClimateFanMode fan_mode) { } ClimateCall &ClimateCall::set_fan_mode(const char *custom_fan_mode) { + return this->set_fan_mode(custom_fan_mode, strlen(custom_fan_mode)); +} + +ClimateCall &ClimateCall::set_fan_mode(const std::string &fan_mode) { + return this->set_fan_mode(fan_mode.data(), fan_mode.size()); +} + +ClimateCall &ClimateCall::set_fan_mode(const char *custom_fan_mode, size_t len) { // Check if it's a standard enum mode first for (const auto &mode_entry : CLIMATE_FAN_MODES_BY_STR) { - if (str_equals_case_insensitive(custom_fan_mode, mode_entry.str)) { + if (strncasecmp(custom_fan_mode, mode_entry.str, len) == 0 && mode_entry.str[len] == '\0') { return this->set_fan_mode(static_cast(mode_entry.value)); } } // Find the matching pointer from parent climate device - if (const char *mode_ptr = this->parent_->find_custom_fan_mode_(custom_fan_mode)) { + if (const char *mode_ptr = this->parent_->find_custom_fan_mode_(custom_fan_mode, len)) { this->custom_fan_mode_ = mode_ptr; this->fan_mode_.reset(); return *this; } - ESP_LOGW(TAG, "'%s' - Unrecognized fan mode %s", this->parent_->get_name().c_str(), custom_fan_mode); + ESP_LOGW(TAG, "'%s' - Unrecognized fan mode %.*s", this->parent_->get_name().c_str(), (int) len, custom_fan_mode); return *this; } -ClimateCall &ClimateCall::set_fan_mode(const std::string &fan_mode) { return this->set_fan_mode(fan_mode.c_str()); } - ClimateCall &ClimateCall::set_fan_mode(optional fan_mode) { if (fan_mode.has_value()) { this->set_fan_mode(fan_mode.value()); @@ -222,24 +229,30 @@ ClimateCall &ClimateCall::set_preset(ClimatePreset preset) { } ClimateCall &ClimateCall::set_preset(const char *custom_preset) { + return this->set_preset(custom_preset, strlen(custom_preset)); +} + +ClimateCall &ClimateCall::set_preset(const std::string &preset) { + return this->set_preset(preset.data(), preset.size()); +} + +ClimateCall &ClimateCall::set_preset(const char *custom_preset, size_t len) { // Check if it's a standard enum preset first for (const auto &preset_entry : CLIMATE_PRESETS_BY_STR) { - if (str_equals_case_insensitive(custom_preset, preset_entry.str)) { + if (strncasecmp(custom_preset, preset_entry.str, len) == 0 && preset_entry.str[len] == '\0') { return this->set_preset(static_cast(preset_entry.value)); } } // Find the matching pointer from parent climate device - if (const char *preset_ptr = this->parent_->find_custom_preset_(custom_preset)) { + if (const char *preset_ptr = this->parent_->find_custom_preset_(custom_preset, len)) { this->custom_preset_ = preset_ptr; this->preset_.reset(); return *this; } - ESP_LOGW(TAG, "'%s' - Unrecognized preset %s", this->parent_->get_name().c_str(), custom_preset); + ESP_LOGW(TAG, "'%s' - Unrecognized preset %.*s", this->parent_->get_name().c_str(), (int) len, custom_preset); return *this; } -ClimateCall &ClimateCall::set_preset(const std::string &preset) { return this->set_preset(preset.c_str()); } - ClimateCall &ClimateCall::set_preset(optional preset) { if (preset.has_value()) { this->set_preset(preset.value()); @@ -688,11 +701,19 @@ bool Climate::set_custom_preset_(const char *preset) { void Climate::clear_custom_preset_() { this->custom_preset_ = nullptr; } const char *Climate::find_custom_fan_mode_(const char *custom_fan_mode) { - return this->get_traits().find_custom_fan_mode_(custom_fan_mode); + return this->find_custom_fan_mode_(custom_fan_mode, strlen(custom_fan_mode)); +} + +const char *Climate::find_custom_fan_mode_(const char *custom_fan_mode, size_t len) { + return this->get_traits().find_custom_fan_mode_(custom_fan_mode, len); } const char *Climate::find_custom_preset_(const char *custom_preset) { - return this->get_traits().find_custom_preset_(custom_preset); + return this->find_custom_preset_(custom_preset, strlen(custom_preset)); +} + +const char *Climate::find_custom_preset_(const char *custom_preset, size_t len) { + return this->get_traits().find_custom_preset_(custom_preset, len); } void Climate::dump_traits_(const char *tag) { diff --git a/esphome/components/climate/climate.h b/esphome/components/climate/climate.h index 82df4b815f4..06adb580cf4 100644 --- a/esphome/components/climate/climate.h +++ b/esphome/components/climate/climate.h @@ -78,6 +78,8 @@ class ClimateCall { ClimateCall &set_fan_mode(optional fan_mode); /// Set the custom fan mode of the climate device. ClimateCall &set_fan_mode(const char *custom_fan_mode); + /// Set the custom fan mode of the climate device (zero-copy API path). + ClimateCall &set_fan_mode(const char *custom_fan_mode, size_t len); /// Set the swing mode of the climate device. ClimateCall &set_swing_mode(ClimateSwingMode swing_mode); /// Set the swing mode of the climate device. @@ -94,6 +96,8 @@ class ClimateCall { ClimateCall &set_preset(optional preset); /// Set the custom preset of the climate device. ClimateCall &set_preset(const char *custom_preset); + /// Set the custom preset of the climate device (zero-copy API path). + ClimateCall &set_preset(const char *custom_preset, size_t len); void perform(); @@ -290,9 +294,11 @@ class Climate : public EntityBase { /// Find and return the matching custom fan mode pointer from traits, or nullptr if not found. const char *find_custom_fan_mode_(const char *custom_fan_mode); + const char *find_custom_fan_mode_(const char *custom_fan_mode, size_t len); /// Find and return the matching custom preset pointer from traits, or nullptr if not found. const char *find_custom_preset_(const char *custom_preset); + const char *find_custom_preset_(const char *custom_preset, size_t len); /** Get the default traits of this climate device. * @@ -320,8 +326,8 @@ class Climate : public EntityBase { void dump_traits_(const char *tag); - CallbackManager state_callback_{}; - CallbackManager control_callback_{}; + LazyCallbackManager state_callback_{}; + LazyCallbackManager control_callback_{}; ESPPreferenceObject rtc_; #ifdef USE_CLIMATE_VISUAL_OVERRIDES float visual_min_temperature_override_{NAN}; diff --git a/esphome/components/climate/climate_traits.h b/esphome/components/climate/climate_traits.h index d3582934752..80ef0854d59 100644 --- a/esphome/components/climate/climate_traits.h +++ b/esphome/components/climate/climate_traits.h @@ -20,18 +20,22 @@ using ClimatePresetMask = FiniteSetMask &vec, const char *value) { +inline bool vector_contains(const std::vector &vec, const char *value, size_t len) { for (const char *item : vec) { - if (strcmp(item, value) == 0) + if (strncmp(item, value, len) == 0 && item[len] == '\0') return true; } return false; } +inline bool vector_contains(const std::vector &vec, const char *value) { + return vector_contains(vec, value, strlen(value)); +} + // Find and return matching pointer from vector, or nullptr if not found -inline const char *vector_find(const std::vector &vec, const char *value) { +inline const char *vector_find(const std::vector &vec, const char *value, size_t len) { for (const char *item : vec) { - if (strcmp(item, value) == 0) + if (strncmp(item, value, len) == 0 && item[len] == '\0') return item; } return nullptr; @@ -257,13 +261,19 @@ class ClimateTraits { /// Find and return the matching custom fan mode pointer from supported modes, or nullptr if not found /// This is protected as it's an implementation detail - use Climate::find_custom_fan_mode_() instead const char *find_custom_fan_mode_(const char *custom_fan_mode) const { - return vector_find(this->supported_custom_fan_modes_, custom_fan_mode); + return this->find_custom_fan_mode_(custom_fan_mode, strlen(custom_fan_mode)); + } + const char *find_custom_fan_mode_(const char *custom_fan_mode, size_t len) const { + return vector_find(this->supported_custom_fan_modes_, custom_fan_mode, len); } /// Find and return the matching custom preset pointer from supported presets, or nullptr if not found /// This is protected as it's an implementation detail - use Climate::find_custom_preset_() instead const char *find_custom_preset_(const char *custom_preset) const { - return vector_find(this->supported_custom_presets_, custom_preset); + return this->find_custom_preset_(custom_preset, strlen(custom_preset)); + } + const char *find_custom_preset_(const char *custom_preset, size_t len) const { + return vector_find(this->supported_custom_presets_, custom_preset, len); } uint32_t feature_flags_{0}; diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 12a69551f56..fcfafa0c1a5 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -7,9 +7,11 @@ BYTE_ORDER_LITTLE = "little_endian" BYTE_ORDER_BIG = "big_endian" CONF_COLOR_DEPTH = "color_depth" +CONF_CRC_ENABLE = "crc_enable" CONF_DRAW_ROUNDING = "draw_rounding" CONF_ENABLED = "enabled" CONF_IGNORE_NOT_FOUND = "ignore_not_found" +CONF_ON_PACKET = "on_packet" CONF_ON_RECEIVE = "on_receive" CONF_ON_STATE_CHANGE = "on_state_change" CONF_REQUEST_HEADERS = "request_headers" diff --git a/esphome/components/copy/select/copy_select.cpp b/esphome/components/copy/select/copy_select.cpp index e45338e7857..e85e08e3536 100644 --- a/esphome/components/copy/select/copy_select.cpp +++ b/esphome/components/copy/select/copy_select.cpp @@ -7,7 +7,7 @@ namespace copy { static const char *const TAG = "copy.select"; void CopySelect::setup() { - source_->add_on_state_callback([this](const std::string &value, size_t index) { this->publish_state(index); }); + source_->add_on_state_callback([this](size_t index) { this->publish_state(index); }); traits.set_options(source_->traits.get_options()); diff --git a/esphome/components/cover/cover.h b/esphome/components/cover/cover.h index d8c45ab2bda..e710915a0e9 100644 --- a/esphome/components/cover/cover.h +++ b/esphome/components/cover/cover.h @@ -152,7 +152,7 @@ class Cover : public EntityBase, public EntityBase_DeviceClass { optional restore_state_(); - CallbackManager state_callback_{}; + LazyCallbackManager state_callback_{}; ESPPreferenceObject rtc_; }; diff --git a/esphome/components/datetime/datetime_base.h b/esphome/components/datetime/datetime_base.h index 7b9b281ea43..1b0b3d54639 100644 --- a/esphome/components/datetime/datetime_base.h +++ b/esphome/components/datetime/datetime_base.h @@ -22,7 +22,7 @@ class DateTimeBase : public EntityBase { #endif protected: - CallbackManager state_callback_; + LazyCallbackManager state_callback_; #ifdef USE_TIME time::RealTimeClock *rtc_; diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 8849fad7d60..3cfe7aa6417 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -1,4 +1,4 @@ -from esphome import automation, pins +from esphome import automation, core, pins import esphome.codegen as cg from esphome.components import esp32, time from esphome.components.esp32 import ( @@ -23,16 +23,20 @@ from esphome.const import ( CONF_MINUTE, CONF_MODE, CONF_NUMBER, + CONF_PIN, CONF_PINS, CONF_RUN_DURATION, CONF_SECOND, CONF_SLEEP_DURATION, CONF_TIME_ID, CONF_WAKEUP_PIN, + PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, PlatformFramework, ) +from esphome.core import CORE +from esphome.types import ConfigType WAKEUP_PINS = { VARIANT_ESP32: [ @@ -113,7 +117,7 @@ WAKEUP_PINS = { } -def validate_pin_number(value): +def validate_pin_number_esp32(value: ConfigType) -> ConfigType: valid_pins = WAKEUP_PINS.get(get_esp32_variant(), WAKEUP_PINS[VARIANT_ESP32]) if value[CONF_NUMBER] not in valid_pins: raise cv.Invalid( @@ -122,6 +126,51 @@ def validate_pin_number(value): return value +def validate_pin_number(value: ConfigType) -> ConfigType: + if not CORE.is_esp32: + return value + return validate_pin_number_esp32(value) + + +def validate_wakeup_pin( + value: ConfigType | list[ConfigType], +) -> list[ConfigType]: + if not isinstance(value, list): + processed_pins: list[ConfigType] = [{CONF_PIN: value}] + else: + processed_pins = list(value) + + for i, pin_config in enumerate(processed_pins): + # now validate each item + validated_pin = WAKEUP_PIN_SCHEMA(pin_config) + validate_pin_number(validated_pin[CONF_PIN]) + processed_pins[i] = validated_pin + + return processed_pins + + +def validate_config(config: ConfigType) -> ConfigType: + # right now only BK72XX supports the list format for wakeup pins + if CORE.is_bk72xx: + if CONF_WAKEUP_PIN_MODE in config: + wakeup_pins = config.get(CONF_WAKEUP_PIN, []) + if len(wakeup_pins) > 1: + raise cv.Invalid( + "You need to remove the global wakeup_pin_mode and define it per pin" + ) + if wakeup_pins: + wakeup_pins[0][CONF_WAKEUP_PIN_MODE] = config.pop(CONF_WAKEUP_PIN_MODE) + elif ( + isinstance(config.get(CONF_WAKEUP_PIN), list) + and len(config[CONF_WAKEUP_PIN]) > 1 + ): + raise cv.Invalid( + "Your platform does not support providing multiple entries in wakeup_pin" + ) + + return config + + def _validate_ex1_wakeup_mode(value): if value == "ALL_LOW": esp32.only_on_variant(supported=[VARIANT_ESP32], msg_prefix="ALL_LOW")(value) @@ -141,6 +190,15 @@ def _validate_ex1_wakeup_mode(value): return value +def _validate_sleep_duration(value: core.TimePeriod) -> core.TimePeriod: + if not CORE.is_bk72xx: + return value + max_duration = core.TimePeriod(hours=36) + if value > max_duration: + raise cv.Invalid("sleep duration cannot be more than 36 hours on BK72XX") + return value + + deep_sleep_ns = cg.esphome_ns.namespace("deep_sleep") DeepSleepComponent = deep_sleep_ns.class_("DeepSleepComponent", cg.Component) EnterDeepSleepAction = deep_sleep_ns.class_("EnterDeepSleepAction", automation.Action) @@ -186,6 +244,13 @@ WAKEUP_CAUSES_SCHEMA = cv.Schema( } ) +WAKEUP_PIN_SCHEMA = cv.Schema( + { + cv.Required(CONF_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_WAKEUP_PIN_MODE): cv.enum(WAKEUP_PIN_MODES, upper=True), + } +) + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -194,14 +259,15 @@ CONFIG_SCHEMA = cv.All( cv.All(cv.only_on_esp32, WAKEUP_CAUSES_SCHEMA), cv.positive_time_period_milliseconds, ), - cv.Optional(CONF_SLEEP_DURATION): cv.positive_time_period_milliseconds, - cv.Optional(CONF_WAKEUP_PIN): cv.All( - cv.only_on_esp32, - pins.internal_gpio_input_pin_schema, - validate_pin_number, + cv.Optional(CONF_SLEEP_DURATION): cv.All( + cv.positive_time_period_milliseconds, + _validate_sleep_duration, ), + cv.Optional(CONF_WAKEUP_PIN): validate_wakeup_pin, cv.Optional(CONF_WAKEUP_PIN_MODE): cv.All( - cv.only_on_esp32, cv.enum(WAKEUP_PIN_MODES), upper=True + cv.only_on([PLATFORM_ESP32, PLATFORM_BK72XX]), + cv.enum(WAKEUP_PIN_MODES), + upper=True, ), cv.Optional(CONF_ESP32_EXT1_WAKEUP): cv.All( cv.only_on_esp32, @@ -212,7 +278,8 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.Required(CONF_PINS): cv.ensure_list( - pins.internal_gpio_input_pin_schema, validate_pin_number + pins.internal_gpio_input_pin_schema, + validate_pin_number_esp32, ), cv.Required(CONF_MODE): cv.All( cv.enum(EXT1_WAKEUP_MODES, upper=True), @@ -238,7 +305,8 @@ CONFIG_SCHEMA = cv.All( ), } ).extend(cv.COMPONENT_SCHEMA), - cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266]), + cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_BK72XX]), + validate_config, ) @@ -249,8 +317,21 @@ async def to_code(config): if CONF_SLEEP_DURATION in config: cg.add(var.set_sleep_duration(config[CONF_SLEEP_DURATION])) if CONF_WAKEUP_PIN in config: - pin = await cg.gpio_pin_expression(config[CONF_WAKEUP_PIN]) - cg.add(var.set_wakeup_pin(pin)) + pins_as_list = config.get(CONF_WAKEUP_PIN, []) + if CORE.is_bk72xx: + cg.add(var.init_wakeup_pins_(len(pins_as_list))) + for item in pins_as_list: + cg.add( + var.add_wakeup_pin( + await cg.gpio_pin_expression(item[CONF_PIN]), + item.get( + CONF_WAKEUP_PIN_MODE, WakeupPinMode.WAKEUP_PIN_MODE_IGNORE + ), + ) + ) + else: + pin = await cg.gpio_pin_expression(pins_as_list[0][CONF_PIN]) + cg.add(var.set_wakeup_pin(pin)) if CONF_WAKEUP_PIN_MODE in config: cg.add(var.set_wakeup_pin_mode(config[CONF_WAKEUP_PIN_MODE])) if CONF_RUN_DURATION in config: @@ -305,7 +386,10 @@ DEEP_SLEEP_ENTER_SCHEMA = cv.All( cv.Schema( { cv.Exclusive(CONF_SLEEP_DURATION, "time"): cv.templatable( - cv.positive_time_period_milliseconds + cv.All( + cv.positive_time_period_milliseconds, + _validate_sleep_duration, + ) ), # Only on ESP32 due to how long the RTC on ESP8266 can stay asleep cv.Exclusive(CONF_UNTIL, "time"): cv.All( @@ -363,5 +447,6 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.ESP32_IDF, }, "deep_sleep_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO}, + "deep_sleep_bk72xx.cpp": {PlatformFramework.BK72XX_ARDUINO}, } ) diff --git a/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp new file mode 100644 index 00000000000..b5fadd7230e --- /dev/null +++ b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp @@ -0,0 +1,64 @@ +#ifdef USE_BK72XX + +#include "deep_sleep_component.h" +#include "esphome/core/log.h" + +namespace esphome::deep_sleep { + +static const char *const TAG = "deep_sleep.bk72xx"; + +optional DeepSleepComponent::get_run_duration_() const { return this->run_duration_; } + +void DeepSleepComponent::dump_config_platform_() { + for (const WakeUpPinItem &item : this->wakeup_pins_) { + LOG_PIN(" Wakeup Pin: ", item.wakeup_pin); + } +} + +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::prepare_to_sleep_() { + if (wakeup_pins_.size() > 0) { + for (WakeUpPinItem &item : this->wakeup_pins_) { + if (pin_prevents_sleep_(item)) { + // Defer deep sleep until inactive + if (!this->next_enter_deep_sleep_) { + this->status_set_warning(); + ESP_LOGV(TAG, "Waiting for pin to switch state to enter deep sleep..."); + } + this->next_enter_deep_sleep_ = true; + return false; + } + } + } + return true; +} + +void DeepSleepComponent::deep_sleep_() { + for (WakeUpPinItem &item : this->wakeup_pins_) { + if (item.wakeup_pin_mode == WAKEUP_PIN_MODE_INVERT_WAKEUP) { + if (item.wakeup_level == get_real_pin_state_(*item.wakeup_pin)) { + 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", + static_cast(item.wakeup_pin_mode)); + } + + if (this->sleep_duration_.has_value()) + lt_deep_sleep_config_timer((*this->sleep_duration_ / 1000) & 0xFFFFFFFF); + + for (WakeUpPinItem &item : this->wakeup_pins_) { + lt_deep_sleep_config_gpio(1 << item.wakeup_pin->get_pin(), item.wakeup_level); + lt_deep_sleep_keep_floating_gpio(1 << item.wakeup_pin->get_pin(), true); + } + + lt_deep_sleep_enter(); +} + +} // namespace esphome::deep_sleep + +#endif // USE_BK72XX diff --git a/esphome/components/deep_sleep/deep_sleep_component.h b/esphome/components/deep_sleep/deep_sleep_component.h index bca3aa5e4d8..3e6eda2257a 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.h +++ b/esphome/components/deep_sleep/deep_sleep_component.h @@ -19,7 +19,7 @@ namespace esphome { namespace deep_sleep { -#ifdef USE_ESP32 +#if defined(USE_ESP32) || defined(USE_BK72XX) /** The values of this enum define what should be done if deep sleep is set up with a wakeup pin on the ESP32 * and the scenario occurs that the wakeup pin is already in the wakeup state. @@ -33,7 +33,17 @@ enum WakeupPinMode { */ WAKEUP_PIN_MODE_INVERT_WAKEUP, }; +#endif +#if defined(USE_BK72XX) +struct WakeUpPinItem { + InternalGPIOPin *wakeup_pin; + WakeupPinMode wakeup_pin_mode; + bool wakeup_level; +}; +#endif // USE_BK72XX + +#ifdef USE_ESP32 #if defined(USE_ESP32) && !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) struct Ext1Wakeup { uint64_t mask; @@ -75,6 +85,13 @@ class DeepSleepComponent : public Component { void set_wakeup_pin_mode(WakeupPinMode wakeup_pin_mode); #endif // USE_ESP32 +#if defined(USE_BK72XX) + 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()}); + } +#endif // USE_BK72XX + #if defined(USE_ESP32) #if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) void set_ext1_wakeup(Ext1Wakeup ext1_wakeup); @@ -114,7 +131,17 @@ class DeepSleepComponent : public Component { bool prepare_to_sleep_(); void deep_sleep_(); +#ifdef USE_BK72XX + bool pin_prevents_sleep_(WakeUpPinItem &pinItem) const; + bool get_real_pin_state_(InternalGPIOPin &pin) const { return (pin.digital_read() ^ pin.is_inverted()); } +#endif // USE_BK72XX + optional sleep_duration_; + +#ifdef USE_BK72XX + FixedVector wakeup_pins_; +#endif // USE_BK72XX + #ifdef USE_ESP32 InternalGPIOPin *wakeup_pin_; WakeupPinMode wakeup_pin_mode_{WAKEUP_PIN_MODE_IGNORE}; @@ -124,8 +151,10 @@ class DeepSleepComponent : public Component { #endif optional touch_wakeup_; + optional wakeup_cause_to_run_duration_; #endif // USE_ESP32 + optional run_duration_; bool next_enter_deep_sleep_{false}; bool prevent_{false}; diff --git a/esphome/components/dht/dht.cpp b/esphome/components/dht/dht.cpp index cc0bf55a807..e0abb7c5f09 100644 --- a/esphome/components/dht/dht.cpp +++ b/esphome/components/dht/dht.cpp @@ -8,17 +8,20 @@ namespace dht { static const char *const TAG = "dht"; void DHT::setup() { - this->pin_->digital_write(true); - this->pin_->setup(); - this->pin_->digital_write(true); + this->t_pin_->digital_write(true); + this->t_pin_->setup(); +#ifdef USE_ESP32 + this->t_pin_->pin_mode(this->t_pin_->get_flags() | gpio::FLAG_OUTPUT | gpio::FLAG_OPEN_DRAIN); +#endif + this->t_pin_->digital_write(true); } void DHT::dump_config() { ESP_LOGCONFIG(TAG, "DHT:"); - LOG_PIN(" Pin: ", this->pin_); + LOG_PIN(" Pin: ", this->t_pin_); ESP_LOGCONFIG(TAG, " %sModel: %s", this->is_auto_detect_ ? "Auto-detected " : "", this->model_ == DHT_MODEL_DHT11 ? "DHT11" : "DHT22 or equivalent"); - ESP_LOGCONFIG(TAG, " Internal pull-up: %s", ONOFF(this->pin_->get_flags() & gpio::FLAG_PULLUP)); + ESP_LOGCONFIG(TAG, " Internal pull-up: %s", ONOFF(this->t_pin_->get_flags() & gpio::FLAG_PULLUP)); LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); @@ -72,21 +75,15 @@ bool HOT IRAM_ATTR DHT::read_sensor_(float *temperature, float *humidity, bool r int8_t i = 0; uint8_t data[5] = {0, 0, 0, 0, 0}; - this->pin_->digital_write(false); - this->pin_->pin_mode(gpio::FLAG_OUTPUT); - this->pin_->digital_write(false); +#ifndef USE_ESP32 + this->pin_.pin_mode(gpio::FLAG_OUTPUT); +#endif + this->pin_.digital_write(false); if (this->model_ == DHT_MODEL_DHT11) { delayMicroseconds(18000); } else if (this->model_ == DHT_MODEL_SI7021) { -#ifdef USE_ESP8266 delayMicroseconds(500); - this->pin_->digital_write(true); - delayMicroseconds(40); -#else - delayMicroseconds(400); - this->pin_->digital_write(true); -#endif } else if (this->model_ == DHT_MODEL_DHT22_TYPE2) { delayMicroseconds(2000); } else if (this->model_ == DHT_MODEL_AM2120 || this->model_ == DHT_MODEL_AM2302) { @@ -94,7 +91,12 @@ bool HOT IRAM_ATTR DHT::read_sensor_(float *temperature, float *humidity, bool r } else { delayMicroseconds(800); } - this->pin_->pin_mode(this->pin_->get_flags()); + +#ifdef USE_ESP32 + this->pin_.digital_write(true); +#else + this->pin_.pin_mode(this->t_pin_->get_flags()); +#endif { InterruptLock lock; @@ -110,7 +112,7 @@ bool HOT IRAM_ATTR DHT::read_sensor_(float *temperature, float *humidity, bool r uint32_t start_time = micros(); // Wait for rising edge - while (!this->pin_->digital_read()) { + while (!this->pin_.digital_read()) { if (micros() - start_time > 90) { if (i < 0) { error_code = 1; // line didn't clear @@ -127,7 +129,7 @@ bool HOT IRAM_ATTR DHT::read_sensor_(float *temperature, float *humidity, bool r uint32_t end_time = start_time; // Wait for falling edge - while (this->pin_->digital_read()) { + while (this->pin_.digital_read()) { end_time = micros(); if (end_time - start_time > 90) { if (i < 0) { diff --git a/esphome/components/dht/dht.h b/esphome/components/dht/dht.h index 327e8a4f5c6..9047dd2c964 100644 --- a/esphome/components/dht/dht.h +++ b/esphome/components/dht/dht.h @@ -38,7 +38,10 @@ class DHT : public PollingComponent { */ void set_dht_model(DHTModel model); - void set_pin(InternalGPIOPin *pin) { pin_ = pin; } + void set_pin(InternalGPIOPin *pin) { + this->t_pin_ = pin; + this->pin_ = pin->to_isr(); + } void set_model(DHTModel model) { model_ = model; } void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } void set_humidity_sensor(sensor::Sensor *humidity_sensor) { humidity_sensor_ = humidity_sensor; } @@ -54,7 +57,8 @@ class DHT : public PollingComponent { protected: bool read_sensor_(float *temperature, float *humidity, bool report_errors); - InternalGPIOPin *pin_; + InternalGPIOPin *t_pin_; + ISRInternalGPIOPin pin_; DHTModel model_{DHT_MODEL_AUTO_DETECT}; bool is_auto_detect_{false}; sensor::Sensor *temperature_sensor_{nullptr}; diff --git a/esphome/components/display_menu_base/menu_item.cpp b/esphome/components/display_menu_base/menu_item.cpp index 8224adf3fe9..08f758045e5 100644 --- a/esphome/components/display_menu_base/menu_item.cpp +++ b/esphome/components/display_menu_base/menu_item.cpp @@ -54,6 +54,7 @@ bool MenuItemSelect::select_next() { if (this->select_var_ != nullptr) { this->select_var_->make_call().select_next(true).perform(); + this->on_value_(); changed = true; } @@ -65,6 +66,7 @@ bool MenuItemSelect::select_prev() { if (this->select_var_ != nullptr) { this->select_var_->make_call().select_previous(true).perform(); + this->on_value_(); changed = true; } diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 3dc5e4bbaaa..dc442cfbd2a 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -4,6 +4,7 @@ import itertools import logging import os from pathlib import Path +import re from esphome import yaml_util import esphome.codegen as cg @@ -12,6 +13,7 @@ from esphome.const import ( CONF_ADVANCED, CONF_BOARD, CONF_COMPONENTS, + CONF_DISABLED, CONF_ESPHOME, CONF_FRAMEWORK, CONF_IGNORE_EFUSE_CUSTOM_MAC, @@ -23,6 +25,7 @@ from esphome.const import ( CONF_PLATFORMIO_OPTIONS, CONF_REF, CONF_REFRESH, + CONF_SAFE_MODE, CONF_SOURCE, CONF_TYPE, CONF_VARIANT, @@ -80,6 +83,7 @@ CONF_ASSERTION_LEVEL = "assertion_level" CONF_COMPILER_OPTIMIZATION = "compiler_optimization" CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES = "enable_idf_experimental_features" CONF_ENABLE_LWIP_ASSERT = "enable_lwip_assert" +CONF_ENABLE_OTA_ROLLBACK = "enable_ota_rollback" CONF_EXECUTE_FROM_PSRAM = "execute_from_psram" CONF_RELEASE = "release" @@ -117,8 +121,8 @@ ARDUINO_ALLOWED_VARIANTS = [ ] -def get_cpu_frequencies(*frequencies): - return [str(x) + "MHZ" for x in frequencies] +def get_cpu_frequencies(*frequencies: int) -> list[str]: + return [f"{frequency}MHZ" for frequency in frequencies] CPU_FREQUENCIES = { @@ -135,7 +139,7 @@ CPU_FREQUENCIES = { } # Make sure not missed here if a new variant added. -assert all(v in CPU_FREQUENCIES for v in VARIANTS) +assert all(variant in CPU_FREQUENCIES for variant in VARIANTS) FULL_CPU_FREQUENCIES = set(itertools.chain.from_iterable(CPU_FREQUENCIES.values())) @@ -249,10 +253,10 @@ def add_idf_sdkconfig_option(name: str, value: SdkconfigValueType): def add_idf_component( *, name: str, - repo: str = None, - ref: str = None, - path: str = None, - refresh: TimePeriod = None, + repo: str | None = None, + ref: str | None = None, + path: str | None = None, + refresh: TimePeriod | None = None, components: list[str] | None = None, submodules: list[str] | None = None, ): @@ -333,7 +337,7 @@ def _format_framework_espidf_version(ver: cv.Version, release: str) -> str: return f"pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v{str(ver)}/esp-idf-v{str(ver)}.{ext}" -def _is_framework_url(source: str) -> str: +def _is_framework_url(source: str) -> bool: # platformio accepts many URL schemes for framework repositories and archives including http, https, git, file, and symlink import urllib.parse @@ -570,6 +574,13 @@ def final_validate(config): path=[CONF_FLASH_SIZE], ) ) + if advanced[CONF_ENABLE_OTA_ROLLBACK]: + safe_mode_config = full_config.get(CONF_SAFE_MODE) + if safe_mode_config is None or safe_mode_config.get(CONF_DISABLED, False): + _LOGGER.warning( + "OTA rollback requires safe_mode, disabling rollback support" + ) + advanced[CONF_ENABLE_OTA_ROLLBACK] = False if errs: raise cv.MultipleInvalid(errs) @@ -616,10 +627,13 @@ def require_vfs_dir() -> None: def _parse_idf_component(value: str) -> ConfigType: """Parse IDF component shorthand syntax like 'owner/component^version'""" - if "^" not in value: - raise cv.Invalid(f"Invalid IDF component shorthand '{value}'") - name, ref = value.split("^", 1) - return {CONF_NAME: name, CONF_REF: ref} + # Match operator followed by version-like string (digit or *) + if match := re.search(r"(~=|>=|<=|==|!=|>|<|\^|~)(\d|\*)", value): + return {CONF_NAME: value[: match.start()], CONF_REF: value[match.start() :]} + raise cv.Invalid( + f"Invalid IDF component shorthand '{value}'. " + f"Expected format: 'owner/componentversion' where is one of: ^, ~, ~=, ==, !=, >=, >, <=, <" + ) def _validate_idf_component(config: ConfigType) -> ConfigType: @@ -687,6 +701,7 @@ FRAMEWORK_SCHEMA = cv.Schema( cv.Optional(CONF_LOOP_TASK_STACK_SIZE, default=8192): cv.int_range( min=8192, max=32768 ), + cv.Optional(CONF_ENABLE_OTA_ROLLBACK, default=True): cv.boolean, } ), cv.Optional(CONF_COMPONENTS, default=[]): cv.ensure_list( @@ -967,29 +982,14 @@ async def to_code(config): cg.add_platformio_option("framework", "arduino, espidf") cg.add_build_flag("-DUSE_ARDUINO") cg.add_build_flag("-DUSE_ESP32_FRAMEWORK_ARDUINO") - cg.add_platformio_option( - "board_build.embed_txtfiles", - [ - "managed_components/espressif__esp_insights/server_certs/https_server.crt", - "managed_components/espressif__esp_rainmaker/server_certs/rmaker_mqtt_server.crt", - "managed_components/espressif__esp_rainmaker/server_certs/rmaker_claim_service_server.crt", - "managed_components/espressif__esp_rainmaker/server_certs/rmaker_ota_server.crt", - ], - ) cg.add_define( "USE_ARDUINO_VERSION_CODE", cg.RawExpression( f"VERSION_CODE({framework_ver.major}, {framework_ver.minor}, {framework_ver.patch})" ), ) - add_idf_sdkconfig_option( - "CONFIG_ARDUINO_LOOP_STACK_SIZE", - conf[CONF_ADVANCED][CONF_LOOP_TASK_STACK_SIZE], - ) - add_idf_sdkconfig_option("CONFIG_AUTOSTART_ARDUINO", True) add_idf_sdkconfig_option("CONFIG_MBEDTLS_PSK_MODES", True) add_idf_sdkconfig_option("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE", True) - add_idf_sdkconfig_option("CONFIG_ESP_PHY_REDUCE_TX_POWER", True) # ESP32-S2 Arduino: Disable USB Serial on boot to avoid TinyUSB dependency if get_esp32_variant() == VARIANT_ESP32S2: @@ -1160,6 +1160,11 @@ async def to_code(config): "CONFIG_BOOTLOADER_CACHE_32BIT_ADDR_QUAD_FLASH", True ) + # Enable OTA rollback support + if advanced[CONF_ENABLE_OTA_ROLLBACK]: + add_idf_sdkconfig_option("CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE", True) + cg.add_define("USE_OTA_ROLLBACK") + cg.add_define("ESPHOME_LOOP_TASK_STACK_SIZE", advanced[CONF_LOOP_TASK_STACK_SIZE]) cg.add_define( @@ -1189,7 +1194,7 @@ APP_PARTITION_SIZES = { } -def get_arduino_partition_csv(flash_size): +def get_arduino_partition_csv(flash_size: str): app_partition_size = APP_PARTITION_SIZES[flash_size] eeprom_partition_size = 0x1000 # 4 KB spiffs_partition_size = 0xF000 # 60 KB @@ -1209,7 +1214,7 @@ spiffs, data, spiffs, 0x{spiffs_partition_start:X}, 0x{spiffs_partition_size: """ -def get_idf_partition_csv(flash_size): +def get_idf_partition_csv(flash_size: str): app_partition_size = APP_PARTITION_SIZES[flash_size] return f"""\ diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index 6215ff862f8..09a45c14a67 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -4,25 +4,20 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "preferences.h" -#include -#include +#include +#include #include #include #include #include -#include +#include +#include -#include +void setup(); // NOLINT(readability-redundant-declaration) +void loop(); // NOLINT(readability-redundant-declaration) -#ifdef USE_ARDUINO -#include -#else -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0) -#include -#endif -void setup(); -void loop(); -#endif +// Weak stub for initArduino - overridden when the Arduino component is present +extern "C" __attribute__((weak)) void initArduino() {} namespace esphome { @@ -41,29 +36,13 @@ void arch_restart() { void arch_init() { // Enable the task watchdog only on the loop task (from which we're currently running) -#if defined(USE_ESP_IDF) esp_task_wdt_add(nullptr); - // Idle task watchdog is disabled on ESP-IDF -#elif defined(USE_ARDUINO) - enableLoopWDT(); - // Disable idle task watchdog on the core we're using (Arduino pins the task to a core) -#if defined(CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0) && CONFIG_ARDUINO_RUNNING_CORE == 0 - disableCore0WDT(); -#endif -#if defined(CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1) && CONFIG_ARDUINO_RUNNING_CORE == 1 - disableCore1WDT(); -#endif -#endif - // If the bootloader was compiled with CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE the current - // partition will get rolled back unless it is marked as valid. - esp_ota_img_states_t state; - const esp_partition_t *running = esp_ota_get_running_partition(); - if (esp_ota_get_state_partition(running, &state) == ESP_OK) { - if (state == ESP_OTA_IMG_PENDING_VERIFY) { - esp_ota_mark_app_valid_cancel_rollback(); - } - } + // Handle OTA rollback: mark partition valid immediately unless USE_OTA_ROLLBACK is enabled, + // in which case safe_mode will mark it valid after confirming successful boot. +#ifndef USE_OTA_ROLLBACK + esp_ota_mark_app_valid_cancel_rollback(); +#endif } void IRAM_ATTR HOT arch_feed_wdt() { esp_task_wdt_reset(); } @@ -71,21 +50,10 @@ uint8_t progmem_read_byte(const uint8_t *addr) { return *addr; } uint32_t arch_get_cpu_cycle_count() { return esp_cpu_get_cycle_count(); } uint32_t arch_get_cpu_freq_hz() { uint32_t freq = 0; -#ifdef USE_ESP_IDF -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0) esp_clk_tree_src_get_freq_hz(SOC_MOD_CLK_CPU, ESP_CLK_TREE_SRC_FREQ_PRECISION_CACHED, &freq); -#else - rtc_cpu_freq_config_t config; - rtc_clk_cpu_freq_get_config(&config); - freq = config.freq_mhz * 1000000U; -#endif -#elif defined(USE_ARDUINO) - freq = ESP.getCpuFreqMHz() * 1000000; -#endif return freq; } -#ifdef USE_ESP_IDF TaskHandle_t loop_task_handle = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) void loop_task(void *pv_params) { @@ -96,6 +64,7 @@ void loop_task(void *pv_params) { } extern "C" void app_main() { + initArduino(); esp32::setup_preferences(); #if CONFIG_FREERTOS_UNICORE xTaskCreate(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1, &loop_task_handle); @@ -103,11 +72,6 @@ extern "C" void app_main() { xTaskCreatePinnedToCore(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1, &loop_task_handle, 1); #endif } -#endif // USE_ESP_IDF - -#ifdef USE_ARDUINO -extern "C" void init() { esp32::setup_preferences(); } -#endif // USE_ARDUINO } // namespace esphome diff --git a/esphome/components/esp32/post_build.py.script b/esphome/components/esp32/post_build.py.script index c9952142324..5ef5860687c 100644 --- a/esphome/components/esp32/post_build.py.script +++ b/esphome/components/esp32/post_build.py.script @@ -5,6 +5,7 @@ import json # noqa: E402 import os # noqa: E402 import pathlib # noqa: E402 import shutil # noqa: E402 +from glob import glob # noqa: E402 def merge_factory_bin(source, target, env): @@ -126,3 +127,14 @@ def esp32_copy_ota_bin(source, target, env): # Run merge first, then ota copy second env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", merge_factory_bin) # noqa: F821 env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", esp32_copy_ota_bin) # noqa: F821 + +# Find server certificates in managed components and generate .S files. +# Workaround for PlatformIO not processing target_add_binary_data() from managed component CMakeLists. +project_dir = env.subst("$PROJECT_DIR") +managed_components = os.path.join(project_dir, "managed_components") +if os.path.isdir(managed_components): + for cert_file in glob(os.path.join(managed_components, "**/server_certs/*.crt"), recursive=True): + try: + env.FileToAsm(cert_file, FILE_TYPE="TEXT") + except Exception as e: + print(f"Error processing {os.path.basename(cert_file)}: {e}") diff --git a/esphome/components/esp32/preferences.cpp b/esphome/components/esp32/preferences.cpp index 7bdbb265ca5..5e1e8734e53 100644 --- a/esphome/components/esp32/preferences.cpp +++ b/esphome/components/esp32/preferences.cpp @@ -4,26 +4,28 @@ #include "esphome/core/log.h" #include "esphome/core/preferences.h" #include -#include #include -#include -#include +#include #include +#include namespace esphome { namespace esp32 { static const char *const TAG = "esp32.preferences"; +// Buffer size for converting uint32_t to string: max "4294967295" (10 chars) + null terminator + 1 padding +static constexpr size_t KEY_BUFFER_SIZE = 12; + struct NVSData { - std::string key; + uint32_t key; std::unique_ptr data; size_t len; void set_data(const uint8_t *src, size_t size) { - data = std::make_unique(size); - memcpy(data.get(), src, size); - len = size; + this->data = std::make_unique(size); + memcpy(this->data.get(), src, size); + this->len = size; } }; @@ -31,27 +33,27 @@ static std::vector s_pending_save; // NOLINT(cppcoreguidelines-avoid-n class ESP32PreferenceBackend : public ESPPreferenceBackend { public: - std::string key; + uint32_t key; uint32_t nvs_handle; bool save(const uint8_t *data, size_t len) override { // try find in pending saves and update that for (auto &obj : s_pending_save) { - if (obj.key == key) { + if (obj.key == this->key) { obj.set_data(data, len); return true; } } NVSData save{}; - save.key = key; + save.key = this->key; save.set_data(data, len); s_pending_save.emplace_back(std::move(save)); - ESP_LOGVV(TAG, "s_pending_save: key: %s, len: %zu", key.c_str(), len); + ESP_LOGVV(TAG, "s_pending_save: key: %" PRIu32 ", len: %zu", this->key, len); return true; } bool load(uint8_t *data, size_t len) override { // try find in pending saves and load from that for (auto &obj : s_pending_save) { - if (obj.key == key) { + if (obj.key == this->key) { if (obj.len != len) { // size mismatch return false; @@ -61,22 +63,24 @@ class ESP32PreferenceBackend : public ESPPreferenceBackend { } } + char key_str[KEY_BUFFER_SIZE]; + snprintf(key_str, sizeof(key_str), "%" PRIu32, this->key); size_t actual_len; - esp_err_t err = nvs_get_blob(nvs_handle, key.c_str(), nullptr, &actual_len); + esp_err_t err = nvs_get_blob(this->nvs_handle, key_str, nullptr, &actual_len); if (err != 0) { - ESP_LOGV(TAG, "nvs_get_blob('%s'): %s - the key might not be set yet", key.c_str(), esp_err_to_name(err)); + ESP_LOGV(TAG, "nvs_get_blob('%s'): %s - the key might not be set yet", key_str, esp_err_to_name(err)); return false; } if (actual_len != len) { ESP_LOGVV(TAG, "NVS length does not match (%zu!=%zu)", actual_len, len); return false; } - err = nvs_get_blob(nvs_handle, key.c_str(), data, &len); + err = nvs_get_blob(this->nvs_handle, key_str, data, &len); if (err != 0) { - ESP_LOGV(TAG, "nvs_get_blob('%s') failed: %s", key.c_str(), esp_err_to_name(err)); + ESP_LOGV(TAG, "nvs_get_blob('%s') failed: %s", key_str, esp_err_to_name(err)); return false; } else { - ESP_LOGVV(TAG, "nvs_get_blob: key: %s, len: %zu", key.c_str(), len); + ESP_LOGVV(TAG, "nvs_get_blob: key: %s, len: %zu", key_str, len); } return true; } @@ -103,14 +107,12 @@ class ESP32Preferences : public ESPPreferences { } } ESPPreferenceObject make_preference(size_t length, uint32_t type, bool in_flash) override { - return make_preference(length, type); + return this->make_preference(length, type); } ESPPreferenceObject make_preference(size_t length, uint32_t type) override { auto *pref = new ESP32PreferenceBackend(); // NOLINT(cppcoreguidelines-owning-memory) - pref->nvs_handle = nvs_handle; - - uint32_t keyval = type; - pref->key = str_sprintf("%" PRIu32, keyval); + pref->nvs_handle = this->nvs_handle; + pref->key = type; return ESPPreferenceObject(pref); } @@ -123,17 +125,19 @@ class ESP32Preferences : public ESPPreferences { // goal try write all pending saves even if one fails int cached = 0, written = 0, failed = 0; esp_err_t last_err = ESP_OK; - std::string last_key{}; + uint32_t last_key = 0; // go through vector from back to front (makes erase easier/more efficient) for (ssize_t i = s_pending_save.size() - 1; i >= 0; i--) { const auto &save = s_pending_save[i]; - ESP_LOGVV(TAG, "Checking if NVS data %s has changed", save.key.c_str()); - if (is_changed(nvs_handle, save)) { - esp_err_t err = nvs_set_blob(nvs_handle, save.key.c_str(), save.data.get(), save.len); - ESP_LOGV(TAG, "sync: key: %s, len: %zu", save.key.c_str(), save.len); + char key_str[KEY_BUFFER_SIZE]; + snprintf(key_str, sizeof(key_str), "%" PRIu32, save.key); + ESP_LOGVV(TAG, "Checking if NVS data %s has changed", key_str); + if (this->is_changed_(this->nvs_handle, save, key_str)) { + esp_err_t err = nvs_set_blob(this->nvs_handle, key_str, save.data.get(), save.len); + ESP_LOGV(TAG, "sync: key: %s, len: %zu", key_str, save.len); if (err != 0) { - ESP_LOGV(TAG, "nvs_set_blob('%s', len=%zu) failed: %s", save.key.c_str(), save.len, esp_err_to_name(err)); + ESP_LOGV(TAG, "nvs_set_blob('%s', len=%zu) failed: %s", key_str, save.len, esp_err_to_name(err)); failed++; last_err = err; last_key = save.key; @@ -141,7 +145,7 @@ class ESP32Preferences : public ESPPreferences { } written++; } else { - ESP_LOGV(TAG, "NVS data not changed skipping %s len=%zu", save.key.c_str(), save.len); + ESP_LOGV(TAG, "NVS data not changed skipping %" PRIu32 " len=%zu", save.key, save.len); cached++; } s_pending_save.erase(s_pending_save.begin() + i); @@ -149,12 +153,12 @@ class ESP32Preferences : public ESPPreferences { ESP_LOGD(TAG, "Writing %d items: %d cached, %d written, %d failed", cached + written + failed, cached, written, failed); if (failed > 0) { - ESP_LOGE(TAG, "Writing %d items failed. Last error=%s for key=%s", failed, esp_err_to_name(last_err), - last_key.c_str()); + ESP_LOGE(TAG, "Writing %d items failed. Last error=%s for key=%" PRIu32, failed, esp_err_to_name(last_err), + last_key); } // note: commit on esp-idf currently is a no-op, nvs_set_blob always writes - esp_err_t err = nvs_commit(nvs_handle); + esp_err_t err = nvs_commit(this->nvs_handle); if (err != 0) { ESP_LOGV(TAG, "nvs_commit() failed: %s", esp_err_to_name(err)); return false; @@ -162,11 +166,13 @@ class ESP32Preferences : public ESPPreferences { return failed == 0; } - bool is_changed(const uint32_t nvs_handle, const NVSData &to_save) { + + protected: + bool is_changed_(uint32_t nvs_handle, const NVSData &to_save, const char *key_str) { size_t actual_len; - esp_err_t err = nvs_get_blob(nvs_handle, to_save.key.c_str(), nullptr, &actual_len); + esp_err_t err = nvs_get_blob(nvs_handle, key_str, nullptr, &actual_len); if (err != 0) { - ESP_LOGV(TAG, "nvs_get_blob('%s'): %s - the key might not be set yet", to_save.key.c_str(), esp_err_to_name(err)); + ESP_LOGV(TAG, "nvs_get_blob('%s'): %s - the key might not be set yet", key_str, esp_err_to_name(err)); return true; } // Check size first before allocating memory @@ -174,9 +180,9 @@ class ESP32Preferences : public ESPPreferences { return true; } auto stored_data = std::make_unique(actual_len); - err = nvs_get_blob(nvs_handle, to_save.key.c_str(), stored_data.get(), &actual_len); + err = nvs_get_blob(nvs_handle, key_str, stored_data.get(), &actual_len); if (err != 0) { - ESP_LOGV(TAG, "nvs_get_blob('%s') failed: %s", to_save.key.c_str(), esp_err_to_name(err)); + ESP_LOGV(TAG, "nvs_get_blob('%s') failed: %s", key_str, esp_err_to_name(err)); return true; } return memcmp(to_save.data.get(), stored_data.get(), to_save.len) != 0; diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index a0ed9ee90cd..42f8ab8fd4e 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -256,8 +256,11 @@ bool ESP32BLE::ble_setup_() { } #endif + // BLE device names are limited to 20 characters + // Buffer: 20 chars + null terminator + constexpr size_t ble_name_max_len = 21; + char name_buffer[ble_name_max_len]; const char *device_name; - std::string name_with_suffix; if (this->name_ != nullptr) { if (App.is_name_add_mac_suffix_enabled()) { @@ -268,23 +271,28 @@ bool ESP32BLE::ble_setup_() { char mac_addr[mac_address_len]; get_mac_address_into_buffer(mac_addr); const char *mac_suffix_ptr = mac_addr + mac_address_suffix_len; - name_with_suffix = - make_name_with_suffix(this->name_, strlen(this->name_), '-', mac_suffix_ptr, mac_address_suffix_len); - device_name = name_with_suffix.c_str(); + make_name_with_suffix_to(name_buffer, sizeof(name_buffer), this->name_, strlen(this->name_), '-', mac_suffix_ptr, + mac_address_suffix_len); + device_name = name_buffer; } else { device_name = this->name_; } } else { - name_with_suffix = App.get_name(); - if (name_with_suffix.length() > 20) { + const std::string &app_name = App.get_name(); + size_t name_len = app_name.length(); + if (name_len > 20) { if (App.is_name_add_mac_suffix_enabled()) { // Keep first 13 chars and last 7 chars (MAC suffix), remove middle - name_with_suffix.erase(13, name_with_suffix.length() - 20); + memcpy(name_buffer, app_name.c_str(), 13); + memcpy(name_buffer + 13, app_name.c_str() + name_len - 7, 7); } else { - name_with_suffix.resize(20); + memcpy(name_buffer, app_name.c_str(), 20); } + name_buffer[20] = '\0'; + } else { + memcpy(name_buffer, app_name.c_str(), name_len + 1); // Include null terminator } - device_name = name_with_suffix.c_str(); + device_name = name_buffer; } err = esp_ble_gap_set_device_name(device_name); @@ -308,13 +316,21 @@ bool ESP32BLE::ble_setup_() { bool ESP32BLE::ble_dismantle_() { esp_err_t err = esp_bluedroid_disable(); if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_bluedroid_disable failed: %d", err); - return false; + // ESP_ERR_INVALID_STATE means Bluedroid is already disabled, which is fine + if (err != ESP_ERR_INVALID_STATE) { + ESP_LOGE(TAG, "esp_bluedroid_disable failed: %d", err); + return false; + } + ESP_LOGD(TAG, "Already disabled"); } err = esp_bluedroid_deinit(); if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_bluedroid_deinit failed: %d", err); - return false; + // ESP_ERR_INVALID_STATE means Bluedroid is already deinitialized, which is fine + if (err != ESP_ERR_INVALID_STATE) { + ESP_LOGE(TAG, "esp_bluedroid_deinit failed: %d", err); + return false; + } + ESP_LOGD(TAG, "Already deinitialized"); } #ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 393ec2e9115..1999c870f8b 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -212,17 +212,23 @@ extern ESP32BLE *global_ble; template class BLEEnabledCondition : public Condition { public: - bool check(const Ts &...x) override { return global_ble->is_active(); } + bool check(const Ts &...x) override { return global_ble != nullptr && global_ble->is_active(); } }; template class BLEEnableAction : public Action { public: - void play(const Ts &...x) override { global_ble->enable(); } + void play(const Ts &...x) override { + if (global_ble != nullptr) + global_ble->enable(); + } }; template class BLEDisableAction : public Action { public: - void play(const Ts &...x) override { global_ble->disable(); } + void play(const Ts &...x) override { + if (global_ble != nullptr) + global_ble->disable(); + } }; } // namespace esphome::esp32_ble diff --git a/esphome/components/esp32_ble/ble_uuid.cpp b/esphome/components/esp32_ble/ble_uuid.cpp index dcbb285e078..c6b27f3bb96 100644 --- a/esphome/components/esp32_ble/ble_uuid.cpp +++ b/esphome/components/esp32_ble/ble_uuid.cpp @@ -143,9 +143,8 @@ bool ESPBTUUID::operator==(const ESPBTUUID &uuid) const { return this->as_128bit() == uuid.as_128bit(); } esp_bt_uuid_t ESPBTUUID::get_uuid() const { return this->uuid_; } -std::string ESPBTUUID::to_string() const { - char buf[40]; // Enough for 128-bit UUID with dashes - char *pos = buf; +void ESPBTUUID::to_str(std::span output) const { + char *pos = output.data(); switch (this->uuid_.len) { case ESP_UUID_LEN_16: @@ -156,7 +155,7 @@ std::string ESPBTUUID::to_string() const { *pos++ = format_hex_pretty_char((this->uuid_.uuid.uuid16 >> 4) & 0x0F); *pos++ = format_hex_pretty_char(this->uuid_.uuid.uuid16 & 0x0F); *pos = '\0'; - return std::string(buf); + return; case ESP_UUID_LEN_32: *pos++ = '0'; @@ -165,7 +164,7 @@ std::string ESPBTUUID::to_string() const { *pos++ = format_hex_pretty_char((this->uuid_.uuid.uuid32 >> shift) & 0x0F); } *pos = '\0'; - return std::string(buf); + return; default: case ESP_UUID_LEN_128: @@ -179,9 +178,13 @@ std::string ESPBTUUID::to_string() const { } } *pos = '\0'; - return std::string(buf); + return; } - return ""; +} +std::string ESPBTUUID::to_string() const { + char buf[UUID_STR_LEN]; + this->to_str(buf); + return std::string(buf); } } // namespace esphome::esp32_ble diff --git a/esphome/components/esp32_ble/ble_uuid.h b/esphome/components/esp32_ble/ble_uuid.h index 4cf2d10abdc..ed561d70e4a 100644 --- a/esphome/components/esp32_ble/ble_uuid.h +++ b/esphome/components/esp32_ble/ble_uuid.h @@ -7,11 +7,15 @@ #ifdef USE_ESP32 #ifdef USE_ESP32_BLE_UUID +#include #include #include 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(); @@ -37,6 +41,7 @@ class ESPBTUUID { esp_bt_uuid_t get_uuid() const; std::string to_string() const; + void to_str(std::span output) const; protected: esp_bt_uuid_t uuid_; diff --git a/esphome/components/esp32_ble_client/ble_characteristic.cpp b/esphome/components/esp32_ble_client/ble_characteristic.cpp index e0d0174c570..e830702f114 100644 --- a/esphome/components/esp32_ble_client/ble_characteristic.cpp +++ b/esphome/components/esp32_ble_client/ble_characteristic.cpp @@ -50,8 +50,12 @@ void BLECharacteristic::parse_descriptors() { desc->handle = result.handle; desc->characteristic = this; this->descriptors.push_back(desc); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char uuid_buf[espbt::UUID_STR_LEN]; + desc->uuid.to_str(uuid_buf); ESP_LOGV(TAG, "[%d] [%s] descriptor %s, handle 0x%x", this->service->client->get_connection_index(), - this->service->client->address_str(), desc->uuid.to_string().c_str(), desc->handle); + this->service->client->address_str(), uuid_buf, desc->handle); +#endif offset++; } } diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index a09390c7478..8017b577f42 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -411,12 +411,15 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->update_conn_params_(MEDIUM_MIN_CONN_INTERVAL, MEDIUM_MAX_CONN_INTERVAL, 0, MEDIUM_CONN_TIMEOUT, "medium"); } else if (this->connection_type_ != espbt::ConnectionType::V3_WITH_CACHE) { #ifdef USE_ESP32_BLE_DEVICE +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE for (auto &svc : this->services_) { - ESP_LOGV(TAG, "[%d] [%s] Service UUID: %s", this->connection_index_, this->address_str_, - svc->uuid.to_string().c_str()); + char uuid_buf[espbt::UUID_STR_LEN]; + svc->uuid.to_str(uuid_buf); + ESP_LOGV(TAG, "[%d] [%s] Service UUID: %s", this->connection_index_, this->address_str_, uuid_buf); ESP_LOGV(TAG, "[%d] [%s] start_handle: 0x%x end_handle: 0x%x", this->connection_index_, this->address_str_, svc->start_handle, svc->end_handle); } +#endif #endif } ESP_LOGI(TAG, "[%d] [%s] Service discovery complete", this->connection_index_, this->address_str_); diff --git a/esphome/components/esp32_ble_client/ble_service.cpp b/esphome/components/esp32_ble_client/ble_service.cpp index deaaa3de023..695f468c5b9 100644 --- a/esphome/components/esp32_ble_client/ble_service.cpp +++ b/esphome/components/esp32_ble_client/ble_service.cpp @@ -64,9 +64,12 @@ void BLEService::parse_characteristics() { characteristic->handle = result.char_handle; characteristic->service = this; this->characteristics.push_back(characteristic); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char uuid_buf[espbt::UUID_STR_LEN]; + characteristic->uuid.to_str(uuid_buf); ESP_LOGV(TAG, "[%d] [%s] characteristic %s, handle 0x%x, properties 0x%x", this->client->get_connection_index(), - this->client->address_str(), characteristic->uuid.to_string().c_str(), characteristic->handle, - characteristic->properties); + this->client->address_str(), uuid_buf, characteristic->handle, characteristic->properties); +#endif offset++; } } diff --git a/esphome/components/esp32_ble_server/ble_characteristic.cpp b/esphome/components/esp32_ble_server/ble_characteristic.cpp index 7627a583384..0482848ea0f 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.cpp +++ b/esphome/components/esp32_ble_server/ble_characteristic.cpp @@ -109,7 +109,11 @@ void BLECharacteristic::do_create(BLEService *service) { esp_attr_control_t control; control.auto_rsp = ESP_GATT_RSP_BY_APP; - ESP_LOGV(TAG, "Creating characteristic - %s", this->uuid_.to_string().c_str()); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char uuid_buf[esp32_ble::UUID_STR_LEN]; + this->uuid_.to_str(uuid_buf); + ESP_LOGV(TAG, "Creating characteristic - %s", uuid_buf); +#endif esp_bt_uuid_t uuid = this->uuid_.get_uuid(); esp_err_t err = esp_ble_gatts_add_char(service->get_handle(), &uuid, static_cast(this->permissions_), diff --git a/esphome/components/esp32_ble_server/ble_descriptor.cpp b/esphome/components/esp32_ble_server/ble_descriptor.cpp index 2d053c09bd1..4ffca7312b6 100644 --- a/esphome/components/esp32_ble_server/ble_descriptor.cpp +++ b/esphome/components/esp32_ble_server/ble_descriptor.cpp @@ -34,7 +34,11 @@ void BLEDescriptor::do_create(BLECharacteristic *characteristic) { esp_attr_control_t control; control.auto_rsp = ESP_GATT_AUTO_RSP; - ESP_LOGV(TAG, "Creating descriptor - %s", this->uuid_.to_string().c_str()); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char uuid_buf[esp32_ble::UUID_STR_LEN]; + this->uuid_.to_str(uuid_buf); + ESP_LOGV(TAG, "Creating descriptor - %s", uuid_buf); +#endif esp_bt_uuid_t uuid = this->uuid_.get_uuid(); esp_err_t err = esp_ble_gatts_add_char_descr(this->characteristic_->get_service()->get_handle(), &uuid, this->permissions_, &this->value_, &control); diff --git a/esphome/components/esp32_ble_server/ble_server.cpp b/esphome/components/esp32_ble_server/ble_server.cpp index 0e58224a5a4..2c13a8ac365 100644 --- a/esphome/components/esp32_ble_server/ble_server.cpp +++ b/esphome/components/esp32_ble_server/ble_server.cpp @@ -106,7 +106,11 @@ void BLEServer::restart_advertising_() { } BLEService *BLEServer::create_service(ESPBTUUID uuid, bool advertise, uint16_t num_handles) { - ESP_LOGV(TAG, "Creating BLE service - %s", uuid.to_string().c_str()); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char uuid_buf[esp32_ble::UUID_STR_LEN]; + uuid.to_str(uuid_buf); + ESP_LOGV(TAG, "Creating BLE service - %s", uuid_buf); +#endif // Calculate the inst_id for the service uint8_t inst_id = 0; for (; inst_id < 0xFF; inst_id++) { @@ -115,7 +119,9 @@ BLEService *BLEServer::create_service(ESPBTUUID uuid, bool advertise, uint16_t n } } if (inst_id == 0xFF) { - ESP_LOGW(TAG, "Could not create BLE service %s, too many instances", uuid.to_string().c_str()); + char warn_uuid_buf[esp32_ble::UUID_STR_LEN]; + uuid.to_str(warn_uuid_buf); + ESP_LOGW(TAG, "Could not create BLE service %s, too many instances", warn_uuid_buf); return nullptr; } BLEService *service = // NOLINT(cppcoreguidelines-owning-memory) @@ -128,7 +134,11 @@ BLEService *BLEServer::create_service(ESPBTUUID uuid, bool advertise, uint16_t n } void BLEServer::remove_service(ESPBTUUID uuid, uint8_t inst_id) { - ESP_LOGV(TAG, "Removing BLE service - %s %d", uuid.to_string().c_str(), inst_id); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char uuid_buf[esp32_ble::UUID_STR_LEN]; + uuid.to_str(uuid_buf); + ESP_LOGV(TAG, "Removing BLE service - %s %d", uuid_buf, inst_id); +#endif for (auto it = this->services_.begin(); it != this->services_.end(); ++it) { if (it->uuid == uuid && it->inst_id == inst_id) { it->service->do_delete(); @@ -137,7 +147,9 @@ void BLEServer::remove_service(ESPBTUUID uuid, uint8_t inst_id) { return; } } - ESP_LOGW(TAG, "BLE service %s %d does not exist", uuid.to_string().c_str(), inst_id); + char warn_uuid_buf[esp32_ble::UUID_STR_LEN]; + uuid.to_str(warn_uuid_buf); + ESP_LOGW(TAG, "BLE service %s %d does not exist", warn_uuid_buf, inst_id); } BLEService *BLEServer::get_service(ESPBTUUID uuid, uint8_t inst_id) { diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 4e25434aad9..37e74672ed8 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 +from esphome.components import esp32_ble, ota from esphome.components.esp32 import add_idf_sdkconfig_option from esphome.components.esp32_ble import ( IDF_MAX_CONNECTIONS, @@ -328,7 +328,7 @@ async def to_code(config): # Note: CONFIG_BT_ACL_CONNECTIONS and CONFIG_BTDM_CTRL_BLE_MAX_CONN are now # configured in esp32_ble component based on max_connections setting - cg.add_define("USE_OTA_STATE_CALLBACK") # To be notified when an OTA update starts + ota.request_ota_state_listeners() # To be notified when an OTA update starts cg.add_define("USE_ESP32_BLE_CLIENT") CORE.add_job(_add_ble_features) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index d3c5edfb946..47da2e3570b 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -71,21 +71,24 @@ void ESP32BLETracker::setup() { global_esp32_ble_tracker = this; -#ifdef USE_OTA - ota::get_global_ota_callback()->add_on_state_callback( - [this](ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) { - if (state == ota::OTA_STARTED) { - this->stop_scan(); -#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT - for (auto *client : this->clients_) { - client->disconnect(); - } -#endif - } - }); +#ifdef USE_OTA_STATE_LISTENER + ota::get_global_ota_callback()->add_global_state_listener(this); #endif } +#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) { + this->stop_scan(); +#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT + for (auto *client : this->clients_) { + client->disconnect(); + } +#endif + } +} +#endif + void ESP32BLETracker::loop() { if (!this->parent_->is_active()) { this->ble_was_disabled_ = true; @@ -185,7 +188,10 @@ 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) { - ESP_LOGE(TAG, "Cannot stop scan: %s", this->scanner_state_to_string_(this->scanner_state_)); + // If scanner is already idle, there's nothing to stop - this is not an error + if (this->scanner_state_ != ScannerState::IDLE) { + ESP_LOGE(TAG, "Cannot stop scan: %s", this->scanner_state_to_string_(this->scanner_state_)); + } return; } // Reset timeout state machine when stopping scan @@ -435,24 +441,31 @@ void ESPBTDevice::parse_scan_rst(const BLEScanResult &scan_result) { ESP_LOGVV(TAG, " Ad Flag: %u", *this->ad_flag_); } for (auto &uuid : this->service_uuids_) { - ESP_LOGVV(TAG, " Service UUID: %s", uuid.to_string().c_str()); + char uuid_buf[esp32_ble::UUID_STR_LEN]; + uuid.to_str(uuid_buf); + ESP_LOGVV(TAG, " Service UUID: %s", uuid_buf); } for (auto &data : this->manufacturer_datas_) { auto ibeacon = ESPBLEiBeacon::from_manufacturer_data(data); if (ibeacon.has_value()) { ESP_LOGVV(TAG, " Manufacturer iBeacon:"); - ESP_LOGVV(TAG, " UUID: %s", ibeacon.value().get_uuid().to_string().c_str()); + 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 { - ESP_LOGVV(TAG, " Manufacturer ID: %s, data: %s", data.uuid.to_string().c_str(), - format_hex_pretty(data.data).c_str()); + 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(data.data).c_str()); } } for (auto &data : this->service_datas_) { ESP_LOGVV(TAG, " Service data:"); - ESP_LOGVV(TAG, " UUID: %s", data.uuid.to_string().c_str()); + 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(data.data).c_str()); } diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 92d13a62ad6..b64e36279c6 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -22,6 +22,10 @@ #include "esphome/components/esp32_ble/ble_uuid.h" #include "esphome/components/esp32_ble/ble_scan_result.h" +#ifdef USE_OTA_STATE_LISTENER +#include "esphome/components/ota/ota_backend.h" +#endif + namespace esphome::esp32_ble_tracker { using namespace esp32_ble; @@ -241,6 +245,9 @@ class ESP32BLETracker : public Component, public GAPScanEventHandler, public GATTcEventHandler, public BLEStatusEventHandler, +#ifdef USE_OTA_STATE_LISTENER + public ota::OTAGlobalStateListener, +#endif public Parented { public: void set_scan_duration(uint32_t scan_duration) { scan_duration_ = scan_duration; } @@ -274,6 +281,10 @@ class ESP32BLETracker : public Component, void gap_scan_event_handler(const BLEScanResult &scan_result) override; void ble_before_disabled_event_handler() override; +#ifdef USE_OTA_STATE_LISTENER + 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 void add_scanner_state_listener(BLEScannerStateListener *listener) { this->scanner_state_listeners_.push_back(listener); diff --git a/esphome/components/esp32_camera/__init__.py b/esphome/components/esp32_camera/__init__.py index d9d9bc0a56c..db6244fb3f7 100644 --- a/esphome/components/esp32_camera/__init__.py +++ b/esphome/components/esp32_camera/__init__.py @@ -2,8 +2,8 @@ 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 +from esphome.components import i2c, socket +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 ( @@ -27,7 +27,7 @@ import esphome.final_validate as fv _LOGGER = logging.getLogger(__name__) -AUTO_LOAD = ["camera"] +AUTO_LOAD = ["camera", "socket"] DEPENDENCIES = ["esp32"] esp32_camera_ns = cg.esphome_ns.namespace("esp32_camera") @@ -186,7 +186,7 @@ CONFIG_SCHEMA = cv.All( { cv.Required(CONF_PIN): pins.internal_gpio_input_pin_number, cv.Optional(CONF_FREQUENCY, default="20MHz"): cv.All( - cv.frequency, cv.Range(min=8e6, max=20e6) + cv.frequency, cv.float_range(min=8e6, max=20e6) ), } ), @@ -324,6 +324,7 @@ SETTERS = { async def to_code(config): cg.add_define("USE_CAMERA") + socket.require_wake_loop_threadsafe() var = cg.new_Pvariable(config[CONF_ID]) await setup_entity(var, config, "camera") await cg.register_component(var, config) @@ -352,6 +353,8 @@ async def to_code(config): cg.add_define("USE_CAMERA") add_idf_component(name="espressif/esp32-camera", ref="2.1.1") + add_idf_sdkconfig_option("CONFIG_SCCB_HARDWARE_I2C_DRIVER_NEW", True) + add_idf_sdkconfig_option("CONFIG_SCCB_HARDWARE_I2C_DRIVER_LEGACY", False) for conf in config.get(CONF_ON_STREAM_START, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) diff --git a/esphome/components/esp32_camera/esp32_camera.cpp b/esphome/components/esp32_camera/esp32_camera.cpp index 5080a6f32db..06ba7ff16f0 100644 --- a/esphome/components/esp32_camera/esp32_camera.cpp +++ b/esphome/components/esp32_camera/esp32_camera.cpp @@ -11,6 +11,10 @@ namespace esphome { namespace esp32_camera { static const char *const TAG = "esp32_camera"; +static constexpr size_t FRAMEBUFFER_TASK_STACK_SIZE = 1792; +#if ESPHOME_LOG_LEVEL < ESPHOME_LOG_LEVEL_VERBOSE +static constexpr uint32_t FRAME_LOG_INTERVAL_MS = 60000; +#endif /* ---------------- public API (derivated) ---------------- */ void ESP32Camera::setup() { @@ -39,12 +43,12 @@ void ESP32Camera::setup() { this->framebuffer_get_queue_ = xQueueCreate(1, sizeof(camera_fb_t *)); this->framebuffer_return_queue_ = xQueueCreate(1, sizeof(camera_fb_t *)); xTaskCreatePinnedToCore(&ESP32Camera::framebuffer_task, - "framebuffer_task", // name - 1024, // stack size - this, // task pv params - 1, // priority - nullptr, // handle - 1 // core + "framebuffer_task", // name + FRAMEBUFFER_TASK_STACK_SIZE, // stack size + this, // task pv params + 1, // priority + nullptr, // handle + 1 // core ); } @@ -164,6 +168,19 @@ void ESP32Camera::dump_config() { } void ESP32Camera::loop() { + // Fast path: skip all work when truly idle + // (no current image, no pending requests, and not time for idle request yet) + const uint32_t now = App.get_loop_component_start_time(); + if (!this->current_image_ && !this->has_requested_image_()) { + // Only check idle interval when we're otherwise idle + if (this->idle_update_interval_ != 0 && now - this->last_idle_request_ > this->idle_update_interval_) { + this->last_idle_request_ = now; + this->request_image(camera::IDLE); + } else { + return; + } + } + // check if we can return the image if (this->can_return_image_()) { // return image @@ -172,13 +189,6 @@ void ESP32Camera::loop() { this->current_image_.reset(); } - // request idle image every idle_update_interval - const uint32_t now = App.get_loop_component_start_time(); - if (this->idle_update_interval_ != 0 && now - this->last_idle_request_ > this->idle_update_interval_) { - this->last_idle_request_ = now; - this->request_image(camera::IDLE); - } - // Check if we should fetch a new image if (!this->has_requested_image_()) return; @@ -204,7 +214,20 @@ void ESP32Camera::loop() { } this->current_image_ = std::make_shared(fb, this->single_requesters_ | this->stream_requesters_); - ESP_LOGD(TAG, "Got Image: len=%u", fb->len); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + ESP_LOGV(TAG, "Got Image: len=%u", fb->len); +#else + // Initialize log time on first frame to ensure accurate interval measurement + if (this->frame_count_ == 0) { + this->last_log_time_ = now; + } + this->frame_count_++; + if (now - this->last_log_time_ >= FRAME_LOG_INTERVAL_MS) { + ESP_LOGD(TAG, "Received %u images in last %us", this->frame_count_, FRAME_LOG_INTERVAL_MS / 1000); + this->last_log_time_ = now; + this->frame_count_ = 0; + } +#endif for (auto *listener : this->listeners_) { listener->on_camera_image(this->current_image_); } @@ -405,6 +428,10 @@ void ESP32Camera::framebuffer_task(void *pv) { while (true) { camera_fb_t *framebuffer = esp_camera_fb_get(); xQueueSend(that->framebuffer_get_queue_, &framebuffer, portMAX_DELAY); + // Only wake the main loop if there's a pending request to consume the frame + if (that->has_requested_image_()) { + App.wake_loop_threadsafe(); + } // return is no-op for config with 1 fb xQueueReceive(that->framebuffer_return_queue_, &framebuffer, portMAX_DELAY); esp_camera_fb_return(framebuffer); diff --git a/esphome/components/esp32_camera/esp32_camera.h b/esphome/components/esp32_camera/esp32_camera.h index 54a7d6064a2..e97eb27c705 100644 --- a/esphome/components/esp32_camera/esp32_camera.h +++ b/esphome/components/esp32_camera/esp32_camera.h @@ -2,6 +2,7 @@ #ifdef USE_ESP32 +#include #include #include #include @@ -205,14 +206,18 @@ class ESP32Camera : public camera::Camera { esp_err_t init_error_{ESP_OK}; std::shared_ptr current_image_; - uint8_t single_requesters_{0}; - uint8_t stream_requesters_{0}; + std::atomic single_requesters_{0}; + std::atomic stream_requesters_{0}; QueueHandle_t framebuffer_get_queue_; QueueHandle_t framebuffer_return_queue_; std::vector listeners_; uint32_t last_idle_request_{0}; uint32_t last_update_{0}; +#if ESPHOME_LOG_LEVEL < ESPHOME_LOG_LEVEL_VERBOSE + uint32_t last_log_time_{0}; + uint16_t frame_count_{0}; +#endif #ifdef USE_I2C i2c::InternalI2CBus *i2c_bus_{nullptr}; #endif // USE_I2C diff --git a/esphome/components/esp8266_pwm/output.py b/esphome/components/esp8266_pwm/output.py index 1404ef8ac3d..2ddf4b90147 100644 --- a/esphome/components/esp8266_pwm/output.py +++ b/esphome/components/esp8266_pwm/output.py @@ -16,7 +16,7 @@ def valid_pwm_pin(value): esp8266_pwm_ns = cg.esphome_ns.namespace("esp8266_pwm") ESP8266PWM = esp8266_pwm_ns.class_("ESP8266PWM", output.FloatOutput, cg.Component) SetFrequencyAction = esp8266_pwm_ns.class_("SetFrequencyAction", automation.Action) -validate_frequency = cv.All(cv.frequency, cv.Range(min=1.0e-6)) +validate_frequency = cv.All(cv.frequency, cv.float_range(min=1.0e-6)) CONFIG_SCHEMA = cv.All( output.FLOAT_OUTPUT_SCHEMA.extend( diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 6cfd5435531..b589a6119f4 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -41,10 +41,6 @@ static constexpr size_t SHA256_HEX_SIZE = 64; // SHA256 hash as hex string (32 #endif // USE_OTA_PASSWORD void ESPHomeOTAComponent::setup() { -#ifdef USE_OTA_STATE_CALLBACK - ota::register_ota_platform(this); -#endif - this->server_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0); // monitored for incoming connections if (this->server_ == nullptr) { this->log_socket_error_(LOG_STR("creation")); @@ -297,8 +293,8 @@ void ESPHomeOTAComponent::handle_data_() { // accidentally trigger the update process. this->log_start_(LOG_STR("update")); this->status_set_warning(); -#ifdef USE_OTA_STATE_CALLBACK - this->state_callback_.call(ota::OTA_STARTED, 0.0f, 0); +#ifdef USE_OTA_STATE_LISTENER + this->notify_state_(ota::OTA_STARTED, 0.0f, 0); #endif // This will block for a few seconds as it locks flash @@ -357,8 +353,8 @@ void ESPHomeOTAComponent::handle_data_() { last_progress = now; float percentage = (total * 100.0f) / ota_size; ESP_LOGD(TAG, "Progress: %0.1f%%", percentage); -#ifdef USE_OTA_STATE_CALLBACK - this->state_callback_.call(ota::OTA_IN_PROGRESS, percentage, 0); +#ifdef USE_OTA_STATE_LISTENER + this->notify_state_(ota::OTA_IN_PROGRESS, percentage, 0); #endif // feed watchdog and give other tasks a chance to run this->yield_and_feed_watchdog_(); @@ -387,8 +383,8 @@ void ESPHomeOTAComponent::handle_data_() { delay(10); ESP_LOGI(TAG, "Update complete"); this->status_clear_warning(); -#ifdef USE_OTA_STATE_CALLBACK - this->state_callback_.call(ota::OTA_COMPLETED, 100.0f, 0); +#ifdef USE_OTA_STATE_LISTENER + this->notify_state_(ota::OTA_COMPLETED, 100.0f, 0); #endif delay(100); // NOLINT App.safe_reboot(); @@ -402,8 +398,8 @@ error: } this->status_momentary_error("err", 5000); -#ifdef USE_OTA_STATE_CALLBACK - this->state_callback_.call(ota::OTA_ERROR, 0.0f, static_cast(error_code)); +#ifdef USE_OTA_STATE_LISTENER + this->notify_state_(ota::OTA_ERROR, 0.0f, static_cast(error_code)); #endif } diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 057461e6a41..4412a65757a 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -80,6 +80,7 @@ class ESPHomeOTAComponent : public ota::OTAComponent { #ifdef USE_OTA_PASSWORD std::string password_; + std::unique_ptr auth_buf_; #endif // USE_OTA_PASSWORD std::unique_ptr server_; @@ -93,7 +94,6 @@ class ESPHomeOTAComponent : public ota::OTAComponent { uint8_t handshake_buf_pos_{0}; uint8_t ota_features_{0}; #ifdef USE_OTA_PASSWORD - std::unique_ptr auth_buf_; uint8_t auth_buf_pos_{0}; uint8_t auth_type_{0}; // Store auth type to know which hasher to use #endif // USE_OTA_PASSWORD diff --git a/esphome/components/espnow/packet_transport/espnow_transport.cpp b/esphome/components/espnow/packet_transport/espnow_transport.cpp index d30e9447a0a..c1252acc9dc 100644 --- a/esphome/components/espnow/packet_transport/espnow_transport.cpp +++ b/esphome/components/espnow/packet_transport/espnow_transport.cpp @@ -13,7 +13,7 @@ static const char *const TAG = "espnow.transport"; bool ESPNowTransport::should_send() { return this->parent_ != nullptr && !this->parent_->is_failed(); } void ESPNowTransport::setup() { - packet_transport::PacketTransport::setup(); + PacketTransport::setup(); if (this->parent_ == nullptr) { ESP_LOGE(TAG, "ESPNow component not set"); @@ -26,15 +26,10 @@ void ESPNowTransport::setup() { this->peer_address_[2], this->peer_address_[3], this->peer_address_[4], this->peer_address_[5]); // Register received handler - this->parent_->register_received_handler(static_cast(this)); + this->parent_->register_received_handler(this); // Register broadcasted handler - this->parent_->register_broadcasted_handler(static_cast(this)); -} - -void ESPNowTransport::update() { - packet_transport::PacketTransport::update(); - this->updated_ = true; + this->parent_->register_broadcasted_handler(this); } void ESPNowTransport::send_packet(const std::vector &buf) const { diff --git a/esphome/components/espnow/packet_transport/espnow_transport.h b/esphome/components/espnow/packet_transport/espnow_transport.h index 3629fad2cd5..d85119db7dd 100644 --- a/esphome/components/espnow/packet_transport/espnow_transport.h +++ b/esphome/components/espnow/packet_transport/espnow_transport.h @@ -18,7 +18,6 @@ class ESPNowTransport : public packet_transport::PacketTransport, public ESPNowBroadcastedHandler { public: void setup() override; - void update() override; float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } void set_peer_address(peer_address_t address) { diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index b4b1fcd9f60..e1ed327fb97 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -434,10 +434,13 @@ def _final_validate_rmii_pins(config: ConfigType) -> None: # Check all used pins against RMII reserved pins for pin_list in pins.PIN_SCHEMA_REGISTRY.pins_used.values(): - for pin_path, _, pin_config in pin_list: + for pin_path, pin_device, pin_config in pin_list: pin_num = pin_config.get(CONF_NUMBER) if pin_num not in rmii_pins: continue + # Skip if pin is not directly on ESP, but at some expander (device set to something else than 'None') + if pin_device is not None: + continue # Found a conflict - show helpful error message pin_function = rmii_pins[pin_num] component_path = ".".join(str(p) for p in pin_path) diff --git a/esphome/components/event/event.h b/esphome/components/event/event.h index e4b2e0b845b..0d5850d339b 100644 --- a/esphome/components/event/event.h +++ b/esphome/components/event/event.h @@ -50,7 +50,7 @@ class Event : public EntityBase, public EntityBase_DeviceClass { void add_on_event_callback(std::function &&callback); protected: - CallbackManager event_callback_; + LazyCallbackManager event_callback_; FixedVector types_; private: diff --git a/esphome/components/factory_reset/__init__.py b/esphome/components/factory_reset/__init__.py index f3cefe6970d..5784d09ce6f 100644 --- a/esphome/components/factory_reset/__init__.py +++ b/esphome/components/factory_reset/__init__.py @@ -50,7 +50,9 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(): cv.declare_id(FactoryResetComponent), cv.Optional(CONF_MAX_DELAY, default="10s"): cv.All( cv.positive_time_period_seconds, - cv.Range(min=cv.TimePeriod(milliseconds=1000)), + cv.Range( + min=cv.TimePeriod(seconds=1), max=cv.TimePeriod(seconds=65535) + ), ), cv.Optional(CONF_RESETS_REQUIRED): cv.positive_not_null_int, cv.Optional(CONF_ON_INCREMENT): validate_automation( @@ -82,7 +84,7 @@ async def to_code(config): var = cg.new_Pvariable( config[CONF_ID], reset_count, - config[CONF_MAX_DELAY].total_milliseconds, + config[CONF_MAX_DELAY].total_seconds, ) await cg.register_component(var, config) for conf in config.get(CONF_ON_INCREMENT, []): diff --git a/esphome/components/factory_reset/factory_reset.cpp b/esphome/components/factory_reset/factory_reset.cpp index c900759d904..bbbe3991486 100644 --- a/esphome/components/factory_reset/factory_reset.cpp +++ b/esphome/components/factory_reset/factory_reset.cpp @@ -8,8 +8,7 @@ #if !defined(USE_RP2040) && !defined(USE_HOST) -namespace esphome { -namespace factory_reset { +namespace esphome::factory_reset { static const char *const TAG = "factory_reset"; static const uint32_t POWER_CYCLES_KEY = 0xFA5C0DE; @@ -33,10 +32,10 @@ void FactoryResetComponent::dump_config() { this->flash_.load(&count); ESP_LOGCONFIG(TAG, "Factory Reset by Reset:"); ESP_LOGCONFIG(TAG, - " Max interval between resets %" PRIu32 " seconds\n" + " Max interval between resets: %u seconds\n" " Current count: %u\n" " Factory reset after %u resets", - this->max_interval_ / 1000, count, this->required_count_); + this->max_interval_, count, this->required_count_); } void FactoryResetComponent::save_(uint8_t count) { @@ -61,8 +60,8 @@ void FactoryResetComponent::setup() { } this->save_(count); ESP_LOGD(TAG, "Power on reset detected, incremented count to %u", count); - this->set_timeout(this->max_interval_, [this]() { - ESP_LOGD(TAG, "No reset in the last %" PRIu32 " seconds, resetting count", this->max_interval_ / 1000); + this->set_timeout(static_cast(this->max_interval_) * 1000, [this]() { + ESP_LOGD(TAG, "No reset in the last %u seconds, resetting count", this->max_interval_); this->save_(0); // reset count }); } else { @@ -70,7 +69,6 @@ void FactoryResetComponent::setup() { } } -} // namespace factory_reset -} // namespace esphome +} // namespace esphome::factory_reset #endif // !defined(USE_RP2040) && !defined(USE_HOST) diff --git a/esphome/components/factory_reset/factory_reset.h b/esphome/components/factory_reset/factory_reset.h index 80942b29bda..990bb2edb66 100644 --- a/esphome/components/factory_reset/factory_reset.h +++ b/esphome/components/factory_reset/factory_reset.h @@ -9,12 +9,11 @@ #include #endif -namespace esphome { -namespace factory_reset { +namespace esphome::factory_reset { class FactoryResetComponent : public Component { public: - FactoryResetComponent(uint8_t required_count, uint32_t max_interval) - : required_count_(required_count), max_interval_(max_interval) {} + FactoryResetComponent(uint8_t required_count, uint16_t max_interval) + : max_interval_(max_interval), required_count_(required_count) {} void dump_config() override; void setup() override; @@ -26,9 +25,9 @@ class FactoryResetComponent : public Component { ~FactoryResetComponent() = default; void save_(uint8_t count); ESPPreferenceObject flash_{}; // saves the number of fast power cycles - uint8_t required_count_; // The number of boot attempts before fast boot is enabled - uint32_t max_interval_; // max interval between power cycles CallbackManager increment_callback_{}; + uint16_t max_interval_; // max interval between power cycles in seconds + uint8_t required_count_; // The number of boot attempts before fast boot is enabled }; class FastBootTrigger : public Trigger { @@ -37,7 +36,6 @@ class FastBootTrigger : public Trigger { parent->add_increment_callback([this](uint8_t current, uint8_t target) { this->trigger(current, target); }); } }; -} // namespace factory_reset -} // namespace esphome +} // namespace esphome::factory_reset #endif // !defined(USE_RP2040) && !defined(USE_HOST) diff --git a/esphome/components/fan/fan.cpp b/esphome/components/fan/fan.cpp index d37825a6513..bf5506da4b4 100644 --- a/esphome/components/fan/fan.cpp +++ b/esphome/components/fan/fan.cpp @@ -19,22 +19,28 @@ const LogString *fan_direction_to_string(FanDirection direction) { } } -FanCall &FanCall::set_preset_mode(const std::string &preset_mode) { return this->set_preset_mode(preset_mode.c_str()); } +FanCall &FanCall::set_preset_mode(const std::string &preset_mode) { + return this->set_preset_mode(preset_mode.data(), preset_mode.size()); +} FanCall &FanCall::set_preset_mode(const char *preset_mode) { - if (preset_mode == nullptr || strlen(preset_mode) == 0) { + return this->set_preset_mode(preset_mode, preset_mode ? strlen(preset_mode) : 0); +} + +FanCall &FanCall::set_preset_mode(const char *preset_mode, size_t len) { + if (preset_mode == nullptr || len == 0) { this->preset_mode_ = nullptr; return *this; } // Find and validate pointer from traits immediately auto traits = this->parent_.get_traits(); - const char *validated_mode = traits.find_preset_mode(preset_mode); + const char *validated_mode = traits.find_preset_mode(preset_mode, len); if (validated_mode != nullptr) { this->preset_mode_ = validated_mode; // Store pointer from traits } else { // Preset mode not found in traits - log warning and don't set - ESP_LOGW(TAG, "%s: Preset mode '%s' not supported", this->parent_.get_name().c_str(), preset_mode); + ESP_LOGW(TAG, "%s: Preset mode '%.*s' not supported", this->parent_.get_name().c_str(), (int) len, preset_mode); this->preset_mode_ = nullptr; } return *this; @@ -140,7 +146,13 @@ 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->get_traits().find_preset_mode(preset_mode); } +const char *Fan::find_preset_mode_(const char *preset_mode) { + return this->find_preset_mode_(preset_mode, preset_mode ? strlen(preset_mode) : 0); +} + +const char *Fan::find_preset_mode_(const char *preset_mode, size_t len) { + return this->get_traits().find_preset_mode(preset_mode, len); +} bool Fan::set_preset_mode_(const char *preset_mode) { if (preset_mode == nullptr) { diff --git a/esphome/components/fan/fan.h b/esphome/components/fan/fan.h index e38a80dbbe3..7c79fda83e1 100644 --- a/esphome/components/fan/fan.h +++ b/esphome/components/fan/fan.h @@ -72,6 +72,7 @@ class FanCall { optional get_direction() const { return this->direction_; } FanCall &set_preset_mode(const std::string &preset_mode); FanCall &set_preset_mode(const char *preset_mode); + FanCall &set_preset_mode(const char *preset_mode, size_t len); const char *get_preset_mode() const { return this->preset_mode_; } bool has_preset_mode() const { return this->preset_mode_ != nullptr; } @@ -152,8 +153,9 @@ class Fan : public EntityBase { void clear_preset_mode_(); /// Find and return the matching preset mode pointer from traits, or nullptr if not found. const char *find_preset_mode_(const char *preset_mode); + const char *find_preset_mode_(const char *preset_mode, size_t len); - CallbackManager state_callback_{}; + LazyCallbackManager state_callback_{}; ESPPreferenceObject rtc_; FanRestoreMode restore_mode_; diff --git a/esphome/components/fan/fan_traits.h b/esphome/components/fan/fan_traits.h index 24987fe984b..c0c5f34c504 100644 --- a/esphome/components/fan/fan_traits.h +++ b/esphome/components/fan/fan_traits.h @@ -47,10 +47,13 @@ class FanTraits { bool supports_preset_modes() const { return !this->preset_modes_.empty(); } /// Find and return the matching preset mode pointer from supported modes, or nullptr if not found. const char *find_preset_mode(const char *preset_mode) const { - if (preset_mode == nullptr) + return this->find_preset_mode(preset_mode, preset_mode ? strlen(preset_mode) : 0); + } + const char *find_preset_mode(const char *preset_mode, size_t len) const { + if (preset_mode == nullptr || len == 0) return nullptr; for (const char *mode : this->preset_modes_) { - if (strcmp(mode, preset_mode) == 0) { + if (strncmp(mode, preset_mode, len) == 0 && mode[len] == '\0') { return mode; // Return pointer from traits } } diff --git a/esphome/components/hm3301/hm3301.cpp b/esphome/components/hm3301/hm3301.cpp index a19d9dd09fe..00fb85397c6 100644 --- a/esphome/components/hm3301/hm3301.cpp +++ b/esphome/components/hm3301/hm3301.cpp @@ -63,7 +63,7 @@ void HM3301Component::update() { int16_t aqi_value = -1; if (this->aqi_sensor_ != nullptr && pm_2_5_value != -1 && pm_10_0_value != -1) { - AbstractAQICalculator *calculator = this->aqi_calculator_factory_.get_calculator(this->aqi_calc_type_); + 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); } diff --git a/esphome/components/hm3301/hm3301.h b/esphome/components/hm3301/hm3301.h index 6779b4e195f..e155ed6b4b5 100644 --- a/esphome/components/hm3301/hm3301.h +++ b/esphome/components/hm3301/hm3301.h @@ -3,7 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -#include "aqi_calculator_factory.h" +#include "esphome/components/aqi/aqi_calculator_factory.h" namespace esphome { namespace hm3301 { @@ -19,7 +19,7 @@ class HM3301Component : public PollingComponent, public i2c::I2CDevice { void set_pm_10_0_sensor(sensor::Sensor *pm_10_0_sensor) { pm_10_0_sensor_ = pm_10_0_sensor; } void set_aqi_sensor(sensor::Sensor *aqi_sensor) { aqi_sensor_ = aqi_sensor; } - void set_aqi_calculation_type(AQICalculatorType aqi_calc_type) { aqi_calc_type_ = aqi_calc_type; } + void set_aqi_calculation_type(aqi::AQICalculatorType aqi_calc_type) { aqi_calc_type_ = aqi_calc_type; } void setup() override; void dump_config() override; @@ -41,8 +41,8 @@ class HM3301Component : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *pm_10_0_sensor_{nullptr}; sensor::Sensor *aqi_sensor_{nullptr}; - AQICalculatorType aqi_calc_type_; - AQICalculatorFactory aqi_calculator_factory_ = AQICalculatorFactory(); + aqi::AQICalculatorType aqi_calc_type_; + aqi::AQICalculatorFactory aqi_calculator_factory_ = aqi::AQICalculatorFactory(); bool validate_checksum_(const uint8_t *data); uint16_t get_sensor_value_(const uint8_t *data, uint8_t i); diff --git a/esphome/components/hm3301/sensor.py b/esphome/components/hm3301/sensor.py index 5eb17735184..389da97b1ed 100644 --- a/esphome/components/hm3301/sensor.py +++ b/esphome/components/hm3301/sensor.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor +from esphome.components.aqi import AQI_CALCULATION_TYPE, CONF_AQI, CONF_CALCULATION_TYPE import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -16,23 +17,16 @@ from esphome.const import ( ) DEPENDENCIES = ["i2c"] +AUTO_LOAD = ["aqi"] CODEOWNERS = ["@freekode"] hm3301_ns = cg.esphome_ns.namespace("hm3301") HM3301Component = hm3301_ns.class_( "HM3301Component", cg.PollingComponent, i2c.I2CDevice ) -AQICalculatorType = hm3301_ns.enum("AQICalculatorType") -CONF_AQI = "aqi" -CONF_CALCULATION_TYPE = "calculation_type" UNIT_INDEX = "index" -AQI_CALCULATION_TYPE = { - "CAQI": AQICalculatorType.CAQI_TYPE, - "AQI": AQICalculatorType.AQI_TYPE, -} - def _validate(config): if CONF_AQI in config and CONF_PM_2_5 not in config: diff --git a/esphome/components/hmac_sha256/__init__.py b/esphome/components/hmac_sha256/__init__.py new file mode 100644 index 00000000000..158d740dc59 --- /dev/null +++ b/esphome/components/hmac_sha256/__init__.py @@ -0,0 +1,6 @@ +import esphome.config_validation as cv + +AUTO_LOAD = ["sha256"] +CODEOWNERS = ["@dwmw2"] + +CONFIG_SCHEMA = cv.Schema({}) diff --git a/esphome/components/hmac_sha256/hmac_sha256.cpp b/esphome/components/hmac_sha256/hmac_sha256.cpp new file mode 100644 index 00000000000..cf5daf63af7 --- /dev/null +++ b/esphome/components/hmac_sha256/hmac_sha256.cpp @@ -0,0 +1,102 @@ +#include +#include +#include "hmac_sha256.h" +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_HOST) +#include "esphome/core/helpers.h" + +namespace esphome::hmac_sha256 { + +constexpr size_t SHA256_DIGEST_SIZE = 32; + +#if defined(USE_ESP32) || defined(USE_LIBRETINY) + +HmacSHA256::~HmacSHA256() { mbedtls_md_free(&this->ctx_); } + +void HmacSHA256::init(const uint8_t *key, size_t len) { + mbedtls_md_init(&this->ctx_); + const mbedtls_md_info_t *md_info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256); + mbedtls_md_setup(&this->ctx_, md_info, 1); // 1 = HMAC mode + mbedtls_md_hmac_starts(&this->ctx_, key, len); +} + +void HmacSHA256::add(const uint8_t *data, size_t len) { mbedtls_md_hmac_update(&this->ctx_, data, len); } + +void HmacSHA256::calculate() { mbedtls_md_hmac_finish(&this->ctx_, this->digest_); } + +void HmacSHA256::get_bytes(uint8_t *output) { memcpy(output, this->digest_, SHA256_DIGEST_SIZE); } + +void HmacSHA256::get_hex(char *output) { + for (size_t i = 0; i < SHA256_DIGEST_SIZE; i++) { + sprintf(output + (i * 2), "%02x", this->digest_[i]); + } +} + +bool HmacSHA256::equals_bytes(const uint8_t *expected) { + return memcmp(this->digest_, expected, SHA256_DIGEST_SIZE) == 0; +} + +bool HmacSHA256::equals_hex(const char *expected) { + char hex_output[SHA256_DIGEST_SIZE * 2 + 1]; + this->get_hex(hex_output); + hex_output[SHA256_DIGEST_SIZE * 2] = '\0'; + return strncmp(hex_output, expected, SHA256_DIGEST_SIZE * 2) == 0; +} + +#else + +HmacSHA256::~HmacSHA256() = default; + +// HMAC block size for SHA256 (RFC 2104) +constexpr size_t HMAC_BLOCK_SIZE = 64; + +void HmacSHA256::init(const uint8_t *key, size_t len) { + uint8_t ipad[HMAC_BLOCK_SIZE], opad[HMAC_BLOCK_SIZE]; + + memset(ipad, 0, sizeof(ipad)); + if (len > HMAC_BLOCK_SIZE) { + sha256::SHA256 keysha256; + keysha256.init(); + keysha256.add(key, len); + keysha256.calculate(); + keysha256.get_bytes(ipad); + } else { + memcpy(ipad, key, len); + } + memcpy(opad, ipad, sizeof(opad)); + + for (size_t i = 0; i < HMAC_BLOCK_SIZE; i++) { + ipad[i] ^= 0x36; + opad[i] ^= 0x5c; + } + + this->ihash_.init(); + this->ihash_.add(ipad, sizeof(ipad)); + + this->ohash_.init(); + this->ohash_.add(opad, sizeof(opad)); +} + +void HmacSHA256::add(const uint8_t *data, size_t len) { this->ihash_.add(data, len); } + +void HmacSHA256::calculate() { + uint8_t ibytes[32]; + + this->ihash_.calculate(); + this->ihash_.get_bytes(ibytes); + + this->ohash_.add(ibytes, sizeof(ibytes)); + this->ohash_.calculate(); +} + +void HmacSHA256::get_bytes(uint8_t *output) { this->ohash_.get_bytes(output); } + +void HmacSHA256::get_hex(char *output) { this->ohash_.get_hex(output); } + +bool HmacSHA256::equals_bytes(const uint8_t *expected) { return this->ohash_.equals_bytes(expected); } + +bool HmacSHA256::equals_hex(const char *expected) { return this->ohash_.equals_hex(expected); } + +#endif // USE_ESP32 || USE_LIBRETINY + +} // namespace esphome::hmac_sha256 +#endif diff --git a/esphome/components/hmac_sha256/hmac_sha256.h b/esphome/components/hmac_sha256/hmac_sha256.h new file mode 100644 index 00000000000..fa6b64aa949 --- /dev/null +++ b/esphome/components/hmac_sha256/hmac_sha256.h @@ -0,0 +1,59 @@ +#pragma once + +#include "esphome/core/defines.h" +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_HOST) + +#include + +#if defined(USE_ESP32) || defined(USE_LIBRETINY) +#include "mbedtls/md.h" +#else +#include "esphome/components/sha256/sha256.h" +#endif + +namespace esphome::hmac_sha256 { + +class HmacSHA256 { + public: + HmacSHA256() = default; + ~HmacSHA256(); + + /// Initialize a new HMAC-SHA256 digest computation. + void init(const uint8_t *key, size_t len); + void init(const char *key, size_t len) { this->init((const uint8_t *) key, len); } + void init(const std::string &key) { this->init(key.c_str(), key.length()); } + + /// Add bytes of data for the digest. + void add(const uint8_t *data, size_t len); + void add(const char *data, size_t len) { this->add((const uint8_t *) data, len); } + + /// Compute the digest, based on the provided data. + void calculate(); + + /// Retrieve the HMAC-SHA256 digest as bytes. + /// The output must be able to hold 32 bytes or more. + void get_bytes(uint8_t *output); + + /// Retrieve the HMAC-SHA256 digest as hex characters. + /// The output must be able to hold 64 bytes or more. + void get_hex(char *output); + + /// Compare the digest against a provided byte-encoded digest (32 bytes). + bool equals_bytes(const uint8_t *expected); + + /// Compare the digest against a provided hex-encoded digest (64 bytes). + bool equals_hex(const char *expected); + + protected: +#if defined(USE_ESP32) || defined(USE_LIBRETINY) + static constexpr size_t SHA256_DIGEST_SIZE = 32; + mbedtls_md_context_t ctx_{}; + uint8_t digest_[SHA256_DIGEST_SIZE]{}; +#else + sha256::SHA256 ihash_; + sha256::SHA256 ohash_; +#endif +}; + +} // namespace esphome::hmac_sha256 +#endif diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index f4fa448c5b4..b133aa69b22 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -69,9 +69,6 @@ def validate_url(value): def validate_ssl_verification(config): error_message = "" - if CORE.is_esp32 and not CORE.using_esp_idf and config[CONF_VERIFY_SSL]: - error_message = "ESPHome supports certificate verification only via ESP-IDF" - if CORE.is_rp2040 and config[CONF_VERIFY_SSL]: error_message = "ESPHome does not support certificate verification on RP2040" @@ -93,9 +90,9 @@ def validate_ssl_verification(config): def _declare_request_class(value): if CORE.is_host: return cv.declare_id(HttpRequestHost)(value) - if CORE.using_esp_idf: + if CORE.is_esp32: return cv.declare_id(HttpRequestIDF)(value) - if CORE.is_esp8266 or CORE.is_esp32 or CORE.is_rp2040: + if CORE.is_esp8266 or CORE.is_rp2040: return cv.declare_id(HttpRequestArduino)(value) return NotImplementedError @@ -121,11 +118,11 @@ CONFIG_SCHEMA = cv.All( cv.positive_not_null_time_period, cv.positive_time_period_milliseconds, ), - cv.SplitDefault(CONF_BUFFER_SIZE_RX, esp32_idf=512): cv.All( - cv.uint16_t, cv.only_with_esp_idf + cv.SplitDefault(CONF_BUFFER_SIZE_RX, esp32=512): cv.All( + cv.uint16_t, cv.only_on_esp32 ), - cv.SplitDefault(CONF_BUFFER_SIZE_TX, esp32_idf=512): cv.All( - cv.uint16_t, cv.only_with_esp_idf + cv.SplitDefault(CONF_BUFFER_SIZE_TX, esp32=512): cv.All( + cv.uint16_t, cv.only_on_esp32 ), cv.Optional(CONF_CA_CERTIFICATE_PATH): cv.All( cv.file_, @@ -158,25 +155,20 @@ async def to_code(config): cg.add(var.set_watchdog_timeout(timeout_ms)) if CORE.is_esp32: - if CORE.using_esp_idf: - cg.add(var.set_buffer_size_rx(config[CONF_BUFFER_SIZE_RX])) - cg.add(var.set_buffer_size_tx(config[CONF_BUFFER_SIZE_TX])) + cg.add(var.set_buffer_size_rx(config[CONF_BUFFER_SIZE_RX])) + cg.add(var.set_buffer_size_tx(config[CONF_BUFFER_SIZE_TX])) - esp32.add_idf_sdkconfig_option( - "CONFIG_MBEDTLS_CERTIFICATE_BUNDLE", - config.get(CONF_VERIFY_SSL), - ) - esp32.add_idf_sdkconfig_option( - "CONFIG_ESP_TLS_INSECURE", - not config.get(CONF_VERIFY_SSL), - ) - esp32.add_idf_sdkconfig_option( - "CONFIG_ESP_TLS_SKIP_SERVER_CERT_VERIFY", - not config.get(CONF_VERIFY_SSL), - ) - else: - cg.add_library("NetworkClientSecure", None) - cg.add_library("HTTPClient", None) + if config.get(CONF_VERIFY_SSL): + esp32.add_idf_sdkconfig_option("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE", True) + + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_TLS_INSECURE", + not config.get(CONF_VERIFY_SSL), + ) + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_TLS_SKIP_SERVER_CERT_VERIFY", + not config.get(CONF_VERIFY_SSL), + ) if CORE.is_esp8266: cg.add_library("ESP8266HTTPClient", None) if CORE.is_rp2040 and CORE.using_arduino: @@ -327,13 +319,15 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( { "http_request_host.cpp": {PlatformFramework.HOST_NATIVE}, "http_request_arduino.cpp": { - PlatformFramework.ESP32_ARDUINO, PlatformFramework.ESP8266_ARDUINO, PlatformFramework.RP2040_ARDUINO, PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, }, - "http_request_idf.cpp": {PlatformFramework.ESP32_IDF}, + "http_request_idf.cpp": { + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP32_IDF, + }, } ) diff --git a/esphome/components/http_request/http_request.cpp b/esphome/components/http_request/http_request.cpp index 806354baf16..11dde4715ab 100644 --- a/esphome/components/http_request/http_request.cpp +++ b/esphome/components/http_request/http_request.cpp @@ -4,8 +4,7 @@ #include -namespace esphome { -namespace http_request { +namespace esphome::http_request { static const char *const TAG = "http_request"; @@ -42,5 +41,4 @@ std::string HttpContainer::get_response_header(const std::string &header_name) { } } -} // namespace http_request -} // namespace esphome +} // namespace esphome::http_request diff --git a/esphome/components/http_request/http_request.h b/esphome/components/http_request/http_request.h index 8a82a44d7da..1b5fd9f00ef 100644 --- a/esphome/components/http_request/http_request.h +++ b/esphome/components/http_request/http_request.h @@ -15,8 +15,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace http_request { +namespace esphome::http_request { struct Header { std::string name; @@ -255,6 +254,9 @@ template class HttpRequestSendAction : public Action { size_t read_index = 0; while (container->get_bytes_read() < max_length) { int read = container->read(buf + read_index, std::min(max_length - read_index, 512)); + if (read <= 0) { + break; + } App.feed_wdt(); yield(); read_index += read; @@ -302,5 +304,4 @@ template class HttpRequestSendAction : public Action { size_t max_response_buffer_size_{SIZE_MAX}; }; -} // namespace http_request -} // namespace esphome +} // namespace esphome::http_request diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp index c64a7be5545..a653942b186 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" -#ifdef USE_ARDUINO +#if defined(USE_ARDUINO) && !defined(USE_ESP32) #include "esphome/components/network/util.h" #include "esphome/components/watchdog/watchdog.h" @@ -9,8 +9,7 @@ #include "esphome/core/defines.h" #include "esphome/core/log.h" -namespace esphome { -namespace http_request { +namespace esphome::http_request { static const char *const TAG = "http_request.arduino"; @@ -75,8 +74,6 @@ std::shared_ptr HttpRequestArduino::perform(const std::string &ur container->client_.setInsecure(); } bool status = container->client_.begin(url.c_str()); -#elif defined(USE_ESP32) - bool status = container->client_.begin(url.c_str()); #endif App.feed_wdt(); @@ -90,9 +87,6 @@ std::shared_ptr HttpRequestArduino::perform(const std::string &ur container->client_.setReuse(true); container->client_.setTimeout(this->timeout_); -#if defined(USE_ESP32) - container->client_.setConnectTimeout(this->timeout_); -#endif if (this->useragent_ != nullptr) { container->client_.setUserAgent(this->useragent_); @@ -177,7 +171,6 @@ void HttpContainerArduino::end() { this->client_.end(); } -} // namespace http_request -} // namespace esphome +} // namespace esphome::http_request -#endif // USE_ARDUINO +#endif // USE_ARDUINO && !USE_ESP32 diff --git a/esphome/components/http_request/http_request_arduino.h b/esphome/components/http_request/http_request_arduino.h index b736bb56d1f..d9b5af9d818 100644 --- a/esphome/components/http_request/http_request_arduino.h +++ b/esphome/components/http_request/http_request_arduino.h @@ -2,9 +2,9 @@ #include "http_request.h" -#ifdef USE_ARDUINO +#if defined(USE_ARDUINO) && !defined(USE_ESP32) -#if defined(USE_ESP32) || defined(USE_RP2040) +#if defined(USE_RP2040) #include #include #endif @@ -15,8 +15,7 @@ #endif #endif -namespace esphome { -namespace http_request { +namespace esphome::http_request { class HttpRequestArduino; class HttpContainerArduino : public HttpContainer { @@ -36,7 +35,6 @@ class HttpRequestArduino : public HttpRequestComponent { const std::set &collect_headers) override; }; -} // namespace http_request -} // namespace esphome +} // namespace esphome::http_request -#endif // USE_ARDUINO +#endif // USE_ARDUINO && !USE_ESP32 diff --git a/esphome/components/http_request/http_request_host.cpp b/esphome/components/http_request/http_request_host.cpp index 402affc1d18..b94570be124 100644 --- a/esphome/components/http_request/http_request_host.cpp +++ b/esphome/components/http_request/http_request_host.cpp @@ -12,8 +12,7 @@ #include "esphome/core/application.h" #include "esphome/core/log.h" -namespace esphome { -namespace http_request { +namespace esphome::http_request { static const char *const TAG = "http_request.host"; @@ -139,7 +138,6 @@ void HttpContainerHost::end() { this->bytes_read_ = 0; } -} // namespace http_request -} // namespace esphome +} // namespace esphome::http_request #endif // USE_HOST diff --git a/esphome/components/http_request/http_request_host.h b/esphome/components/http_request/http_request_host.h index 886ba949389..32e149e6a30 100644 --- a/esphome/components/http_request/http_request_host.h +++ b/esphome/components/http_request/http_request_host.h @@ -2,8 +2,8 @@ #ifdef USE_HOST #include "http_request.h" -namespace esphome { -namespace http_request { + +namespace esphome::http_request { class HttpRequestHost; class HttpContainerHost : public HttpContainer { @@ -27,7 +27,6 @@ class HttpRequestHost : public HttpRequestComponent { const char *ca_path_{}; }; -} // namespace http_request -} // namespace esphome +} // namespace esphome::http_request #endif // USE_HOST diff --git a/esphome/components/http_request/http_request_idf.cpp b/esphome/components/http_request/http_request_idf.cpp index 34a3fb87eb9..725a9c1c1ea 100644 --- a/esphome/components/http_request/http_request_idf.cpp +++ b/esphome/components/http_request/http_request_idf.cpp @@ -1,6 +1,6 @@ #include "http_request_idf.h" -#ifdef USE_ESP_IDF +#ifdef USE_ESP32 #include "esphome/components/network/util.h" #include "esphome/components/watchdog/watchdog.h" @@ -14,8 +14,7 @@ #include "esp_task_wdt.h" -namespace esphome { -namespace http_request { +namespace esphome::http_request { static const char *const TAG = "http_request.idf"; @@ -245,7 +244,6 @@ void HttpContainerIDF::feed_wdt() { } } -} // namespace http_request -} // namespace esphome +} // namespace esphome::http_request -#endif // USE_ESP_IDF +#endif // USE_ESP32 diff --git a/esphome/components/http_request/http_request_idf.h b/esphome/components/http_request/http_request_idf.h index e51b3aaebcd..4dc4736423f 100644 --- a/esphome/components/http_request/http_request_idf.h +++ b/esphome/components/http_request/http_request_idf.h @@ -2,15 +2,14 @@ #include "http_request.h" -#ifdef USE_ESP_IDF +#ifdef USE_ESP32 #include #include #include #include -namespace esphome { -namespace http_request { +namespace esphome::http_request { class HttpContainerIDF : public HttpContainer { public: @@ -48,7 +47,6 @@ class HttpRequestIDF : public HttpRequestComponent { static esp_err_t http_event_handler(esp_http_client_event_t *evt); }; -} // namespace http_request -} // namespace esphome +} // namespace esphome::http_request -#endif // USE_ESP_IDF +#endif // USE_ESP32 diff --git a/esphome/components/http_request/ota/ota_http_request.cpp b/esphome/components/http_request/ota/ota_http_request.cpp index 4552fcc9df2..058579752eb 100644 --- a/esphome/components/http_request/ota/ota_http_request.cpp +++ b/esphome/components/http_request/ota/ota_http_request.cpp @@ -16,12 +16,6 @@ namespace http_request { static const char *const TAG = "http_request.ota"; -void OtaHttpRequestComponent::setup() { -#ifdef USE_OTA_STATE_CALLBACK - ota::register_ota_platform(this); -#endif -} - void OtaHttpRequestComponent::dump_config() { ESP_LOGCONFIG(TAG, "Over-The-Air updates via HTTP request"); }; void OtaHttpRequestComponent::set_md5_url(const std::string &url) { @@ -48,24 +42,24 @@ void OtaHttpRequestComponent::flash() { } ESP_LOGI(TAG, "Starting update"); -#ifdef USE_OTA_STATE_CALLBACK - this->state_callback_.call(ota::OTA_STARTED, 0.0f, 0); +#ifdef USE_OTA_STATE_LISTENER + this->notify_state_(ota::OTA_STARTED, 0.0f, 0); #endif auto ota_status = this->do_ota_(); switch (ota_status) { case ota::OTA_RESPONSE_OK: -#ifdef USE_OTA_STATE_CALLBACK - this->state_callback_.call(ota::OTA_COMPLETED, 100.0f, ota_status); +#ifdef USE_OTA_STATE_LISTENER + this->notify_state_(ota::OTA_COMPLETED, 100.0f, ota_status); #endif delay(10); App.safe_reboot(); break; default: -#ifdef USE_OTA_STATE_CALLBACK - this->state_callback_.call(ota::OTA_ERROR, 0.0f, ota_status); +#ifdef USE_OTA_STATE_LISTENER + this->notify_state_(ota::OTA_ERROR, 0.0f, ota_status); #endif this->md5_computed_.clear(); // will be reset at next attempt this->md5_expected_.clear(); // will be reset at next attempt @@ -132,11 +126,18 @@ uint8_t OtaHttpRequestComponent::do_ota_() { App.feed_wdt(); yield(); - if (bufsize < 0) { - ESP_LOGE(TAG, "Stream closed"); - this->cleanup_(std::move(backend), container); - return OTA_CONNECTION_ERROR; - } else if (bufsize > 0 && bufsize <= OtaHttpRequestComponent::HTTP_RECV_BUFFER) { + // Exit loop if no data available (stream closed or end of data) + if (bufsize <= 0) { + if (bufsize < 0) { + ESP_LOGE(TAG, "Stream closed with error"); + this->cleanup_(std::move(backend), container); + return OTA_CONNECTION_ERROR; + } + // bufsize == 0: no more data available, exit loop + break; + } + + if (bufsize <= OtaHttpRequestComponent::HTTP_RECV_BUFFER) { // add read bytes to MD5 md5_receive.add(buf, bufsize); @@ -158,8 +159,8 @@ uint8_t OtaHttpRequestComponent::do_ota_() { last_progress = now; float percentage = container->get_bytes_read() * 100.0f / container->content_length; ESP_LOGD(TAG, "Progress: %0.1f%%", percentage); -#ifdef USE_OTA_STATE_CALLBACK - this->state_callback_.call(ota::OTA_IN_PROGRESS, percentage, 0); +#ifdef USE_OTA_STATE_LISTENER + this->notify_state_(ota::OTA_IN_PROGRESS, percentage, 0); #endif } } // while @@ -247,6 +248,9 @@ bool OtaHttpRequestComponent::http_get_md5_() { int read_len = 0; while (container->get_bytes_read() < MD5_SIZE) { read_len = container->read((uint8_t *) this->md5_expected_.data(), MD5_SIZE); + if (read_len <= 0) { + break; + } App.feed_wdt(); yield(); } diff --git a/esphome/components/http_request/ota/ota_http_request.h b/esphome/components/http_request/ota/ota_http_request.h index 6a86b4ab434..8735189e993 100644 --- a/esphome/components/http_request/ota/ota_http_request.h +++ b/esphome/components/http_request/ota/ota_http_request.h @@ -24,7 +24,6 @@ enum OtaHttpRequestError : uint8_t { class OtaHttpRequestComponent : public ota::OTAComponent, public Parented { public: - void setup() override; void dump_config() override; float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } diff --git a/esphome/components/http_request/update/__init__.py b/esphome/components/http_request/update/__init__.py index abb4b2a4300..d84d80109aa 100644 --- a/esphome/components/http_request/update/__init__.py +++ b/esphome/components/http_request/update/__init__.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import update +from esphome.components import ota, update import esphome.config_validation as cv from esphome.const import CONF_SOURCE @@ -38,6 +38,6 @@ async def to_code(config): cg.add(var.set_source_url(config[CONF_SOURCE])) - cg.add_define("USE_OTA_STATE_CALLBACK") + ota.request_ota_state_listeners() await cg.register_component(var, config) diff --git a/esphome/components/http_request/update/http_request_update.cpp b/esphome/components/http_request/update/http_request_update.cpp index 26af754e69f..a9392ad7367 100644 --- a/esphome/components/http_request/update/http_request_update.cpp +++ b/esphome/components/http_request/update/http_request_update.cpp @@ -20,19 +20,19 @@ static const char *const TAG = "http_request.update"; static const size_t MAX_READ_SIZE = 256; -void HttpRequestUpdate::setup() { - this->ota_parent_->add_on_state_callback([this](ota::OTAState state, float progress, uint8_t err) { - if (state == ota::OTAState::OTA_IN_PROGRESS) { - this->state_ = update::UPDATE_STATE_INSTALLING; - this->update_info_.has_progress = true; - this->update_info_.progress = progress; - this->publish_state(); - } else if (state == ota::OTAState::OTA_ABORT || state == ota::OTAState::OTA_ERROR) { - this->state_ = update::UPDATE_STATE_AVAILABLE; - this->status_set_error(LOG_STR("Failed to install firmware")); - this->publish_state(); - } - }); +void HttpRequestUpdate::setup() { this->ota_parent_->add_state_listener(this); } + +void HttpRequestUpdate::on_ota_state(ota::OTAState state, float progress, uint8_t error) { + if (state == ota::OTAState::OTA_IN_PROGRESS) { + this->state_ = update::UPDATE_STATE_INSTALLING; + this->update_info_.has_progress = true; + this->update_info_.progress = progress; + this->publish_state(); + } else if (state == ota::OTAState::OTA_ABORT || state == ota::OTAState::OTA_ERROR) { + this->state_ = update::UPDATE_STATE_AVAILABLE; + this->status_set_error(LOG_STR("Failed to install firmware")); + this->publish_state(); + } } void HttpRequestUpdate::update() { @@ -76,6 +76,11 @@ void HttpRequestUpdate::update_task(void *params) { yield(); + if (read_bytes <= 0) { + // Network error or connection closed - break to avoid infinite loop + break; + } + read_index += read_bytes; } diff --git a/esphome/components/http_request/update/http_request_update.h b/esphome/components/http_request/update/http_request_update.h index e05fdb0cc2a..cf34ace18eb 100644 --- a/esphome/components/http_request/update/http_request_update.h +++ b/esphome/components/http_request/update/http_request_update.h @@ -14,7 +14,7 @@ namespace esphome { namespace http_request { -class HttpRequestUpdate : public update::UpdateEntity, public PollingComponent { +class HttpRequestUpdate final : public update::UpdateEntity, public PollingComponent, public ota::OTAStateListener { public: void setup() override; void update() override; @@ -29,6 +29,8 @@ class HttpRequestUpdate : public update::UpdateEntity, public PollingComponent { float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } + void on_ota_state(ota::OTAState state, float progress, uint8_t error) override; + protected: HttpRequestComponent *request_parent_; OtaHttpRequestComponent *ota_parent_; diff --git a/esphome/components/hub75/display.py b/esphome/components/hub75/display.py index 81dd4ffc1cf..7736319330a 100644 --- a/esphome/components/hub75/display.py +++ b/esphome/components/hub75/display.py @@ -1,6 +1,6 @@ from typing import Any -from esphome import pins +from esphome import automation, pins import esphome.codegen as cg from esphome.components import display from esphome.components.esp32 import add_idf_component @@ -17,6 +17,8 @@ from esphome.const import ( CONF_OE_PIN, CONF_UPDATE_INTERVAL, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType import esphome.final_validate as fv from esphome.types import ConfigType @@ -93,35 +95,35 @@ CONF_DOUBLE_BUFFER = "double_buffer" CONF_MIN_REFRESH_RATE = "min_refresh_rate" # Map to hub75 library enums (in global namespace) -ShiftDriver = cg.global_ns.enum("ShiftDriver", is_class=True) +Hub75ShiftDriver = cg.global_ns.enum("Hub75ShiftDriver", is_class=True) SHIFT_DRIVERS = { - "GENERIC": ShiftDriver.GENERIC, - "FM6126A": ShiftDriver.FM6126A, - "ICN2038S": ShiftDriver.ICN2038S, - "FM6124": ShiftDriver.FM6124, - "MBI5124": ShiftDriver.MBI5124, - "DP3246": ShiftDriver.DP3246, + "GENERIC": Hub75ShiftDriver.GENERIC, + "FM6126A": Hub75ShiftDriver.FM6126A, + "ICN2038S": Hub75ShiftDriver.ICN2038S, + "FM6124": Hub75ShiftDriver.FM6124, + "MBI5124": Hub75ShiftDriver.MBI5124, + "DP3246": Hub75ShiftDriver.DP3246, } -PanelLayout = cg.global_ns.enum("PanelLayout", is_class=True) +Hub75PanelLayout = cg.global_ns.enum("Hub75PanelLayout", is_class=True) PANEL_LAYOUTS = { - "HORIZONTAL": PanelLayout.HORIZONTAL, - "TOP_LEFT_DOWN": PanelLayout.TOP_LEFT_DOWN, - "TOP_RIGHT_DOWN": PanelLayout.TOP_RIGHT_DOWN, - "BOTTOM_LEFT_UP": PanelLayout.BOTTOM_LEFT_UP, - "BOTTOM_RIGHT_UP": PanelLayout.BOTTOM_RIGHT_UP, - "TOP_LEFT_DOWN_ZIGZAG": PanelLayout.TOP_LEFT_DOWN_ZIGZAG, - "TOP_RIGHT_DOWN_ZIGZAG": PanelLayout.TOP_RIGHT_DOWN_ZIGZAG, - "BOTTOM_LEFT_UP_ZIGZAG": PanelLayout.BOTTOM_LEFT_UP_ZIGZAG, - "BOTTOM_RIGHT_UP_ZIGZAG": PanelLayout.BOTTOM_RIGHT_UP_ZIGZAG, + "HORIZONTAL": Hub75PanelLayout.HORIZONTAL, + "TOP_LEFT_DOWN": Hub75PanelLayout.TOP_LEFT_DOWN, + "TOP_RIGHT_DOWN": Hub75PanelLayout.TOP_RIGHT_DOWN, + "BOTTOM_LEFT_UP": Hub75PanelLayout.BOTTOM_LEFT_UP, + "BOTTOM_RIGHT_UP": Hub75PanelLayout.BOTTOM_RIGHT_UP, + "TOP_LEFT_DOWN_ZIGZAG": Hub75PanelLayout.TOP_LEFT_DOWN_ZIGZAG, + "TOP_RIGHT_DOWN_ZIGZAG": Hub75PanelLayout.TOP_RIGHT_DOWN_ZIGZAG, + "BOTTOM_LEFT_UP_ZIGZAG": Hub75PanelLayout.BOTTOM_LEFT_UP_ZIGZAG, + "BOTTOM_RIGHT_UP_ZIGZAG": Hub75PanelLayout.BOTTOM_RIGHT_UP_ZIGZAG, } -ScanPattern = cg.global_ns.enum("ScanPattern", is_class=True) +Hub75ScanWiring = cg.global_ns.enum("Hub75ScanWiring", is_class=True) SCAN_PATTERNS = { - "STANDARD_TWO_SCAN": ScanPattern.STANDARD_TWO_SCAN, - "FOUR_SCAN_16PX_HIGH": ScanPattern.FOUR_SCAN_16PX_HIGH, - "FOUR_SCAN_32PX_HIGH": ScanPattern.FOUR_SCAN_32PX_HIGH, - "FOUR_SCAN_64PX_HIGH": ScanPattern.FOUR_SCAN_64PX_HIGH, + "STANDARD_TWO_SCAN": Hub75ScanWiring.STANDARD_TWO_SCAN, + "FOUR_SCAN_16PX_HIGH": Hub75ScanWiring.FOUR_SCAN_16PX_HIGH, + "FOUR_SCAN_32PX_HIGH": Hub75ScanWiring.FOUR_SCAN_32PX_HIGH, + "FOUR_SCAN_64PX_HIGH": Hub75ScanWiring.FOUR_SCAN_64PX_HIGH, } Hub75ClockSpeed = cg.global_ns.enum("Hub75ClockSpeed", is_class=True) @@ -135,6 +137,7 @@ CLOCK_SPEEDS = { HUB75Display = hub75_ns.class_("HUB75Display", cg.PollingComponent, display.Display) Hub75Config = cg.global_ns.struct("Hub75Config") Hub75Pins = cg.global_ns.struct("Hub75Pins") +SetBrightnessAction = hub75_ns.class_("SetBrightnessAction", automation.Action) def _merge_board_pins(config: ConfigType) -> ConfigType: @@ -528,7 +531,7 @@ def _build_config_struct( async def to_code(config: ConfigType) -> None: add_idf_component( name="esphome/esp-hub75", - ref="0.1.6", + ref="0.1.7", ) # Set compile-time configuration via defines @@ -576,3 +579,27 @@ async def to_code(config: ConfigType) -> None: config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void ) cg.add(var.set_writer(lambda_)) + + +@automation.register_action( + "hub75.set_brightness", + SetBrightnessAction, + cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(HUB75Display), + cv.Required(CONF_BRIGHTNESS): cv.templatable(cv.int_range(min=0, max=255)), + }, + key=CONF_BRIGHTNESS, + ), +) +async def hub75_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) + cg.add(var.set_brightness(template_)) + return var diff --git a/esphome/components/hub75/hub75.cpp b/esphome/components/hub75/hub75.cpp index e023e446c42..73171748317 100644 --- a/esphome/components/hub75/hub75.cpp +++ b/esphome/components/hub75/hub75.cpp @@ -179,7 +179,7 @@ void HOT HUB75Display::draw_pixels_at(int x_start, int y_start, int w, int h, co } } -void HUB75Display::set_brightness(int brightness) { +void HUB75Display::set_brightness(uint8_t brightness) { this->brightness_ = brightness; this->enabled_ = (brightness > 0); if (this->driver_ != nullptr) { diff --git a/esphome/components/hub75/hub75_component.h b/esphome/components/hub75/hub75_component.h index 49d42744836..f0e7ea10d56 100644 --- a/esphome/components/hub75/hub75_component.h +++ b/esphome/components/hub75/hub75_component.h @@ -5,6 +5,7 @@ #include #include "esphome/components/display/display_buffer.h" +#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" @@ -34,7 +35,7 @@ class HUB75Display : public display::Display { display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; // Brightness control (runtime mutable) - void set_brightness(int brightness); + void set_brightness(uint8_t brightness); protected: // Display internal methods @@ -46,10 +47,17 @@ class HUB75Display : public display::Display { Hub75Config config_; // Immutable configuration // Runtime state (mutable) - int brightness_{128}; + uint8_t brightness_{128}; bool enabled_{false}; }; +template class SetBrightnessAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(uint8_t, brightness) + + void play(const Ts &...x) override { this->parent_->set_brightness(this->brightness_.value(x...)); } +}; + } // namespace esphome::hub75 #endif diff --git a/esphome/components/i2c/__init__.py b/esphome/components/i2c/__init__.py index 9e7c9d702cc..7706484e977 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -121,7 +121,7 @@ CONFIG_SCHEMA = cv.All( nrf52="100kHz", ): cv.All( cv.frequency, - cv.Range(min=0, min_included=False), + cv.float_range(min=0, min_included=False), ), cv.Optional(CONF_TIMEOUT): cv.All( cv.only_with_framework(["arduino", "esp-idf"]), diff --git a/esphome/components/ledc/output.py b/esphome/components/ledc/output.py index 2133c4daf98..7a45b9dc3f6 100644 --- a/esphome/components/ledc/output.py +++ b/esphome/components/ledc/output.py @@ -45,10 +45,12 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( { cv.Required(CONF_ID): cv.declare_id(LEDCOutput), cv.Required(CONF_PIN): pins.internal_gpio_output_pin_schema, - cv.Optional(CONF_FREQUENCY, default="1kHz"): cv.frequency, + cv.Optional(CONF_FREQUENCY, default="1kHz"): cv.All( + cv.frequency, cv.float_range(min=0, min_included=False) + ), cv.Optional(CONF_CHANNEL): cv.int_range(min=0, max=15), cv.Optional(CONF_PHASE_ANGLE): cv.All( - cv.only_with_esp_idf, cv.angle, cv.float_range(min=0.0, max=360.0) + cv.angle, cv.float_range(min=0.0, max=360.0) ), } ).extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/libretiny/preferences.cpp b/esphome/components/libretiny/preferences.cpp index 871b186d8eb..e47e88c6f36 100644 --- a/esphome/components/libretiny/preferences.cpp +++ b/esphome/components/libretiny/preferences.cpp @@ -4,24 +4,27 @@ #include "esphome/core/log.h" #include "esphome/core/preferences.h" #include +#include #include #include -#include namespace esphome { namespace libretiny { static const char *const TAG = "lt.preferences"; +// Buffer size for converting uint32_t to string: max "4294967295" (10 chars) + null terminator + 1 padding +static constexpr size_t KEY_BUFFER_SIZE = 12; + struct NVSData { - std::string key; + uint32_t key; std::unique_ptr data; size_t len; void set_data(const uint8_t *src, size_t size) { - data = std::make_unique(size); - memcpy(data.get(), src, size); - len = size; + this->data = std::make_unique(size); + memcpy(this->data.get(), src, size); + this->len = size; } }; @@ -29,30 +32,30 @@ static std::vector s_pending_save; // NOLINT(cppcoreguidelines-avoid-n class LibreTinyPreferenceBackend : public ESPPreferenceBackend { public: - std::string key; + uint32_t key; fdb_kvdb_t db; fdb_blob_t blob; bool save(const uint8_t *data, size_t len) override { // try find in pending saves and update that for (auto &obj : s_pending_save) { - if (obj.key == key) { + if (obj.key == this->key) { obj.set_data(data, len); return true; } } NVSData save{}; - save.key = key; + save.key = this->key; save.set_data(data, len); s_pending_save.emplace_back(std::move(save)); - ESP_LOGVV(TAG, "s_pending_save: key: %s, len: %zu", key.c_str(), len); + ESP_LOGVV(TAG, "s_pending_save: key: %" PRIu32 ", len: %zu", this->key, len); return true; } bool load(uint8_t *data, size_t len) override { // try find in pending saves and load from that for (auto &obj : s_pending_save) { - if (obj.key == key) { + if (obj.key == this->key) { if (obj.len != len) { // size mismatch return false; @@ -62,13 +65,15 @@ class LibreTinyPreferenceBackend : public ESPPreferenceBackend { } } - fdb_blob_make(blob, data, len); - size_t actual_len = fdb_kv_get_blob(db, key.c_str(), blob); + char key_str[KEY_BUFFER_SIZE]; + snprintf(key_str, sizeof(key_str), "%" PRIu32, this->key); + fdb_blob_make(this->blob, data, len); + size_t actual_len = fdb_kv_get_blob(this->db, key_str, this->blob); if (actual_len != len) { ESP_LOGVV(TAG, "NVS length does not match (%zu!=%zu)", actual_len, len); return false; } else { - ESP_LOGVV(TAG, "fdb_kv_get_blob: key: %s, len: %zu", key.c_str(), len); + ESP_LOGVV(TAG, "fdb_kv_get_blob: key: %s, len: %zu", key_str, len); } return true; } @@ -90,16 +95,14 @@ class LibreTinyPreferences : public ESPPreferences { } ESPPreferenceObject make_preference(size_t length, uint32_t type, bool in_flash) override { - return make_preference(length, type); + return this->make_preference(length, type); } ESPPreferenceObject make_preference(size_t length, uint32_t type) override { auto *pref = new LibreTinyPreferenceBackend(); // NOLINT(cppcoreguidelines-owning-memory) - pref->db = &db; - pref->blob = &blob; - - uint32_t keyval = type; - pref->key = str_sprintf("%u", keyval); + pref->db = &this->db; + pref->blob = &this->blob; + pref->key = type; return ESPPreferenceObject(pref); } @@ -112,18 +115,20 @@ class LibreTinyPreferences : public ESPPreferences { // goal try write all pending saves even if one fails int cached = 0, written = 0, failed = 0; fdb_err_t last_err = FDB_NO_ERR; - std::string last_key{}; + uint32_t last_key = 0; // go through vector from back to front (makes erase easier/more efficient) for (ssize_t i = s_pending_save.size() - 1; i >= 0; i--) { const auto &save = s_pending_save[i]; - ESP_LOGVV(TAG, "Checking if FDB data %s has changed", save.key.c_str()); - if (is_changed(&db, save)) { - ESP_LOGV(TAG, "sync: key: %s, len: %zu", save.key.c_str(), save.len); - fdb_blob_make(&blob, save.data.get(), save.len); - fdb_err_t err = fdb_kv_set_blob(&db, save.key.c_str(), &blob); + char key_str[KEY_BUFFER_SIZE]; + snprintf(key_str, sizeof(key_str), "%" PRIu32, save.key); + ESP_LOGVV(TAG, "Checking if FDB data %s has changed", key_str); + if (this->is_changed_(&this->db, save, key_str)) { + ESP_LOGV(TAG, "sync: key: %s, len: %zu", key_str, save.len); + fdb_blob_make(&this->blob, save.data.get(), save.len); + fdb_err_t err = fdb_kv_set_blob(&this->db, key_str, &this->blob); if (err != FDB_NO_ERR) { - ESP_LOGV(TAG, "fdb_kv_set_blob('%s', len=%zu) failed: %d", save.key.c_str(), save.len, err); + ESP_LOGV(TAG, "fdb_kv_set_blob('%s', len=%zu) failed: %d", key_str, save.len, err); failed++; last_err = err; last_key = save.key; @@ -131,7 +136,7 @@ class LibreTinyPreferences : public ESPPreferences { } written++; } else { - ESP_LOGD(TAG, "FDB data not changed; skipping %s len=%zu", save.key.c_str(), save.len); + ESP_LOGD(TAG, "FDB data not changed; skipping %" PRIu32 " len=%zu", save.key, save.len); cached++; } s_pending_save.erase(s_pending_save.begin() + i); @@ -139,17 +144,18 @@ class LibreTinyPreferences : public ESPPreferences { ESP_LOGD(TAG, "Writing %d items: %d cached, %d written, %d failed", cached + written + failed, cached, written, failed); if (failed > 0) { - ESP_LOGE(TAG, "Writing %d items failed. Last error=%d for key=%s", failed, last_err, last_key.c_str()); + ESP_LOGE(TAG, "Writing %d items failed. Last error=%d for key=%" PRIu32, failed, last_err, last_key); } return failed == 0; } - bool is_changed(const fdb_kvdb_t db, const NVSData &to_save) { + protected: + bool is_changed_(fdb_kvdb_t db, const NVSData &to_save, const char *key_str) { struct fdb_kv kv; - fdb_kv_t kvp = fdb_kv_get_obj(db, to_save.key.c_str(), &kv); + fdb_kv_t kvp = fdb_kv_get_obj(db, key_str, &kv); if (kvp == nullptr) { - ESP_LOGV(TAG, "fdb_kv_get_obj('%s'): nullptr - the key might not be set yet", to_save.key.c_str()); + ESP_LOGV(TAG, "fdb_kv_get_obj('%s'): nullptr - the key might not be set yet", key_str); return true; } @@ -160,10 +166,10 @@ class LibreTinyPreferences : public ESPPreferences { // Allocate buffer on heap to avoid stack allocation for large data auto stored_data = std::make_unique(kv.value_len); - fdb_blob_make(&blob, stored_data.get(), kv.value_len); - size_t actual_len = fdb_kv_get_blob(db, to_save.key.c_str(), &blob); + fdb_blob_make(&this->blob, stored_data.get(), kv.value_len); + size_t actual_len = fdb_kv_get_blob(db, key_str, &this->blob); if (actual_len != kv.value_len) { - ESP_LOGV(TAG, "fdb_kv_get_blob('%s') len mismatch: %u != %u", to_save.key.c_str(), actual_len, kv.value_len); + ESP_LOGV(TAG, "fdb_kv_get_blob('%s') len mismatch: %u != %u", key_str, actual_len, kv.value_len); return true; } diff --git a/esphome/components/libretiny_pwm/output.py b/esphome/components/libretiny_pwm/output.py index 1eb4869da3e..28556514d89 100644 --- a/esphome/components/libretiny_pwm/output.py +++ b/esphome/components/libretiny_pwm/output.py @@ -14,7 +14,9 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( { cv.Required(CONF_ID): cv.declare_id(LibreTinyPWM), cv.Required(CONF_PIN): pins.internal_gpio_output_pin_schema, - cv.Optional(CONF_FREQUENCY, default="1kHz"): cv.frequency, + cv.Optional(CONF_FREQUENCY, default="1kHz"): cv.All( + cv.frequency, cv.float_range(min=0, min_included=False) + ), } ).extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/lock/lock.h b/esphome/components/lock/lock.h index 4001a182b8b..f77b11b145b 100644 --- a/esphome/components/lock/lock.h +++ b/esphome/components/lock/lock.h @@ -174,7 +174,7 @@ class Lock : public EntityBase { */ virtual void control(const LockCall &call) = 0; - CallbackManager state_callback_{}; + LazyCallbackManager state_callback_{}; Deduplicator publish_dedup_; ESPPreferenceObject rtc_; }; diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index fb0ce92cc9a..8968a5eab81 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -241,9 +241,12 @@ CONFIG_SCHEMA = cv.All( CONF_HARDWARE_UART, esp8266=UART0, esp32=UART0, + esp32_c2=UART0, esp32_c3=USB_SERIAL_JTAG, esp32_c5=USB_SERIAL_JTAG, esp32_c6=USB_SERIAL_JTAG, + esp32_c61=USB_SERIAL_JTAG, + esp32_h2=USB_SERIAL_JTAG, esp32_p4=USB_SERIAL_JTAG, esp32_s2=USB_CDC, esp32_s3=USB_SERIAL_JTAG, diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index 8abc1196e18..36195b919a7 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -118,11 +118,11 @@ static constexpr uint16_t MAX_HEADER_SIZE = 128; static constexpr size_t MAX_POINTER_REPRESENTATION = 2 + sizeof(void *) * 2 + 1; // Platform-specific: does write_msg_ add its own newline? -// false: Caller must add newline to buffer before calling write_msg_ (ESP32, ESP8266, LibreTiny) +// false: Caller must add newline to buffer before calling write_msg_ (ESP32, ESP8266, RP2040, LibreTiny) // Allows single write call with newline included for efficiency // true: write_msg_ adds newline itself via puts()/println() (other platforms) // Newline should NOT be added to buffer -#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_LIBRETINY) +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) static constexpr bool WRITE_MSG_ADDS_NEWLINE = false; #else static constexpr bool WRITE_MSG_ADDS_NEWLINE = true; diff --git a/esphome/components/logger/logger_rp2040.cpp b/esphome/components/logger/logger_rp2040.cpp index 4a8535c8e40..be8252f56ab 100644 --- a/esphome/components/logger/logger_rp2040.cpp +++ b/esphome/components/logger/logger_rp2040.cpp @@ -27,7 +27,10 @@ void Logger::pre_setup() { ESP_LOGI(TAG, "Log initialized"); } -void HOT Logger::write_msg_(const char *msg, size_t) { this->hw_serial_->println(msg); } +void HOT Logger::write_msg_(const char *msg, size_t len) { + // Single write with newline already in buffer (added by caller) + this->hw_serial_->write(msg, len); +} const LogString *Logger::get_uart_selection_() { switch (this->uart_) { diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 19c258fcd53..c9cad1ac905 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -256,9 +256,11 @@ async def to_code(configs): True, type=lv_font_t.operator("ptr").operator("const"), ) + # static=False because LV_FONT_CUSTOM_DECLARE creates an extern declaration cg.new_variable( globfont_id, MockObj(await lvalid.lv_font.process(default_font), "->").get_lv_font(), + static=False, ) add_define("LV_FONT_DEFAULT", df.DEFAULT_ESPHOME_FONT) else: diff --git a/esphome/components/lvgl/lvcode.py b/esphome/components/lvgl/lvcode.py index c11597131f1..e2c70642a80 100644 --- a/esphome/components/lvgl/lvcode.py +++ b/esphome/components/lvgl/lvcode.py @@ -337,7 +337,7 @@ def lv_Pvariable(type, name) -> MockObj: """ if isinstance(name, str): name = ID(name, True, type) - decl = VariableDeclarationExpression(type, "*", name) + decl = VariableDeclarationExpression(type, "*", name, static=True) CORE.add_global(decl) var = MockObj(name, "->") CORE.register_variable(name, var) @@ -353,7 +353,7 @@ def lv_variable(type, name) -> MockObj: """ if isinstance(name, str): name = ID(name, True, type) - decl = VariableDeclarationExpression(type, "", name) + decl = VariableDeclarationExpression(type, "", name, static=True) CORE.add_global(decl) var = MockObj(name, ".") CORE.register_variable(name, var) diff --git a/esphome/components/mapping/__init__.py b/esphome/components/mapping/__init__.py index 94c7c10a82a..a36b414fd51 100644 --- a/esphome/components/mapping/__init__.py +++ b/esphome/components/mapping/__init__.py @@ -133,7 +133,7 @@ async def to_code(config): value_type, ) var = MockObj(varid, ".") - decl = VariableDeclarationExpression(varid.type, "", varid) + decl = VariableDeclarationExpression(varid.type, "", varid, static=True) add_global(decl) CORE.register_variable(varid, var) diff --git a/esphome/components/media_player/media_player.h b/esphome/components/media_player/media_player.h index 2f1c99115f9..b753e2d0880 100644 --- a/esphome/components/media_player/media_player.h +++ b/esphome/components/media_player/media_player.h @@ -157,7 +157,7 @@ class MediaPlayer : public EntityBase { virtual void control(const MediaPlayerCall &call) = 0; - CallbackManager state_callback_{}; + LazyCallbackManager state_callback_{}; }; } // namespace media_player diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index 575fb97799b..0d478f749b1 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, socket +from esphome.components import esp32, microphone, ota, socket import esphome.config_validation as cv from esphome.const import ( CONF_FILE, @@ -452,7 +452,7 @@ async def to_code(config): cg.add(var.set_microphone_source(mic_source)) cg.add_define("USE_MICRO_WAKE_WORD") - cg.add_define("USE_OTA_STATE_CALLBACK") + ota.request_ota_state_listeners() esp32.add_idf_component(name="espressif/esp-tflite-micro", ref="1.3.3~1") diff --git a/esphome/components/micro_wake_word/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index ec8fa34da4a..b8377ead38b 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -119,18 +119,21 @@ void MicroWakeWord::setup() { } }); -#ifdef USE_OTA - ota::get_global_ota_callback()->add_on_state_callback( - [this](ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) { - if (state == ota::OTA_STARTED) { - this->suspend_task_(); - } else if (state == ota::OTA_ERROR) { - this->resume_task_(); - } - }); +#ifdef USE_OTA_STATE_LISTENER + ota::get_global_ota_callback()->add_global_state_listener(this); #endif } +#ifdef USE_OTA_STATE_LISTENER +void MicroWakeWord::on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) { + if (state == ota::OTA_STARTED) { + this->suspend_task_(); + } else if (state == ota::OTA_ERROR) { + this->resume_task_(); + } +} +#endif + void MicroWakeWord::inference_task(void *params) { MicroWakeWord *this_mww = (MicroWakeWord *) params; diff --git a/esphome/components/micro_wake_word/micro_wake_word.h b/esphome/components/micro_wake_word/micro_wake_word.h index d46c40e48be..84261eaa5b8 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.h +++ b/esphome/components/micro_wake_word/micro_wake_word.h @@ -9,8 +9,13 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" +#include "esphome/core/defines.h" #include "esphome/core/ring_buffer.h" +#ifdef USE_OTA_STATE_LISTENER +#include "esphome/components/ota/ota_backend.h" +#endif + #include #include @@ -26,13 +31,22 @@ enum State { STOPPED, }; -class MicroWakeWord : public Component { +class MicroWakeWord : public Component +#ifdef USE_OTA_STATE_LISTENER + , + public ota::OTAGlobalStateListener +#endif +{ public: void setup() override; void loop() override; float get_setup_priority() const override; void dump_config() override; +#ifdef USE_OTA_STATE_LISTENER + void on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) override; +#endif + void start(); void stop(); diff --git a/esphome/components/microphone/__init__.py b/esphome/components/microphone/__init__.py index 1fc0df88a39..ce314844134 100644 --- a/esphome/components/microphone/__init__.py +++ b/esphome/components/microphone/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( CONF_GAIN_FACTOR, CONF_ID, CONF_MICROPHONE, + CONF_ON_DATA, CONF_TRIGGER_ID, ) from esphome.core import CORE @@ -19,8 +20,6 @@ CODEOWNERS = ["@jesserockz", "@kahrendt"] IS_PLATFORM_COMPONENT = True -CONF_ON_DATA = "on_data" - microphone_ns = cg.esphome_ns.namespace("microphone") Microphone = microphone_ns.class_("Microphone") diff --git a/esphome/components/mmc5603/mmc5603.cpp b/esphome/components/mmc5603/mmc5603.cpp index f0d1044f3f1..d6321eae8ff 100644 --- a/esphome/components/mmc5603/mmc5603.cpp +++ b/esphome/components/mmc5603/mmc5603.cpp @@ -83,6 +83,7 @@ void MMC5603Component::dump_config() { ESP_LOGE(TAG, "The ID registers don't match - Is this really an MMC5603?"); } LOG_UPDATE_INTERVAL(this); + ESP_LOGCONFIG(TAG, " Auto set/reset: %s", ONOFF(this->auto_set_reset_)); LOG_SENSOR(" ", "X Axis", this->x_sensor_); LOG_SENSOR(" ", "Y Axis", this->y_sensor_); @@ -93,7 +94,8 @@ void MMC5603Component::dump_config() { float MMC5603Component::get_setup_priority() const { return setup_priority::DATA; } void MMC5603Component::update() { - if (!this->write_byte(MMC56X3_CTRL0_REG, 0x01)) { + uint8_t ctrl0 = (this->auto_set_reset_) ? 0x21 : 0x01; + if (!this->write_byte(MMC56X3_CTRL0_REG, ctrl0)) { this->status_set_warning(); return; } diff --git a/esphome/components/mmc5603/mmc5603.h b/esphome/components/mmc5603/mmc5603.h index cd0893053c4..09718bd3b78 100644 --- a/esphome/components/mmc5603/mmc5603.h +++ b/esphome/components/mmc5603/mmc5603.h @@ -25,6 +25,7 @@ class MMC5603Component : public PollingComponent, public i2c::I2CDevice { void set_y_sensor(sensor::Sensor *y_sensor) { y_sensor_ = y_sensor; } void set_z_sensor(sensor::Sensor *z_sensor) { z_sensor_ = z_sensor; } void set_heading_sensor(sensor::Sensor *heading_sensor) { heading_sensor_ = heading_sensor; } + void set_auto_set_reset(bool auto_set_reset) { auto_set_reset_ = auto_set_reset; } protected: MMC5603Datarate datarate_; @@ -32,6 +33,7 @@ class MMC5603Component : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *y_sensor_{nullptr}; sensor::Sensor *z_sensor_{nullptr}; sensor::Sensor *heading_sensor_{nullptr}; + bool auto_set_reset_{true}; enum ErrorCode { NONE = 0, COMMUNICATION_FAILED, diff --git a/esphome/components/mmc5603/sensor.py b/esphome/components/mmc5603/sensor.py index 3223225271a..5b3982cee68 100644 --- a/esphome/components/mmc5603/sensor.py +++ b/esphome/components/mmc5603/sensor.py @@ -16,6 +16,8 @@ from esphome.const import ( UNIT_MICROTESLA, ) +CONF_AUTO_SET_RESET = "auto_set_reset" + DEPENDENCIES = ["i2c"] mmc5603_ns = cg.esphome_ns.namespace("mmc5603") @@ -54,6 +56,7 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_FIELD_STRENGTH_Y): field_strength_schema, cv.Optional(CONF_FIELD_STRENGTH_Z): field_strength_schema, cv.Optional(CONF_HEADING): heading_schema, + cv.Optional(CONF_AUTO_SET_RESET, default=True): cv.boolean, } ) .extend(cv.polling_component_schema("60s")) @@ -88,3 +91,5 @@ async def to_code(config): if CONF_HEADING in config: sens = await sensor.new_sensor(config[CONF_HEADING]) cg.add(var.set_heading_sensor(sens)) + if CONF_AUTO_SET_RESET in config: + cg.add(var.set_auto_set_reset(config[CONF_AUTO_SET_RESET])) diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 237ed2ce385..e73de49fef5 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -233,11 +233,11 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_PASSWORD, default=""): cv.string, cv.Optional(CONF_CLEAN_SESSION, default=False): cv.boolean, cv.Optional(CONF_CLIENT_ID): cv.string, - cv.SplitDefault(CONF_IDF_SEND_ASYNC, esp32_idf=False): cv.All( - cv.boolean, cv.only_with_esp_idf + cv.SplitDefault(CONF_IDF_SEND_ASYNC, esp32=False): cv.All( + cv.boolean, cv.only_on_esp32 ), cv.Optional(CONF_CERTIFICATE_AUTHORITY): cv.All( - cv.string, cv.only_with_esp_idf + cv.string, cv.only_on_esp32 ), cv.Inclusive(CONF_CLIENT_CERTIFICATE, "cert-key-pair"): cv.All( cv.string, cv.only_on_esp32 @@ -245,8 +245,8 @@ CONFIG_SCHEMA = cv.All( cv.Inclusive(CONF_CLIENT_CERTIFICATE_KEY, "cert-key-pair"): cv.All( cv.string, cv.only_on_esp32 ), - cv.SplitDefault(CONF_SKIP_CERT_CN_CHECK, esp32_idf=False): cv.All( - cv.boolean, cv.only_with_esp_idf + cv.SplitDefault(CONF_SKIP_CERT_CN_CHECK, esp32=False): cv.All( + cv.boolean, cv.only_on_esp32 ), cv.Optional(CONF_DISCOVERY, default=True): cv.Any( cv.boolean, cv.one_of("CLEAN", upper=True) diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index 5d2bedae790..9db1b1f7c89 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -154,7 +154,15 @@ bool MQTTComponent::send_discovery_() { device_info[MQTT_DEVICE_MANUFACTURER] = model == nullptr ? ESPHOME_PROJECT_NAME : std::string(ESPHOME_PROJECT_NAME, model - ESPHOME_PROJECT_NAME); #else - device_info[MQTT_DEVICE_SW_VERSION] = ESPHOME_VERSION " (" + App.get_compilation_time_ref() + ")"; + static const char ver_fmt[] PROGMEM = ESPHOME_VERSION " (config hash 0x%08" PRIx32 ")"; +#ifdef USE_ESP8266 + char fmt_buf[sizeof(ver_fmt)]; + strcpy_P(fmt_buf, ver_fmt); + const char *fmt = fmt_buf; +#else + const char *fmt = ver_fmt; +#endif + device_info[MQTT_DEVICE_SW_VERSION] = str_sprintf(fmt, App.get_config_hash()); device_info[MQTT_DEVICE_MODEL] = ESPHOME_BOARD; #if defined(USE_ESP8266) || defined(USE_ESP32) device_info[MQTT_DEVICE_MANUFACTURER] = "Espressif"; diff --git a/esphome/components/mqtt/mqtt_select.cpp b/esphome/components/mqtt/mqtt_select.cpp index e1660b07eac..e48af980c8f 100644 --- a/esphome/components/mqtt/mqtt_select.cpp +++ b/esphome/components/mqtt/mqtt_select.cpp @@ -21,8 +21,7 @@ void MQTTSelectComponent::setup() { call.set_option(state); call.perform(); }); - this->select_->add_on_state_callback( - [this](const std::string &state, size_t index) { this->publish_state(this->select_->option_at(index)); }); + this->select_->add_on_state_callback([this](size_t index) { this->publish_state(this->select_->option_at(index)); }); } void MQTTSelectComponent::dump_config() { diff --git a/esphome/components/nextion/__init__.py b/esphome/components/nextion/__init__.py index 8adc49d68c1..38f449dc038 100644 --- a/esphome/components/nextion/__init__.py +++ b/esphome/components/nextion/__init__.py @@ -13,14 +13,16 @@ CONF_SEND_TO_NEXTION = "send_to_nextion" FILTER_SOURCE_FILES = filter_source_files_from_platform( { - "nextion_upload_arduino.cpp": { + "nextion_upload_esp32.cpp": { PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP32_IDF, + }, + "nextion_upload_arduino.cpp": { PlatformFramework.ESP8266_ARDUINO, PlatformFramework.RP2040_ARDUINO, PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, }, - "nextion_upload_idf.cpp": {PlatformFramework.ESP32_IDF}, } ) diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index ed6cd930277..b95df55a61a 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -154,14 +154,11 @@ async def to_code(config): cg.add_define("USE_NEXTION_TFT_UPLOAD") cg.add(var.set_tft_url(config[CONF_TFT_URL])) if CORE.is_esp32: - if CORE.using_arduino: - cg.add_library("NetworkClientSecure", None) - cg.add_library("HTTPClient", None) esp32.add_idf_sdkconfig_option("CONFIG_ESP_TLS_INSECURE", True) esp32.add_idf_sdkconfig_option( "CONFIG_ESP_TLS_SKIP_SERVER_CERT_VERIFY", True ) - elif CORE.is_esp8266 and CORE.using_arduino: + elif CORE.is_esp8266: cg.add_library("ESP8266HTTPClient", None) if CONF_TOUCH_SLEEP_TIMEOUT in config: diff --git a/esphome/components/nextion/nextion.h b/esphome/components/nextion/nextion.h index 7e8f563a96c..331e9015785 100644 --- a/esphome/components/nextion/nextion.h +++ b/esphome/components/nextion/nextion.h @@ -13,17 +13,12 @@ #include "esphome/components/display/display_color_utils.h" #ifdef USE_NEXTION_TFT_UPLOAD -#ifdef USE_ARDUINO #ifdef USE_ESP32 -#include -#endif // USE_ESP32 -#ifdef USE_ESP8266 +#include +#elif defined(USE_ESP8266) #include #include -#endif // USE_ESP8266 -#elif defined(USE_ESP_IDF) -#include -#endif // ARDUINO vs USE_ESP_IDF +#endif // USE_ESP32 vs USE_ESP8266 #endif // USE_NEXTION_TFT_UPLOAD namespace esphome { @@ -1078,7 +1073,7 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe #ifdef USE_NEXTION_TFT_UPLOAD /** - * Set the tft file URL. https seems problematic with Arduino.. + * Set the tft file URL. */ void set_tft_url(const std::string &tft_url) { this->tft_url_ = tft_url; } @@ -1422,16 +1417,7 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe uint32_t original_baud_rate_ = 0; bool upload_first_chunk_sent_ = false; -#ifdef USE_ARDUINO - /** - * will request chunk_size chunks from the web server - * and send each to the nextion - * @param HTTPClient http_client HTTP client handler. - * @param int range_start Position of next byte to transfer. - * @return position of last byte transferred, -1 for failure. - */ - int upload_by_chunks_(HTTPClient &http_client, uint32_t &range_start); -#elif defined(USE_ESP_IDF) +#ifdef USE_ESP32 /** * will request 4096 bytes chunks from the web server * and send each to Nextion @@ -1440,7 +1426,16 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe * @return position of last byte transferred, -1 for failure. */ int upload_by_chunks_(esp_http_client_handle_t http_client, uint32_t &range_start); -#endif // USE_ARDUINO vs USE_ESP_IDF +#elif defined(USE_ARDUINO) + /** + * will request chunk_size chunks from the web server + * and send each to the nextion + * @param HTTPClient http_client HTTP client handler. + * @param int range_start Position of next byte to transfer. + * @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 /** * Ends the upload process, restart Nextion and, if successful, @@ -1450,12 +1445,6 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe */ bool upload_end_(bool successful); - /** - * Returns the ESP Free Heap memory. This is framework independent. - * @return Free Heap in bytes. - */ - uint32_t get_free_heap_(); - #endif // USE_NEXTION_TFT_UPLOAD bool check_connect_(); diff --git a/esphome/components/nextion/nextion_upload_arduino.cpp b/esphome/components/nextion/nextion_upload_arduino.cpp index baea9387293..dfbb5a497ec 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 -#ifdef USE_ARDUINO +#ifndef USE_ESP32 #include #include "esphome/components/network/util.h" @@ -10,10 +10,6 @@ #include "esphome/core/log.h" #include "esphome/core/util.h" -#ifdef USE_ESP32 -#include -#endif - namespace esphome { namespace nextion { static const char *const TAG = "nextion.upload.arduino"; @@ -21,23 +17,17 @@ static const char *const TAG = "nextion.upload.arduino"; // Followed guide // https://unofficialnextion.com/t/nextion-upload-protocol-v1-2-the-fast-one/1044/2 -inline uint32_t Nextion::get_free_heap_() { -#if defined(USE_ESP32) - return heap_caps_get_free_size(MALLOC_CAP_INTERNAL); -#elif defined(USE_ESP8266) - return EspClass::getFreeHeap(); -#endif // USE_ESP32 vs USE_ESP8266 -} - int Nextion::upload_by_chunks_(HTTPClient &http_client, uint32_t &range_start) { uint32_t range_size = this->tft_size_ - range_start; - ESP_LOGV(TAG, "Heap: %" PRIu32, this->get_free_heap_()); + ESP_LOGV(TAG, "Heap: %" PRIu32, EspClass::getFreeHeap()); uint32_t range_end = ((upload_first_chunk_sent_ or this->tft_size_ < 4096) ? this->tft_size_ : 4096) - 1; ESP_LOGD(TAG, "Range start: %" PRIu32, range_start); if (range_size <= 0 or range_end <= range_start) { - ESP_LOGD(TAG, "Range end: %" PRIu32, range_end); - ESP_LOGD(TAG, "Range size: %" PRIu32, range_size); ESP_LOGE(TAG, "Invalid range"); + ESP_LOGD(TAG, + "Range end: %" PRIu32 "\n" + "Range size: %" PRIu32, + range_end, range_size); return -1; } @@ -95,14 +85,8 @@ int Nextion::upload_by_chunks_(HTTPClient &http_client, uint32_t &range_start) { this->recv_ret_string_(recv_string, upload_first_chunk_sent_ ? 500 : 5000, true); this->content_length_ -= read_len; const float upload_percentage = 100.0f * (this->tft_size_ - this->content_length_) / this->tft_size_; -#if defined(USE_ESP32) && defined(USE_PSRAM) - ESP_LOGD(TAG, "Upload: %0.2f%% (%" PRIu32 " left, heap: %" PRIu32 "+%" PRIu32 ")", upload_percentage, - this->content_length_, static_cast(heap_caps_get_free_size(MALLOC_CAP_INTERNAL)), - static_cast(heap_caps_get_free_size(MALLOC_CAP_SPIRAM))); -#else ESP_LOGD(TAG, "Upload: %0.2f%% (%" PRIu32 " left, heap: %" PRIu32 ")", upload_percentage, this->content_length_, - this->get_free_heap_()); -#endif + EspClass::getFreeHeap()); upload_first_chunk_sent_ = true; if (recv_string[0] == 0x08 && recv_string.size() == 5) { // handle partial upload request ESP_LOGD(TAG, "Recv: [%s]", @@ -148,9 +132,11 @@ int Nextion::upload_by_chunks_(HTTPClient &http_client, uint32_t &range_start) { } bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { - ESP_LOGD(TAG, "TFT upload requested"); - ESP_LOGD(TAG, "Exit reparse: %s", YESNO(exit_reparse)); - ESP_LOGD(TAG, "URL: %s", this->tft_url_.c_str()); + ESP_LOGD(TAG, + "TFT upload requested\n" + "Exit reparse: %s\n" + "URL: %s", + YESNO(exit_reparse), this->tft_url_.c_str()); if (this->connection_state_.is_updating_) { ESP_LOGW(TAG, "Upload in progress"); @@ -180,15 +166,14 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { ESP_LOGD(TAG, "Baud rate: %" PRIu32, baud_rate); // Define the configuration for the HTTP client - ESP_LOGV(TAG, "Init HTTP client"); - ESP_LOGV(TAG, "Heap: %" PRIu32, this->get_free_heap_()); + ESP_LOGV(TAG, + "Init HTTP client\n" + "Heap: %" PRIu32, + EspClass::getFreeHeap()); HTTPClient http_client; http_client.setTimeout(15000); // Yes 15 seconds.... Helps 8266s along bool begin_status = false; -#ifdef USE_ESP32 - begin_status = http_client.begin(this->tft_url_.c_str()); -#endif #ifdef USE_ESP8266 #if USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 7, 0) http_client.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); @@ -256,22 +241,24 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { this->send_command_("sleep=0"); this->send_command_("dim=100"); delay(250); // NOLINT - ESP_LOGV(TAG, "Heap: %" PRIu32, this->get_free_heap_()); + ESP_LOGV(TAG, "Heap: %" PRIu32, EspClass::getFreeHeap()); App.feed_wdt(); char command[128]; // Tells the Nextion the content length of the tft file and baud rate it will be sent at // Once the Nextion accepts the command it will wait until the file is successfully uploaded // If it fails for any reason a power cycle of the display will be needed - sprintf(command, "whmi-wris %d,%d,1", this->content_length_, baud_rate); + snprintf(command, sizeof(command), "whmi-wris %" PRIu32 ",%" PRIu32 ",1", this->content_length_, baud_rate); // Clear serial receive buffer ESP_LOGV(TAG, "Clear RX buffer"); this->reset_(false); delay(250); // NOLINT - ESP_LOGV(TAG, "Heap: %" PRIu32, this->get_free_heap_()); - ESP_LOGV(TAG, "Upload cmd: %s", command); + ESP_LOGV(TAG, + "Heap: %" PRIu32 "\n" + "Upload cmd: %s", + EspClass::getFreeHeap(), command); this->send_command_(command); if (baud_rate != this->original_baud_rate_) { @@ -290,7 +277,7 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { ESP_LOGD(TAG, "Upload resp: [%s] %zu B", format_hex_pretty(reinterpret_cast(response.data()), response.size()).c_str(), response.length()); - ESP_LOGV(TAG, "Heap: %" PRIu32, this->get_free_heap_()); + ESP_LOGV(TAG, "Heap: %" PRIu32, EspClass::getFreeHeap()); if (response.find(0x05) != std::string::npos) { ESP_LOGV(TAG, "Upload prep done"); @@ -302,10 +289,12 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { return this->upload_end_(false); } - ESP_LOGD(TAG, "Upload TFT:"); - ESP_LOGD(TAG, " URL: %s", this->tft_url_.c_str()); - ESP_LOGD(TAG, " Size: %d bytes", this->content_length_); - ESP_LOGD(TAG, " Heap: %" PRIu32, this->get_free_heap_()); + ESP_LOGD(TAG, + "Upload TFT:\n" + " URL: %s\n" + " Size: %d bytes\n" + " Heap: %" PRIu32, + this->tft_url_.c_str(), this->content_length_, EspClass::getFreeHeap()); // Proceed with the content download as before @@ -322,7 +311,7 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { return this->upload_end_(false); } App.feed_wdt(); - ESP_LOGV(TAG, "Heap: %" PRIu32 " left: %" PRIu32, this->get_free_heap_(), this->content_length_); + ESP_LOGV(TAG, "Heap: %" PRIu32 " left: %" PRIu32, EspClass::getFreeHeap(), this->content_length_); } ESP_LOGD(TAG, "Upload complete"); @@ -356,5 +345,5 @@ WiFiClient *Nextion::get_wifi_client_() { } // namespace nextion } // namespace esphome -#endif // USE_ARDUINO +#endif // NOT USE_ESP32 #endif // USE_NEXTION_TFT_UPLOAD diff --git a/esphome/components/nextion/nextion_upload_idf.cpp b/esphome/components/nextion/nextion_upload_esp32.cpp similarity index 90% rename from esphome/components/nextion/nextion_upload_idf.cpp rename to esphome/components/nextion/nextion_upload_esp32.cpp index 942e3dd6c31..29a7e3c8d70 100644 --- a/esphome/components/nextion/nextion_upload_idf.cpp +++ b/esphome/components/nextion/nextion_upload_esp32.cpp @@ -1,7 +1,7 @@ #include "nextion.h" #ifdef USE_NEXTION_TFT_UPLOAD -#ifdef USE_ESP_IDF +#ifdef USE_ESP32 #include #include @@ -14,7 +14,7 @@ namespace esphome { namespace nextion { -static const char *const TAG = "nextion.upload.idf"; +static const char *const TAG = "nextion.upload.esp32"; // Followed guide // https://unofficialnextion.com/t/nextion-upload-protocol-v1-2-the-fast-one/1044/2 @@ -25,8 +25,10 @@ int Nextion::upload_by_chunks_(esp_http_client_handle_t http_client, uint32_t &r uint32_t range_end = ((upload_first_chunk_sent_ or this->tft_size_ < 4096) ? this->tft_size_ : 4096) - 1; ESP_LOGD(TAG, "Range start: %" PRIu32, range_start); if (range_size <= 0 or range_end <= range_start) { - ESP_LOGD(TAG, "Range end: %" PRIu32, range_end); - ESP_LOGD(TAG, "Range size: %" PRIu32, range_size); + ESP_LOGD(TAG, + "Range end: %" PRIu32 "\n" + "Range size: %" PRIu32, + range_end, range_size); ESP_LOGE(TAG, "Invalid range"); return -1; } @@ -151,9 +153,11 @@ int Nextion::upload_by_chunks_(esp_http_client_handle_t http_client, uint32_t &r } bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { - ESP_LOGD(TAG, "TFT upload requested"); - ESP_LOGD(TAG, "Exit reparse: %s", YESNO(exit_reparse)); - ESP_LOGD(TAG, "URL: %s", this->tft_url_.c_str()); + ESP_LOGD(TAG, + "TFT upload requested\n" + "Exit reparse: %s\n" + "URL: %s", + YESNO(exit_reparse), this->tft_url_.c_str()); if (this->connection_state_.is_updating_) { ESP_LOGW(TAG, "Upload in progress"); @@ -183,8 +187,10 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { ESP_LOGD(TAG, "Baud rate: %" PRIu32, baud_rate); // Define the configuration for the HTTP client - ESP_LOGV(TAG, "Init HTTP client"); - ESP_LOGV(TAG, "Heap: %" PRIu32, esp_get_free_heap_size()); + ESP_LOGV(TAG, + "Init HTTP client\n" + "Heap: %" PRIu32, + esp_get_free_heap_size()); esp_http_client_config_t config = { .url = this->tft_url_.c_str(), .cert_pem = nullptr, @@ -208,8 +214,10 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { } // Perform the HTTP request - ESP_LOGV(TAG, "Check connection"); - ESP_LOGV(TAG, "Heap: %" PRIu32, esp_get_free_heap_size()); + ESP_LOGV(TAG, + "Check connection\n" + "Heap: %" PRIu32, + esp_get_free_heap_size()); err = esp_http_client_perform(http_client); if (err != ESP_OK) { ESP_LOGE(TAG, "HTTP failed: %s", esp_err_to_name(err)); @@ -218,8 +226,10 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { } // Check the HTTP Status Code - ESP_LOGV(TAG, "Check status"); - ESP_LOGV(TAG, "Heap: %" PRIu32, esp_get_free_heap_size()); + ESP_LOGV(TAG, + "Check status\n" + "Heap: %" PRIu32, + esp_get_free_heap_size()); int status_code = esp_http_client_get_status_code(http_client); if (status_code != 200 && status_code != 206) { return this->upload_end_(false); @@ -255,7 +265,7 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { // Tells the Nextion the content length of the tft file and baud rate it will be sent at // Once the Nextion accepts the command it will wait until the file is successfully uploaded // If it fails for any reason a power cycle of the display will be needed - sprintf(command, "whmi-wris %" PRIu32 ",%" PRIu32 ",1", this->content_length_, baud_rate); + snprintf(command, sizeof(command), "whmi-wris %" PRIu32 ",%" PRIu32 ",1", this->content_length_, baud_rate); // Clear serial receive buffer ESP_LOGV(TAG, "Clear RX buffer"); @@ -300,10 +310,12 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { return this->upload_end_(false); } - ESP_LOGD(TAG, "Uploading TFT:"); - ESP_LOGD(TAG, " URL: %s", this->tft_url_.c_str()); - ESP_LOGD(TAG, " Size: %" PRIu32 " bytes", this->content_length_); - ESP_LOGD(TAG, " Heap: %" PRIu32, esp_get_free_heap_size()); + ESP_LOGD(TAG, + "Uploading TFT:\n" + " URL: %s\n" + " Size: %" PRIu32 " bytes\n" + " Heap: %" PRIu32, + this->tft_url_.c_str(), this->content_length_, esp_get_free_heap_size()); // Proceed with the content download as before @@ -324,9 +336,8 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { ESP_LOGV(TAG, "Heap: %" PRIu32 " left: %" PRIu32, esp_get_free_heap_size(), this->content_length_); } - ESP_LOGD(TAG, "TFT upload complete"); - - ESP_LOGD(TAG, "Close HTTP"); + ESP_LOGD(TAG, "TFT upload complete\n" + "Close HTTP"); esp_http_client_close(http_client); esp_http_client_cleanup(http_client); ESP_LOGV(TAG, "Connection closed"); @@ -336,5 +347,5 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { } // namespace nextion } // namespace esphome -#endif // USE_ESP_IDF +#endif // USE_ESP32 #endif // USE_NEXTION_TFT_UPLOAD diff --git a/esphome/components/number/number.h b/esphome/components/number/number.h index 472e06ad61d..0425714702f 100644 --- a/esphome/components/number/number.h +++ b/esphome/components/number/number.h @@ -49,7 +49,7 @@ class Number : public EntityBase { */ virtual void control(float value) = 0; - CallbackManager state_callback_; + LazyCallbackManager state_callback_; }; } // namespace esphome::number diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index 5b1abe4fb5e..050e45cdc94 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -91,7 +91,7 @@ def set_sdkconfig_options(config): add_idf_sdkconfig_option("CONFIG_OPENTHREAD_SRP_CLIENT", True) add_idf_sdkconfig_option("CONFIG_OPENTHREAD_SRP_CLIENT_MAX_SERVICES", 5) - # TODO: Add suport for synchronized sleepy end devices (SSED) + # TODO: Add support for synchronized sleepy end devices (SSED) add_idf_sdkconfig_option(f"CONFIG_OPENTHREAD_{config.get(CONF_DEVICE_TYPE)}", True) @@ -102,7 +102,7 @@ OpenThreadSrpComponent = openthread_ns.class_("OpenThreadSrpComponent", cg.Compo _CONNECTION_SCHEMA = cv.Schema( { cv.Optional(CONF_PAN_ID): cv.hex_int, - cv.Optional(CONF_CHANNEL): cv.int_, + cv.Optional(CONF_CHANNEL): cv.int_range(min=11, max=26), cv.Optional(CONF_NETWORK_KEY): cv.hex_int, cv.Optional(CONF_EXT_PAN_ID): cv.hex_int, cv.Optional(CONF_NETWORK_NAME): cv.string_strict, diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index 721ab89326c..90da17e2d3b 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -21,8 +21,7 @@ static const char *const TAG = "openthread"; -namespace esphome { -namespace openthread { +namespace esphome::openthread { OpenThreadComponent *global_openthread_component = // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -275,7 +274,5 @@ const char *OpenThreadComponent::get_use_address() const { return this->use_addr void OpenThreadComponent::set_use_address(const char *use_address) { this->use_address_ = use_address; } -} // namespace openthread -} // namespace esphome - +} // namespace esphome::openthread #endif diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index 546128b3666..3c60acaaddb 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -13,8 +13,7 @@ #include #include -namespace esphome { -namespace openthread { +namespace esphome::openthread { class InstanceLock; @@ -91,6 +90,5 @@ class InstanceLock { InstanceLock() {} }; -} // namespace openthread -} // namespace esphome +} // namespace esphome::openthread #endif diff --git a/esphome/components/openthread/openthread_esp.cpp b/esphome/components/openthread/openthread_esp.cpp index 72dc521091c..b47e4b884a7 100644 --- a/esphome/components/openthread/openthread_esp.cpp +++ b/esphome/components/openthread/openthread_esp.cpp @@ -24,8 +24,7 @@ static const char *const TAG = "openthread"; -namespace esphome { -namespace openthread { +namespace esphome::openthread { void OpenThreadComponent::setup() { // Used eventfds: @@ -209,6 +208,5 @@ otInstance *InstanceLock::get_instance() { return esp_openthread_get_instance(); InstanceLock::~InstanceLock() { esp_openthread_lock_release(); } -} // namespace openthread -} // namespace esphome +} // namespace esphome::openthread #endif diff --git a/esphome/components/openthread_info/openthread_info_text_sensor.cpp b/esphome/components/openthread_info/openthread_info_text_sensor.cpp index 10724f3e2fc..fc61ad81b20 100644 --- a/esphome/components/openthread_info/openthread_info_text_sensor.cpp +++ b/esphome/components/openthread_info/openthread_info_text_sensor.cpp @@ -3,8 +3,7 @@ #ifdef USE_OPENTHREAD #include "esphome/core/log.h" -namespace esphome { -namespace openthread_info { +namespace esphome::openthread_info { static const char *const TAG = "openthread_info"; @@ -19,6 +18,5 @@ void NetworkKeyOpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "Network Key" void PanIdOpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "PAN ID", this); } void ExtPanIdOpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "Extended PAN ID", this); } -} // namespace openthread_info -} // namespace esphome +} // namespace esphome::openthread_info #endif diff --git a/esphome/components/openthread_info/openthread_info_text_sensor.h b/esphome/components/openthread_info/openthread_info_text_sensor.h index bbcd2d4655b..35e46212cbf 100644 --- a/esphome/components/openthread_info/openthread_info_text_sensor.h +++ b/esphome/components/openthread_info/openthread_info_text_sensor.h @@ -5,8 +5,7 @@ #include "esphome/core/component.h" #ifdef USE_OPENTHREAD -namespace esphome { -namespace openthread_info { +namespace esphome::openthread_info { using esphome::openthread::InstanceLock; @@ -213,6 +212,5 @@ class ExtPanIdOpenThreadInfo : public DatasetOpenThreadInfo, public text_sensor: std::array last_extpanid_{}; }; -} // namespace openthread_info -} // namespace esphome +} // namespace esphome::openthread_info #endif diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index be1b6da2410..8bed9cee421 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -13,6 +13,8 @@ from esphome.const import ( from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority +OTA_STATE_LISTENER_KEY = "ota_state_listener" + CODEOWNERS = ["@esphome/core"] AUTO_LOAD = ["md5", "safe_mode"] @@ -86,6 +88,7 @@ BASE_OTA_SCHEMA = cv.Schema( @coroutine_with_priority(CoroPriority.OTA_UPDATES) async def to_code(config): cg.add_define("USE_OTA") + CORE.add_job(final_step) if CORE.is_rp2040 and CORE.using_arduino: cg.add_library("Updater", None) @@ -119,7 +122,24 @@ async def ota_to_code(var, config): await automation.build_automation(trigger, [(cg.uint8, "x")], conf) use_state_callback = True if use_state_callback: - cg.add_define("USE_OTA_STATE_CALLBACK") + request_ota_state_listeners() + + +def request_ota_state_listeners() -> None: + """Request that OTA state listeners be compiled in. + + Components that need to be notified about OTA state changes (start, progress, + complete, error) should call this function during their code generation. + This enables the add_state_listener() API on OTAComponent. + """ + CORE.data[OTA_STATE_LISTENER_KEY] = True + + +@coroutine_with_priority(CoroPriority.FINAL) +async def final_step(): + """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") FILTER_SOURCE_FILES = filter_source_files_from_platform( diff --git a/esphome/components/ota/automation.h b/esphome/components/ota/automation.h index 7e1a60f3ce2..92c0050ba01 100644 --- a/esphome/components/ota/automation.h +++ b/esphome/components/ota/automation.h @@ -1,5 +1,5 @@ #pragma once -#ifdef USE_OTA_STATE_CALLBACK +#ifdef USE_OTA_STATE_LISTENER #include "ota_backend.h" #include "esphome/core/automation.h" @@ -7,70 +7,64 @@ namespace esphome { namespace ota { -class OTAStateChangeTrigger : public Trigger { +class OTAStateChangeTrigger final : public Trigger, public OTAStateListener { public: - explicit OTAStateChangeTrigger(OTAComponent *parent) { - parent->add_on_state_callback([this, parent](OTAState state, float progress, uint8_t error) { - if (!parent->is_failed()) { - trigger(state); - } - }); + explicit OTAStateChangeTrigger(OTAComponent *parent) : parent_(parent) { parent->add_state_listener(this); } + + void on_ota_state(OTAState state, float progress, uint8_t error) override { + if (!this->parent_->is_failed()) { + this->trigger(state); + } } + + protected: + OTAComponent *parent_; }; -class OTAStartTrigger : public Trigger<> { +template class OTAStateTrigger final : public Trigger<>, public OTAStateListener { public: - explicit OTAStartTrigger(OTAComponent *parent) { - parent->add_on_state_callback([this, parent](OTAState state, float progress, uint8_t error) { - if (state == OTA_STARTED && !parent->is_failed()) { - trigger(); - } - }); + explicit OTAStateTrigger(OTAComponent *parent) : parent_(parent) { parent->add_state_listener(this); } + + void on_ota_state(OTAState state, float progress, uint8_t error) override { + if (state == State && !this->parent_->is_failed()) { + this->trigger(); + } } + + protected: + OTAComponent *parent_; }; -class OTAProgressTrigger : public Trigger { +using OTAStartTrigger = OTAStateTrigger; +using OTAEndTrigger = OTAStateTrigger; +using OTAAbortTrigger = OTAStateTrigger; + +class OTAProgressTrigger final : public Trigger, public OTAStateListener { public: - explicit OTAProgressTrigger(OTAComponent *parent) { - parent->add_on_state_callback([this, parent](OTAState state, float progress, uint8_t error) { - if (state == OTA_IN_PROGRESS && !parent->is_failed()) { - trigger(progress); - } - }); + explicit OTAProgressTrigger(OTAComponent *parent) : parent_(parent) { parent->add_state_listener(this); } + + void on_ota_state(OTAState state, float progress, uint8_t error) override { + if (state == OTA_IN_PROGRESS && !this->parent_->is_failed()) { + this->trigger(progress); + } } + + protected: + OTAComponent *parent_; }; -class OTAEndTrigger : public Trigger<> { +class OTAErrorTrigger final : public Trigger, public OTAStateListener { public: - explicit OTAEndTrigger(OTAComponent *parent) { - parent->add_on_state_callback([this, parent](OTAState state, float progress, uint8_t error) { - if (state == OTA_COMPLETED && !parent->is_failed()) { - trigger(); - } - }); - } -}; + explicit OTAErrorTrigger(OTAComponent *parent) : parent_(parent) { parent->add_state_listener(this); } -class OTAAbortTrigger : public Trigger<> { - public: - explicit OTAAbortTrigger(OTAComponent *parent) { - parent->add_on_state_callback([this, parent](OTAState state, float progress, uint8_t error) { - if (state == OTA_ABORT && !parent->is_failed()) { - trigger(); - } - }); + void on_ota_state(OTAState state, float progress, uint8_t error) override { + if (state == OTA_ERROR && !this->parent_->is_failed()) { + this->trigger(error); + } } -}; -class OTAErrorTrigger : public Trigger { - public: - explicit OTAErrorTrigger(OTAComponent *parent) { - parent->add_on_state_callback([this, parent](OTAState state, float progress, uint8_t error) { - if (state == OTA_ERROR && !parent->is_failed()) { - trigger(error); - } - }); - } + protected: + OTAComponent *parent_; }; } // namespace ota diff --git a/esphome/components/ota/ota_backend.cpp b/esphome/components/ota/ota_backend.cpp index 30de4ec4b32..8fb9f672145 100644 --- a/esphome/components/ota/ota_backend.cpp +++ b/esphome/components/ota/ota_backend.cpp @@ -3,7 +3,7 @@ namespace esphome { namespace ota { -#ifdef USE_OTA_STATE_CALLBACK +#ifdef USE_OTA_STATE_LISTENER OTAGlobalCallback *global_ota_callback{nullptr}; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) OTAGlobalCallback *get_global_ota_callback() { @@ -13,7 +13,12 @@ OTAGlobalCallback *get_global_ota_callback() { return global_ota_callback; } -void register_ota_platform(OTAComponent *ota_caller) { get_global_ota_callback()->register_ota(ota_caller); } +void OTAComponent::notify_state_(OTAState state, float progress, uint8_t error) { + for (auto *listener : this->state_listeners_) { + listener->on_ota_state(state, progress, error); + } + get_global_ota_callback()->notify_ota_state(state, progress, error, this); +} #endif } // namespace ota diff --git a/esphome/components/ota/ota_backend.h b/esphome/components/ota/ota_backend.h index 64ee0b9f7ce..e03afd4fc6f 100644 --- a/esphome/components/ota/ota_backend.h +++ b/esphome/components/ota/ota_backend.h @@ -4,8 +4,8 @@ #include "esphome/core/defines.h" #include "esphome/core/helpers.h" -#ifdef USE_OTA_STATE_CALLBACK -#include "esphome/core/automation.h" +#ifdef USE_OTA_STATE_LISTENER +#include #endif namespace esphome { @@ -60,62 +60,75 @@ class OTABackend { virtual bool supports_compression() = 0; }; -class OTAComponent : public Component { -#ifdef USE_OTA_STATE_CALLBACK +/** Listener interface for OTA state changes. + * + * Components can implement this interface to receive OTA state updates + * without the overhead of std::function callbacks. + */ +class OTAStateListener { public: - void add_on_state_callback(std::function &&callback) { - this->state_callback_.add(std::move(callback)); - } + virtual ~OTAStateListener() = default; + virtual void on_ota_state(OTAState state, float progress, uint8_t error) = 0; +}; + +class OTAComponent : public Component { +#ifdef USE_OTA_STATE_LISTENER + public: + void add_state_listener(OTAStateListener *listener) { this->state_listeners_.push_back(listener); } protected: - /** Extended callback manager with deferred call support. + void notify_state_(OTAState state, float progress, uint8_t error); + + /** Notify state with deferral to main loop (for thread safety). * - * This adds a call_deferred() method for thread-safe execution from other tasks. + * This should be used by OTA implementations that run in separate tasks + * (like web_server OTA) to ensure listeners execute in the main loop. */ - class StateCallbackManager : public CallbackManager { - public: - StateCallbackManager(OTAComponent *component) : component_(component) {} + void notify_state_deferred_(OTAState state, float progress, uint8_t error) { + this->defer([this, state, progress, error]() { this->notify_state_(state, progress, error); }); + } - /** Call callbacks with deferral to main loop (for thread safety). - * - * This should be used by OTA implementations that run in separate tasks - * (like web_server OTA) to ensure callbacks execute in the main loop. - */ - void call_deferred(ota::OTAState state, float progress, uint8_t error) { - component_->defer([this, state, progress, error]() { this->call(state, progress, error); }); - } - - private: - OTAComponent *component_; - }; - - StateCallbackManager state_callback_{this}; + std::vector state_listeners_; #endif }; -#ifdef USE_OTA_STATE_CALLBACK +#ifdef USE_OTA_STATE_LISTENER + +/** Listener interface for global OTA state changes (includes OTA component pointer). + * + * Used by OTAGlobalCallback to aggregate state from multiple OTA components. + */ +class OTAGlobalStateListener { + public: + virtual ~OTAGlobalStateListener() = default; + virtual void on_ota_global_state(OTAState state, float progress, uint8_t error, OTAComponent *component) = 0; +}; + +/** Global callback that aggregates OTA state from all OTA components. + * + * OTA components call notify_ota_state() directly with their pointer, + * which forwards the event to all registered global listeners. + */ class OTAGlobalCallback { public: - void register_ota(OTAComponent *ota_caller) { - ota_caller->add_on_state_callback([this, ota_caller](OTAState state, float progress, uint8_t error) { - this->state_callback_.call(state, progress, error, ota_caller); - }); - } - void add_on_state_callback(std::function &&callback) { - this->state_callback_.add(std::move(callback)); + void add_global_state_listener(OTAGlobalStateListener *listener) { this->global_listeners_.push_back(listener); } + + void notify_ota_state(OTAState state, float progress, uint8_t error, OTAComponent *component) { + for (auto *listener : this->global_listeners_) { + listener->on_ota_global_state(state, progress, error, component); + } } protected: - CallbackManager state_callback_{}; + std::vector global_listeners_; }; OTAGlobalCallback *get_global_ota_callback(); -void register_ota_platform(OTAComponent *ota_caller); // OTA implementations should use: -// - state_callback_.call() when already in main loop (e.g., esphome OTA) -// - state_callback_.call_deferred() when in separate task (e.g., web_server OTA) -// This ensures proper callback execution in all contexts. +// - notify_state_() when already in main loop (e.g., esphome OTA) +// - notify_state_deferred_() when in separate task (e.g., web_server OTA) +// This ensures proper listener execution in all contexts. #endif std::unique_ptr make_ota_backend(); diff --git a/esphome/components/packet_transport/__init__.py b/esphome/components/packet_transport/__init__.py index 43da7740fec..1930e45e854 100644 --- a/esphome/components/packet_transport/__init__.py +++ b/esphome/components/packet_transport/__init__.py @@ -176,17 +176,22 @@ async def register_packet_transport(var, config): if encryption := provider.get(CONF_ENCRYPTION): cg.add(var.set_provider_encryption(name, hash_encryption_key(encryption))) + is_provider = False for sens_conf in config.get(CONF_SENSORS, ()): + is_provider = True sens_id = sens_conf[CONF_ID] sensor = await cg.get_variable(sens_id) bcst_id = sens_conf.get(CONF_BROADCAST_ID, sens_id.id) cg.add(var.add_sensor(bcst_id, sensor)) for sens_conf in config.get(CONF_BINARY_SENSORS, ()): + is_provider = True sens_id = sens_conf[CONF_ID] sensor = await cg.get_variable(sens_id) bcst_id = sens_conf.get(CONF_BROADCAST_ID, sens_id.id) cg.add(var.add_binary_sensor(bcst_id, sensor)) + if is_provider: + cg.add(var.set_is_provider(True)) if encryption := config.get(CONF_ENCRYPTION): cg.add(var.set_encryption_key(hash_encryption_key(encryption))) return providers diff --git a/esphome/components/packet_transport/packet_transport.cpp b/esphome/components/packet_transport/packet_transport.cpp index 37e5f3d9e18..da7f5f8bff1 100644 --- a/esphome/components/packet_transport/packet_transport.cpp +++ b/esphome/components/packet_transport/packet_transport.cpp @@ -263,6 +263,7 @@ void PacketTransport::flush_() { xxtea::encrypt((uint32_t *) (encode_buffer.data() + header_len), len / 4, (uint32_t *) this->encryption_key_.data()); } + ESP_LOGVV(TAG, "Sending packet %s", format_hex_pretty(encode_buffer.data(), encode_buffer.size()).c_str()); this->send_packet(encode_buffer); } @@ -316,6 +317,9 @@ void PacketTransport::send_data_(bool all) { } void PacketTransport::update() { + // resend all sensors if required + if (this->is_provider_) + this->send_data_(true); if (!this->ping_pong_enable_) { return; } @@ -551,7 +555,7 @@ void PacketTransport::loop() { if (this->resend_ping_key_) this->send_ping_pong_request_(); if (this->updated_) { - this->send_data_(this->resend_data_); + this->send_data_(false); } } diff --git a/esphome/components/packet_transport/packet_transport.h b/esphome/components/packet_transport/packet_transport.h index a2370e97490..86ec564fcec 100644 --- a/esphome/components/packet_transport/packet_transport.h +++ b/esphome/components/packet_transport/packet_transport.h @@ -91,6 +91,7 @@ class PacketTransport : public PollingComponent { } } + void set_is_provider(bool is_provider) { this->is_provider_ = is_provider; } void set_encryption_key(std::vector key) { this->encryption_key_ = std::move(key); } void set_rolling_code_enable(bool enable) { this->rolling_code_enable_ = enable; } void set_ping_pong_enable(bool enable) { this->ping_pong_enable_ = enable; } @@ -129,7 +130,7 @@ class PacketTransport : public PollingComponent { uint32_t ping_pong_recyle_time_{}; uint32_t last_key_time_{}; bool resend_ping_key_{}; - bool resend_data_{}; + bool is_provider_{}; const char *name_{}; ESPPreferenceObject pref_{}; diff --git a/esphome/components/pca9685/__init__.py b/esphome/components/pca9685/__init__.py index 56101c2d621..0e238ff7dae 100644 --- a/esphome/components/pca9685/__init__.py +++ b/esphome/components/pca9685/__init__.py @@ -38,7 +38,7 @@ CONFIG_SCHEMA = cv.All( { cv.GenerateID(): cv.declare_id(PCA9685Output), cv.Optional(CONF_FREQUENCY): cv.All( - cv.frequency, cv.Range(min=23.84, max=1525.88) + cv.frequency, cv.float_range(min=23.84, max=1525.88) ), cv.Optional(CONF_EXTERNAL_CLOCK_INPUT, default=False): cv.boolean, cv.Optional(CONF_PHASE_BALANCER, default="linear"): cv.enum( diff --git a/esphome/components/pm1006/sensor.py b/esphome/components/pm1006/sensor.py index c693cfea196..8ff21ab0693 100644 --- a/esphome/components/pm1006/sensor.py +++ b/esphome/components/pm1006/sensor.py @@ -7,10 +7,10 @@ from esphome.const import ( CONF_UPDATE_INTERVAL, DEVICE_CLASS_PM25, ICON_BLUR, + SCHEDULER_DONT_RUN, STATE_CLASS_MEASUREMENT, UNIT_MICROGRAMS_PER_CUBIC_METER, ) -from esphome.core import TimePeriodMilliseconds CODEOWNERS = ["@habbie"] DEPENDENCIES = ["uart"] @@ -41,16 +41,12 @@ CONFIG_SCHEMA = cv.All( def validate_interval_uart(config): - require_tx = False - interval = config.get(CONF_UPDATE_INTERVAL) - - if isinstance(interval, TimePeriodMilliseconds): - # 'never' is encoded as a very large int, not as a TimePeriodMilliseconds objects - require_tx = True - uart.final_validate_device_schema( - "pm1006", baud_rate=9600, require_rx=True, require_tx=require_tx + "pm1006", + baud_rate=9600, + require_rx=True, + require_tx=interval.total_milliseconds != SCHEDULER_DONT_RUN, )(config) diff --git a/esphome/components/pmsx003/pmsx003.cpp b/esphome/components/pmsx003/pmsx003.cpp index eb10d19c91d..3bdb5219ed6 100644 --- a/esphome/components/pmsx003/pmsx003.cpp +++ b/esphome/components/pmsx003/pmsx003.cpp @@ -18,6 +18,8 @@ static const uint16_t PMS_CMD_MEASUREMENT_MODE_ACTIVE = 0x0001; // automaticall static const uint16_t PMS_CMD_SLEEP_MODE_SLEEP = 0x0000; // go to sleep mode static const uint16_t PMS_CMD_SLEEP_MODE_WAKEUP = 0x0001; // wake up from sleep mode +void PMSX003Component::setup() {} + void PMSX003Component::dump_config() { ESP_LOGCONFIG(TAG, "PMSX003:"); LOG_SENSOR(" ", "PM1.0STD", this->pm_1_0_std_sensor_); @@ -39,21 +41,36 @@ void PMSX003Component::dump_config() { LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); + + if (this->update_interval_ <= PMS_STABILISING_MS) { + ESP_LOGCONFIG(TAG, " Mode: active continuous (sensor default)"); + } else { + ESP_LOGCONFIG(TAG, " Mode: passive with sleep/wake cycles"); + } + this->check_uart_settings(9600); } void PMSX003Component::loop() { const uint32_t now = App.get_loop_component_start_time(); + // Initialize sensor mode on first loop + if (this->initialised_ == 0) { + if (this->update_interval_ > PMS_STABILISING_MS) { + // Long update interval: use passive mode with sleep/wake cycles + this->send_command_(PMS_CMD_MEASUREMENT_MODE, PMS_CMD_MEASUREMENT_MODE_PASSIVE); + this->send_command_(PMS_CMD_SLEEP_MODE, PMS_CMD_SLEEP_MODE_WAKEUP); + } else { + // Short/zero update interval: use active continuous mode + this->send_command_(PMS_CMD_MEASUREMENT_MODE, PMS_CMD_MEASUREMENT_MODE_ACTIVE); + } + this->initialised_ = 1; + } + // If we update less often than it takes the device to stabilise, spin the fan down // rather than running it constantly. It does take some time to stabilise, so we // need to keep track of what state we're in. if (this->update_interval_ > PMS_STABILISING_MS) { - if (this->initialised_ == 0) { - this->send_command_(PMS_CMD_MEASUREMENT_MODE, PMS_CMD_MEASUREMENT_MODE_PASSIVE); - this->send_command_(PMS_CMD_SLEEP_MODE, PMS_CMD_SLEEP_MODE_WAKEUP); - this->initialised_ = 1; - } switch (this->state_) { case PMSX003_STATE_IDLE: // Power on the sensor now so it'll be ready when we hit the update time @@ -248,6 +265,13 @@ void PMSX003Component::parse_data_() { if (this->pm_particles_25um_sensor_ != nullptr) this->pm_particles_25um_sensor_->publish_state(pm_particles_25um); + // Calculate and publish AQI if sensor is configured + if (this->aqi_sensor_ != nullptr) { + aqi::AbstractAQICalculator *calculator = this->aqi_calculator_factory_.get_calculator(this->aqi_calc_type_); + int32_t aqi_value = calculator->get_aqi(pm_2_5_concentration, pm_10_0_concentration); + this->aqi_sensor_->publish_state(aqi_value); + } + if (this->type_ == PMSX003_TYPE_5003T) { ESP_LOGD(TAG, "Got PM0.3 Particles: %u Count/0.1L, PM0.5 Particles: %u Count/0.1L, PM1.0 Particles: %u Count/0.1L, " diff --git a/esphome/components/pmsx003/pmsx003.h b/esphome/components/pmsx003/pmsx003.h index ba607b4487e..229972e2e55 100644 --- a/esphome/components/pmsx003/pmsx003.h +++ b/esphome/components/pmsx003/pmsx003.h @@ -4,6 +4,7 @@ #include "esphome/core/helpers.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/uart/uart.h" +#include "esphome/components/aqi/aqi_calculator_factory.h" namespace esphome { namespace pmsx003 { @@ -31,6 +32,7 @@ enum PMSX003State { class PMSX003Component : public uart::UARTDevice, public Component { public: PMSX003Component() = default; + void setup() override; void dump_config() override; void loop() override; @@ -72,6 +74,10 @@ class PMSX003Component : public uart::UARTDevice, public Component { 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; } + void set_aqi_sensor(sensor::Sensor *aqi_sensor) { aqi_sensor_ = aqi_sensor; } + + void set_aqi_calculation_type(aqi::AQICalculatorType aqi_calc_type) { aqi_calc_type_ = aqi_calc_type; } + protected: optional check_byte_(); void parse_data_(); @@ -115,6 +121,12 @@ class PMSX003Component : public uart::UARTDevice, public Component { // Temperature and Humidity sensor::Sensor *temperature_sensor_{nullptr}; sensor::Sensor *humidity_sensor_{nullptr}; + + // AQI + sensor::Sensor *aqi_sensor_{nullptr}; + + aqi::AQICalculatorType aqi_calc_type_; + aqi::AQICalculatorFactory aqi_calculator_factory_ = aqi::AQICalculatorFactory(); }; } // namespace pmsx003 diff --git a/esphome/components/pmsx003/sensor.py b/esphome/components/pmsx003/sensor.py index bebd3a01ee7..b2d67445478 100644 --- a/esphome/components/pmsx003/sensor.py +++ b/esphome/components/pmsx003/sensor.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import sensor, uart +from esphome.components.aqi import AQI_CALCULATION_TYPE, CONF_AQI, CONF_CALCULATION_TYPE import esphome.config_validation as cv from esphome.const import ( CONF_FORMALDEHYDE, @@ -20,6 +21,7 @@ from esphome.const import ( CONF_TEMPERATURE, CONF_TYPE, CONF_UPDATE_INTERVAL, + DEVICE_CLASS_AQI, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_PM1, DEVICE_CLASS_PM10, @@ -35,11 +37,13 @@ from esphome.const import ( CODEOWNERS = ["@ximex"] DEPENDENCIES = ["uart"] +AUTO_LOAD = ["aqi"] pmsx003_ns = cg.esphome_ns.namespace("pmsx003") PMSX003Component = pmsx003_ns.class_("PMSX003Component", uart.UARTDevice, cg.Component) PMSX003Sensor = pmsx003_ns.class_("PMSX003Sensor", sensor.Sensor) +UNIT_INDEX = "index" TYPE_PMSX003 = "PMSX003" TYPE_PMS5003T = "PMS5003T" TYPE_PMS5003ST = "PMS5003ST" @@ -77,6 +81,10 @@ def validate_pmsx003_sensors(value): 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!") + if CONF_AQI in value and CONF_PM_2_5 not in value: + raise cv.Invalid("AQI computation requires PM 2.5 sensor") + if CONF_AQI in value and CONF_PM_10_0 not in value: + raise cv.Invalid("AQI computation requires PM 10 sensor") return value @@ -192,6 +200,19 @@ CONFIG_SCHEMA = ( device_class=DEVICE_CLASS_HUMIDITY, state_class=STATE_CLASS_MEASUREMENT, ), + cv.Optional(CONF_AQI): sensor.sensor_schema( + unit_of_measurement=UNIT_INDEX, + icon=ICON_CHEMICAL_WEAPON, + accuracy_decimals=0, + device_class=DEVICE_CLASS_AQI, + state_class=STATE_CLASS_MEASUREMENT, + ).extend( + { + cv.Required(CONF_CALCULATION_TYPE): cv.enum( + AQI_CALCULATION_TYPE, upper=True + ), + } + ), cv.Optional(CONF_UPDATE_INTERVAL, default="0s"): validate_update_interval, } ) @@ -278,4 +299,9 @@ async def to_code(config): sens = await sensor.new_sensor(config[CONF_HUMIDITY]) cg.add(var.set_humidity_sensor(sens)) + if CONF_AQI in config: + sens = await sensor.new_sensor(config[CONF_AQI]) + cg.add(var.set_aqi_sensor(sens)) + cg.add(var.set_aqi_calculation_type(config[CONF_AQI][CONF_CALCULATION_TYPE])) + cg.add(var.set_update_interval(config[CONF_UPDATE_INTERVAL])) diff --git a/esphome/components/remote_base/abbwelcome_protocol.h b/esphome/components/remote_base/abbwelcome_protocol.h index 4b922eb2f16..b8d9293c117 100644 --- a/esphome/components/remote_base/abbwelcome_protocol.h +++ b/esphome/components/remote_base/abbwelcome_protocol.h @@ -232,10 +232,10 @@ template class ABBWelcomeAction : public RemoteTransmitterAction data.set_message_id(this->message_id_.value(x...)); data.auto_message_id = this->auto_message_id_.value(x...); std::vector data_vec; - if (this->len_ >= 0) { + if (this->len_ > 0) { // Static mode: copy from flash to vector data_vec.assign(this->data_.data, this->data_.data + this->len_); - } else { + } else if (this->len_ < 0) { // Template mode: call function data_vec = this->data_.func(x...); } @@ -245,7 +245,7 @@ template class ABBWelcomeAction : public RemoteTransmitterAction } protected: - ssize_t len_{-1}; // -1 = template mode, >=0 = static mode with length + ssize_t len_{0}; // <0 = template mode, >=0 = static mode with length union Data { std::vector (*func)(Ts...); // Function pointer (stateless lambdas) const uint8_t *data; // Pointer to static data in flash diff --git a/esphome/components/resampler/speaker/__init__.py b/esphome/components/resampler/speaker/__init__.py index def62547b26..7036862d148 100644 --- a/esphome/components/resampler/speaker/__init__.py +++ b/esphome/components/resampler/speaker/__init__.py @@ -63,9 +63,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_BUFFER_DURATION, default="100ms" ): cv.positive_time_period_milliseconds, - cv.SplitDefault(CONF_TASK_STACK_IN_PSRAM, esp32_idf=False): cv.All( - cv.boolean, cv.only_with_esp_idf - ), + cv.Optional(CONF_TASK_STACK_IN_PSRAM, default=False): cv.boolean, cv.Optional(CONF_FILTERS, default=16): cv.int_range(min=2, max=1024), cv.Optional(CONF_TAPS, default=16): _validate_taps, } diff --git a/esphome/components/rp2040_pwm/output.py b/esphome/components/rp2040_pwm/output.py index ac1892fa290..441a52de7f9 100644 --- a/esphome/components/rp2040_pwm/output.py +++ b/esphome/components/rp2040_pwm/output.py @@ -11,7 +11,7 @@ DEPENDENCIES = ["rp2040"] rp2040_pwm_ns = cg.esphome_ns.namespace("rp2040_pwm") RP2040PWM = rp2040_pwm_ns.class_("RP2040PWM", output.FloatOutput, cg.Component) SetFrequencyAction = rp2040_pwm_ns.class_("SetFrequencyAction", automation.Action) -validate_frequency = cv.All(cv.frequency, cv.Range(min=1.0e-6)) +validate_frequency = cv.All(cv.frequency, cv.float_range(min=1.0e-6)) CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( { diff --git a/esphome/components/safe_mode/safe_mode.cpp b/esphome/components/safe_mode/safe_mode.cpp index 62bbca4fb17..f8e5d7d8e56 100644 --- a/esphome/components/safe_mode/safe_mode.cpp +++ b/esphome/components/safe_mode/safe_mode.cpp @@ -9,6 +9,10 @@ #include #include +#ifdef USE_OTA_ROLLBACK +#include +#endif + namespace esphome { namespace safe_mode { @@ -32,6 +36,14 @@ void SafeModeComponent::dump_config() { ESP_LOGW(TAG, "SAFE MODE IS ACTIVE"); } } + +#ifdef USE_OTA_ROLLBACK + const esp_partition_t *last_invalid = esp_ota_get_last_invalid_partition(); + if (last_invalid != nullptr) { + ESP_LOGW(TAG, "OTA rollback detected! Rolled back from partition '%s'", last_invalid->label); + ESP_LOGW(TAG, "The device reset before the boot was marked successful"); + } +#endif } float SafeModeComponent::get_setup_priority() const { return setup_priority::AFTER_WIFI; } @@ -42,6 +54,10 @@ void SafeModeComponent::loop() { ESP_LOGI(TAG, "Boot seems successful; resetting boot loop counter"); this->clean_rtc(); this->boot_successful_ = true; +#ifdef USE_OTA_ROLLBACK + // Mark OTA partition as valid to prevent rollback + esp_ota_mark_app_valid_cancel_rollback(); +#endif // Disable loop since we no longer need to check this->disable_loop(); } diff --git a/esphome/components/select/automation.h b/esphome/components/select/automation.h index 768f2621f77..dda54035573 100644 --- a/esphome/components/select/automation.h +++ b/esphome/components/select/automation.h @@ -8,9 +8,13 @@ namespace esphome::select { class SelectStateTrigger : public Trigger { public: - explicit SelectStateTrigger(Select *parent) { - parent->add_on_state_callback([this](const std::string &value, size_t index) { this->trigger(value, index); }); + explicit SelectStateTrigger(Select *parent) : parent_(parent) { + parent->add_on_state_callback( + [this](size_t index) { this->trigger(std::string(this->parent_->option_at(index)), index); }); } + + protected: + Select *parent_; }; template class SelectSetAction : public Action { diff --git a/esphome/components/select/select.cpp b/esphome/components/select/select.cpp index 4fc4d79b089..28d7eb07d4f 100644 --- a/esphome/components/select/select.cpp +++ b/esphome/components/select/select.cpp @@ -32,8 +32,7 @@ void Select::publish_state(size_t index) { this->state = option; // Update deprecated member for backward compatibility #pragma GCC diagnostic pop ESP_LOGD(TAG, "'%s': Sending state %s (index %zu)", this->get_name().c_str(), option, index); - // Callback signature requires std::string, create temporary for compatibility - this->state_callback_.call(std::string(option), index); + this->state_callback_.call(index); #if defined(USE_SELECT) && defined(USE_CONTROLLER_REGISTRY) ControllerRegistry::notify_select_update(this); #endif @@ -41,7 +40,7 @@ void Select::publish_state(size_t index) { const char *Select::current_option() const { return this->has_state() ? this->option_at(this->active_index_) : ""; } -void Select::add_on_state_callback(std::function &&callback) { +void Select::add_on_state_callback(std::function &&callback) { this->state_callback_.add(std::move(callback)); } diff --git a/esphome/components/select/select.h b/esphome/components/select/select.h index 63707f6bd6a..330d18ce6f0 100644 --- a/esphome/components/select/select.h +++ b/esphome/components/select/select.h @@ -75,7 +75,7 @@ class Select : public EntityBase { /// Return the option value at the provided index offset (as const char* from flash). const char *option_at(size_t index) const; - void add_on_state_callback(std::function &&callback); + void add_on_state_callback(std::function &&callback); protected: friend class SelectCall; @@ -111,7 +111,7 @@ class Select : public EntityBase { } } - CallbackManager state_callback_; + LazyCallbackManager state_callback_; }; } // namespace esphome::select diff --git a/esphome/components/sen5x/sen5x.cpp b/esphome/components/sen5x/sen5x.cpp index ffb9e2bc020..c72ccf25954 100644 --- a/esphome/components/sen5x/sen5x.cpp +++ b/esphome/components/sen5x/sen5x.cpp @@ -1,4 +1,5 @@ #include "sen5x.h" +#include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -154,10 +155,10 @@ void SEN5XComponent::setup() { if (this->voc_sensor_ && this->store_baseline_) { uint32_t combined_serial = encode_uint24(this->serial_number_[0], this->serial_number_[1], this->serial_number_[2]); - // Hash with compilation time and serial number + // 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 mulitple sensors can be used without conflict - uint32_t hash = fnv1_hash(App.get_compilation_time_ref() + std::to_string(combined_serial)); + // 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(), std::to_string(combined_serial)); this->pref_ = global_preferences->make_preference(hash, true); if (this->pref_.load(&this->voc_baselines_storage_)) { diff --git a/esphome/components/sen5x/sensor.py b/esphome/components/sen5x/sensor.py index 9668a253c04..9c3114b9e24 100644 --- a/esphome/components/sen5x/sensor.py +++ b/esphome/components/sen5x/sensor.py @@ -4,17 +4,28 @@ import esphome.codegen as cg from esphome.components import i2c, sensirion_common, sensor import esphome.config_validation as cv from esphome.const import ( + CONF_ALGORITHM_TUNING, CONF_GAIN_FACTOR, + CONF_GATING_MAX_DURATION_MINUTES, CONF_HUMIDITY, CONF_ID, + CONF_INDEX_OFFSET, + CONF_LEARNING_TIME_GAIN_HOURS, + CONF_LEARNING_TIME_OFFSET_HOURS, + CONF_NORMALIZED_OFFSET_SLOPE, + CONF_NOX, CONF_OFFSET, CONF_PM_1_0, CONF_PM_2_5, CONF_PM_4_0, CONF_PM_10_0, + CONF_STD_INITIAL, CONF_STORE_BASELINE, CONF_TEMPERATURE, CONF_TEMPERATURE_COMPENSATION, + CONF_TIME_CONSTANT, + CONF_VOC, + CONF_VOC_BASELINE, DEVICE_CLASS_AQI, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_PM1, @@ -42,18 +53,7 @@ SEN5XComponent = sen5x_ns.class_( RhtAccelerationMode = sen5x_ns.enum("RhtAccelerationMode") CONF_ACCELERATION_MODE = "acceleration_mode" -CONF_ALGORITHM_TUNING = "algorithm_tuning" CONF_AUTO_CLEANING_INTERVAL = "auto_cleaning_interval" -CONF_GATING_MAX_DURATION_MINUTES = "gating_max_duration_minutes" -CONF_INDEX_OFFSET = "index_offset" -CONF_LEARNING_TIME_GAIN_HOURS = "learning_time_gain_hours" -CONF_LEARNING_TIME_OFFSET_HOURS = "learning_time_offset_hours" -CONF_NORMALIZED_OFFSET_SLOPE = "normalized_offset_slope" -CONF_NOX = "nox" -CONF_STD_INITIAL = "std_initial" -CONF_TIME_CONSTANT = "time_constant" -CONF_VOC = "voc" -CONF_VOC_BASELINE = "voc_baseline" # Actions diff --git a/esphome/components/sensor/sensor.cpp b/esphome/components/sensor/sensor.cpp index 49dc56edaa1..c1d28bf260b 100644 --- a/esphome/components/sensor/sensor.cpp +++ b/esphome/components/sensor/sensor.cpp @@ -76,9 +76,7 @@ StateClass Sensor::get_state_class() { void Sensor::publish_state(float state) { this->raw_state = state; - if (this->raw_callback_) { - this->raw_callback_->call(state); - } + this->raw_callback_.call(state); ESP_LOGV(TAG, "'%s': Received new state %f", this->name_.c_str(), state); @@ -91,10 +89,7 @@ void Sensor::publish_state(float state) { void Sensor::add_on_state_callback(std::function &&callback) { this->callback_.add(std::move(callback)); } void Sensor::add_on_raw_state_callback(std::function &&callback) { - if (!this->raw_callback_) { - this->raw_callback_ = make_unique>(); - } - this->raw_callback_->add(std::move(callback)); + this->raw_callback_.add(std::move(callback)); } void Sensor::add_filter(Filter *filter) { diff --git a/esphome/components/sensor/sensor.h b/esphome/components/sensor/sensor.h index 5d387a1ad77..a792c0d3fd6 100644 --- a/esphome/components/sensor/sensor.h +++ b/esphome/components/sensor/sensor.h @@ -125,8 +125,8 @@ class Sensor : public EntityBase, public EntityBase_DeviceClass, public EntityBa void internal_send_state_to_frontend(float state); protected: - std::unique_ptr> raw_callback_; ///< Storage for raw state callbacks (lazy allocated). - CallbackManager callback_; ///< Storage for filtered state callbacks. + LazyCallbackManager raw_callback_; ///< Storage for raw state callbacks. + LazyCallbackManager callback_; ///< Storage for filtered state callbacks. Filter *filter_list_{nullptr}; ///< Store all active filters. diff --git a/esphome/components/sgp30/sgp30.cpp b/esphome/components/sgp30/sgp30.cpp index fa548ce94eb..13263564374 100644 --- a/esphome/components/sgp30/sgp30.cpp +++ b/esphome/components/sgp30/sgp30.cpp @@ -72,10 +72,10 @@ void SGP30Component::setup() { return; } - // Hash with compilation time and serial number + // 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 mulitple sensors can be used without conflict - uint32_t hash = fnv1_hash(App.get_compilation_time_ref() + std::to_string(this->serial_number_)); + // 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(), std::to_string(this->serial_number_)); this->pref_ = global_preferences->make_preference(hash, true); if (this->store_baseline_ && this->pref_.load(&this->baselines_storage_)) { diff --git a/esphome/components/sgp4x/sensor.py b/esphome/components/sgp4x/sensor.py index 7c6fe580b28..ab78ab59d9f 100644 --- a/esphome/components/sgp4x/sensor.py +++ b/esphome/components/sgp4x/sensor.py @@ -2,11 +2,20 @@ import esphome.codegen as cg from esphome.components import i2c, sensirion_common, sensor import esphome.config_validation as cv from esphome.const import ( + CONF_ALGORITHM_TUNING, CONF_COMPENSATION, CONF_GAIN_FACTOR, + CONF_GATING_MAX_DURATION_MINUTES, CONF_ID, + CONF_INDEX_OFFSET, + CONF_LEARNING_TIME_GAIN_HOURS, + CONF_LEARNING_TIME_OFFSET_HOURS, + CONF_NOX, + CONF_STD_INITIAL, CONF_STORE_BASELINE, CONF_TEMPERATURE_SOURCE, + CONF_VOC, + CONF_VOC_BASELINE, DEVICE_CLASS_AQI, ICON_RADIATOR, STATE_CLASS_MEASUREMENT, @@ -24,16 +33,7 @@ SGP4xComponent = sgp4x_ns.class_( sensirion_common.SensirionI2CDevice, ) -CONF_ALGORITHM_TUNING = "algorithm_tuning" -CONF_GATING_MAX_DURATION_MINUTES = "gating_max_duration_minutes" CONF_HUMIDITY_SOURCE = "humidity_source" -CONF_INDEX_OFFSET = "index_offset" -CONF_LEARNING_TIME_GAIN_HOURS = "learning_time_gain_hours" -CONF_LEARNING_TIME_OFFSET_HOURS = "learning_time_offset_hours" -CONF_NOX = "nox" -CONF_STD_INITIAL = "std_initial" -CONF_VOC = "voc" -CONF_VOC_BASELINE = "voc_baseline" def validate_sensors(config): diff --git a/esphome/components/sgp4x/sgp4x.cpp b/esphome/components/sgp4x/sgp4x.cpp index a0c957d608e..7c0f51c782f 100644 --- a/esphome/components/sgp4x/sgp4x.cpp +++ b/esphome/components/sgp4x/sgp4x.cpp @@ -1,4 +1,5 @@ #include "sgp4x.h" +#include "esphome/core/application.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" #include @@ -56,10 +57,10 @@ void SGP4xComponent::setup() { ESP_LOGD(TAG, "Version 0x%0X", featureset); if (this->store_baseline_) { - // Hash with compilation time and serial number + // 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 mulitple sensors can be used without conflict - uint32_t hash = fnv1_hash(App.get_compilation_time_ref() + std::to_string(this->serial_number_)); + // 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(), std::to_string(this->serial_number_)); this->pref_ = global_preferences->make_preference(hash, true); if (this->pref_.load(&this->voc_baselines_storage_)) { diff --git a/esphome/components/sml/__init__.py b/esphome/components/sml/__init__.py index 936efd8561b..eaeddce3905 100644 --- a/esphome/components/sml/__init__.py +++ b/esphome/components/sml/__init__.py @@ -4,7 +4,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_TRIGGER_ID +from esphome.const import CONF_ID, CONF_ON_DATA, CONF_TRIGGER_ID CODEOWNERS = ["@alengwenus"] @@ -17,7 +17,6 @@ MULTI_CONF = True CONF_SML_ID = "sml_id" CONF_OBIS_CODE = "obis_code" CONF_SERVER_ID = "server_id" -CONF_ON_DATA = "on_data" sml_ns = cg.esphome_ns.namespace("sml") diff --git a/esphome/components/socket/bsd_sockets_impl.cpp b/esphome/components/socket/bsd_sockets_impl.cpp index c7cca620278..09cd81752a6 100644 --- a/esphome/components/socket/bsd_sockets_impl.cpp +++ b/esphome/components/socket/bsd_sockets_impl.cpp @@ -12,8 +12,7 @@ #include #endif -namespace esphome { -namespace socket { +namespace esphome::socket { std::string format_sockaddr(const struct sockaddr_storage &storage) { if (storage.ss_family == AF_INET) { @@ -44,11 +43,11 @@ class BSDSocketImpl : public Socket { BSDSocketImpl(int fd, bool monitor_loop = false) : fd_(fd) { #ifdef USE_SOCKET_SELECT_SUPPORT // Register new socket with the application for select() if monitoring requested - if (monitor_loop && fd_ >= 0) { + if (monitor_loop && this->fd_ >= 0) { // Only set loop_monitored_ to true if registration succeeds - loop_monitored_ = App.register_socket_fd(fd_); + this->loop_monitored_ = App.register_socket_fd(this->fd_); } else { - loop_monitored_ = false; + this->loop_monitored_ = false; } #else // Without select support, ignore monitor_loop parameter @@ -56,70 +55,69 @@ class BSDSocketImpl : public Socket { #endif } ~BSDSocketImpl() override { - if (!closed_) { - close(); // NOLINT(clang-analyzer-optin.cplusplus.VirtualCall) + if (!this->closed_) { + this->close(); // NOLINT(clang-analyzer-optin.cplusplus.VirtualCall) } } - int connect(const struct sockaddr *addr, socklen_t addrlen) override { return ::connect(fd_, addr, addrlen); } + int connect(const struct sockaddr *addr, socklen_t addrlen) override { return ::connect(this->fd_, addr, addrlen); } std::unique_ptr accept(struct sockaddr *addr, socklen_t *addrlen) override { - return accept_impl_(addr, addrlen, false); - } - std::unique_ptr accept_loop_monitored(struct sockaddr *addr, socklen_t *addrlen) override { - return accept_impl_(addr, addrlen, true); - } - - private: - std::unique_ptr accept_impl_(struct sockaddr *addr, socklen_t *addrlen, bool loop_monitored) { - int fd = ::accept(fd_, addr, addrlen); + int fd = ::accept(this->fd_, addr, addrlen); if (fd == -1) return {}; - return make_unique(fd, loop_monitored); + return make_unique(fd, false); + } + std::unique_ptr accept_loop_monitored(struct sockaddr *addr, socklen_t *addrlen) override { + int fd = ::accept(this->fd_, addr, addrlen); + if (fd == -1) + return {}; + return make_unique(fd, true); } - public: - int bind(const struct sockaddr *addr, socklen_t addrlen) override { return ::bind(fd_, addr, addrlen); } + int bind(const struct sockaddr *addr, socklen_t addrlen) override { return ::bind(this->fd_, addr, addrlen); } int close() override { - if (!closed_) { + if (!this->closed_) { #ifdef USE_SOCKET_SELECT_SUPPORT // Unregister from select() before closing if monitored - if (loop_monitored_) { - App.unregister_socket_fd(fd_); + if (this->loop_monitored_) { + App.unregister_socket_fd(this->fd_); } #endif - int ret = ::close(fd_); - closed_ = true; + int ret = ::close(this->fd_); + this->closed_ = true; return ret; } return 0; } - int shutdown(int how) override { return ::shutdown(fd_, how); } + int shutdown(int how) override { return ::shutdown(this->fd_, how); } - int getpeername(struct sockaddr *addr, socklen_t *addrlen) override { return ::getpeername(fd_, addr, addrlen); } + int getpeername(struct sockaddr *addr, socklen_t *addrlen) override { + return ::getpeername(this->fd_, addr, addrlen); + } std::string getpeername() override { struct sockaddr_storage storage; socklen_t len = sizeof(storage); - int err = this->getpeername((struct sockaddr *) &storage, &len); - if (err != 0) + if (::getpeername(this->fd_, (struct sockaddr *) &storage, &len) != 0) return {}; return format_sockaddr(storage); } - int getsockname(struct sockaddr *addr, socklen_t *addrlen) override { return ::getsockname(fd_, addr, addrlen); } + int getsockname(struct sockaddr *addr, socklen_t *addrlen) override { + return ::getsockname(this->fd_, addr, addrlen); + } std::string getsockname() override { struct sockaddr_storage storage; socklen_t len = sizeof(storage); - int err = this->getsockname((struct sockaddr *) &storage, &len); - if (err != 0) + if (::getsockname(this->fd_, (struct sockaddr *) &storage, &len) != 0) return {}; return format_sockaddr(storage); } int getsockopt(int level, int optname, void *optval, socklen_t *optlen) override { - return ::getsockopt(fd_, level, optname, optval, optlen); + return ::getsockopt(this->fd_, level, optname, optval, optlen); } int setsockopt(int level, int optname, const void *optval, socklen_t optlen) override { - return ::setsockopt(fd_, level, optname, optval, optlen); + return ::setsockopt(this->fd_, level, optname, optval, optlen); } - int listen(int backlog) override { return ::listen(fd_, backlog); } - ssize_t read(void *buf, size_t len) override { return ::read(fd_, buf, len); } + int listen(int backlog) override { return ::listen(this->fd_, backlog); } + ssize_t read(void *buf, size_t len) override { return ::read(this->fd_, buf, len); } ssize_t recvfrom(void *buf, size_t len, sockaddr *addr, socklen_t *addr_len) override { #if defined(USE_ESP32) || defined(USE_HOST) return ::recvfrom(this->fd_, buf, len, 0, addr, addr_len); @@ -129,41 +127,52 @@ class BSDSocketImpl : public Socket { } ssize_t readv(const struct iovec *iov, int iovcnt) override { #if defined(USE_ESP32) - return ::lwip_readv(fd_, iov, iovcnt); + return ::lwip_readv(this->fd_, iov, iovcnt); #else - return ::readv(fd_, iov, iovcnt); + return ::readv(this->fd_, iov, iovcnt); #endif } - ssize_t write(const void *buf, size_t len) override { return ::write(fd_, buf, len); } - ssize_t send(void *buf, size_t len, int flags) { return ::send(fd_, buf, len, flags); } + ssize_t write(const void *buf, size_t len) override { return ::write(this->fd_, buf, len); } + ssize_t send(void *buf, size_t len, int flags) { return ::send(this->fd_, buf, len, flags); } ssize_t writev(const struct iovec *iov, int iovcnt) override { #if defined(USE_ESP32) - return ::lwip_writev(fd_, iov, iovcnt); + return ::lwip_writev(this->fd_, iov, iovcnt); #else - return ::writev(fd_, iov, iovcnt); + return ::writev(this->fd_, iov, iovcnt); #endif } ssize_t sendto(const void *buf, size_t len, int flags, const struct sockaddr *to, socklen_t tolen) override { - return ::sendto(fd_, buf, len, flags, to, tolen); // NOLINT(readability-suspicious-call-argument) + return ::sendto(this->fd_, buf, len, flags, to, tolen); // NOLINT(readability-suspicious-call-argument) } int setblocking(bool blocking) override { - int fl = ::fcntl(fd_, F_GETFL, 0); + int fl = ::fcntl(this->fd_, F_GETFL, 0); if (blocking) { fl &= ~O_NONBLOCK; } else { fl |= O_NONBLOCK; } - ::fcntl(fd_, F_SETFL, fl); + ::fcntl(this->fd_, F_SETFL, fl); return 0; } - int get_fd() const override { return fd_; } + int get_fd() const override { return this->fd_; } + +#ifdef USE_SOCKET_SELECT_SUPPORT + bool ready() const override { + if (!this->loop_monitored_) + return true; + return App.is_socket_ready(this->fd_); + } +#endif protected: int fd_; - bool closed_ = false; + bool closed_{false}; +#ifdef USE_SOCKET_SELECT_SUPPORT + bool loop_monitored_{false}; +#endif }; // Helper to create a socket with optional monitoring @@ -182,7 +191,6 @@ std::unique_ptr socket_loop_monitored(int domain, int type, int protocol return create_socket(domain, type, protocol, true); } -} // namespace socket -} // namespace esphome +} // namespace esphome::socket #endif // USE_SOCKET_IMPL_BSD_SOCKETS diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 55382060586..cb5d17d5afd 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -18,8 +18,7 @@ #include // For esp_schedule() #endif -namespace esphome { -namespace socket { +namespace esphome::socket { #ifdef USE_ESP8266 // Flag to signal socket activity - checked by socket_delay() to exit early @@ -188,7 +187,7 @@ class LWIPRawImpl : public Socket { errno = EINVAL; return -1; } - return this->ip2sockaddr_(&pcb_->local_ip, pcb_->local_port, name, addrlen); + return this->ip2sockaddr_(&pcb_->remote_ip, pcb_->remote_port, name, addrlen); } std::string getpeername() override { if (pcb_ == nullptr) { @@ -711,7 +710,6 @@ std::unique_ptr socket_loop_monitored(int domain, int type, int protocol return socket(domain, type, protocol); } -} // namespace socket -} // namespace esphome +} // namespace esphome::socket #endif // USE_SOCKET_IMPL_LWIP_TCP diff --git a/esphome/components/socket/lwip_sockets_impl.cpp b/esphome/components/socket/lwip_sockets_impl.cpp index d94c1fb2ffb..23fb1a7f6f4 100644 --- a/esphome/components/socket/lwip_sockets_impl.cpp +++ b/esphome/components/socket/lwip_sockets_impl.cpp @@ -7,8 +7,7 @@ #include #include "esphome/core/application.h" -namespace esphome { -namespace socket { +namespace esphome::socket { std::string format_sockaddr(const struct sockaddr_storage &storage) { if (storage.ss_family == AF_INET) { @@ -37,11 +36,11 @@ class LwIPSocketImpl : public Socket { LwIPSocketImpl(int fd, bool monitor_loop = false) : fd_(fd) { #ifdef USE_SOCKET_SELECT_SUPPORT // Register new socket with the application for select() if monitoring requested - if (monitor_loop && fd_ >= 0) { + if (monitor_loop && this->fd_ >= 0) { // Only set loop_monitored_ to true if registration succeeds - loop_monitored_ = App.register_socket_fd(fd_); + this->loop_monitored_ = App.register_socket_fd(this->fd_); } else { - loop_monitored_ = false; + this->loop_monitored_ = false; } #else // Without select support, ignore monitor_loop parameter @@ -49,96 +48,108 @@ class LwIPSocketImpl : public Socket { #endif } ~LwIPSocketImpl() override { - if (!closed_) { - close(); // NOLINT(clang-analyzer-optin.cplusplus.VirtualCall) + if (!this->closed_) { + this->close(); // NOLINT(clang-analyzer-optin.cplusplus.VirtualCall) } } - int connect(const struct sockaddr *addr, socklen_t addrlen) override { return lwip_connect(fd_, addr, addrlen); } + int connect(const struct sockaddr *addr, socklen_t addrlen) override { + return lwip_connect(this->fd_, addr, addrlen); + } std::unique_ptr accept(struct sockaddr *addr, socklen_t *addrlen) override { - return accept_impl_(addr, addrlen, false); - } - std::unique_ptr accept_loop_monitored(struct sockaddr *addr, socklen_t *addrlen) override { - return accept_impl_(addr, addrlen, true); - } - - private: - std::unique_ptr accept_impl_(struct sockaddr *addr, socklen_t *addrlen, bool loop_monitored) { - int fd = lwip_accept(fd_, addr, addrlen); + int fd = lwip_accept(this->fd_, addr, addrlen); if (fd == -1) return {}; - return make_unique(fd, loop_monitored); + return make_unique(fd, false); + } + std::unique_ptr accept_loop_monitored(struct sockaddr *addr, socklen_t *addrlen) override { + int fd = lwip_accept(this->fd_, addr, addrlen); + if (fd == -1) + return {}; + return make_unique(fd, true); } - public: - int bind(const struct sockaddr *addr, socklen_t addrlen) override { return lwip_bind(fd_, addr, addrlen); } + int bind(const struct sockaddr *addr, socklen_t addrlen) override { return lwip_bind(this->fd_, addr, addrlen); } int close() override { - if (!closed_) { + if (!this->closed_) { #ifdef USE_SOCKET_SELECT_SUPPORT // Unregister from select() before closing if monitored - if (loop_monitored_) { - App.unregister_socket_fd(fd_); + if (this->loop_monitored_) { + App.unregister_socket_fd(this->fd_); } #endif - int ret = lwip_close(fd_); - closed_ = true; + int ret = lwip_close(this->fd_); + this->closed_ = true; return ret; } return 0; } - int shutdown(int how) override { return lwip_shutdown(fd_, how); } + int shutdown(int how) override { return lwip_shutdown(this->fd_, how); } - int getpeername(struct sockaddr *addr, socklen_t *addrlen) override { return lwip_getpeername(fd_, addr, addrlen); } + int getpeername(struct sockaddr *addr, socklen_t *addrlen) override { + return lwip_getpeername(this->fd_, addr, addrlen); + } std::string getpeername() override { struct sockaddr_storage storage; socklen_t len = sizeof(storage); - int err = this->getpeername((struct sockaddr *) &storage, &len); - if (err != 0) + if (lwip_getpeername(this->fd_, (struct sockaddr *) &storage, &len) != 0) return {}; return format_sockaddr(storage); } - int getsockname(struct sockaddr *addr, socklen_t *addrlen) override { return lwip_getsockname(fd_, addr, addrlen); } + int getsockname(struct sockaddr *addr, socklen_t *addrlen) override { + return lwip_getsockname(this->fd_, addr, addrlen); + } std::string getsockname() override { struct sockaddr_storage storage; socklen_t len = sizeof(storage); - int err = this->getsockname((struct sockaddr *) &storage, &len); - if (err != 0) + if (lwip_getsockname(this->fd_, (struct sockaddr *) &storage, &len) != 0) return {}; return format_sockaddr(storage); } int getsockopt(int level, int optname, void *optval, socklen_t *optlen) override { - return lwip_getsockopt(fd_, level, optname, optval, optlen); + return lwip_getsockopt(this->fd_, level, optname, optval, optlen); } int setsockopt(int level, int optname, const void *optval, socklen_t optlen) override { - return lwip_setsockopt(fd_, level, optname, optval, optlen); + return lwip_setsockopt(this->fd_, level, optname, optval, optlen); } - int listen(int backlog) override { return lwip_listen(fd_, backlog); } - ssize_t read(void *buf, size_t len) override { return lwip_read(fd_, buf, len); } + int listen(int backlog) override { return lwip_listen(this->fd_, backlog); } + ssize_t read(void *buf, size_t len) override { return lwip_read(this->fd_, buf, len); } ssize_t recvfrom(void *buf, size_t len, sockaddr *addr, socklen_t *addr_len) override { - return lwip_recvfrom(fd_, buf, len, 0, addr, addr_len); + return lwip_recvfrom(this->fd_, buf, len, 0, addr, addr_len); } - ssize_t readv(const struct iovec *iov, int iovcnt) override { return lwip_readv(fd_, iov, iovcnt); } - ssize_t write(const void *buf, size_t len) override { return lwip_write(fd_, buf, len); } - ssize_t send(void *buf, size_t len, int flags) { return lwip_send(fd_, buf, len, flags); } - ssize_t writev(const struct iovec *iov, int iovcnt) override { return lwip_writev(fd_, iov, iovcnt); } + ssize_t readv(const struct iovec *iov, int iovcnt) override { return lwip_readv(this->fd_, iov, iovcnt); } + ssize_t write(const void *buf, size_t len) override { return lwip_write(this->fd_, buf, len); } + ssize_t send(void *buf, size_t len, int flags) { return lwip_send(this->fd_, buf, len, flags); } + ssize_t writev(const struct iovec *iov, int iovcnt) override { return lwip_writev(this->fd_, iov, iovcnt); } ssize_t sendto(const void *buf, size_t len, int flags, const struct sockaddr *to, socklen_t tolen) override { - return lwip_sendto(fd_, buf, len, flags, to, tolen); + return lwip_sendto(this->fd_, buf, len, flags, to, tolen); } int setblocking(bool blocking) override { - int fl = lwip_fcntl(fd_, F_GETFL, 0); + int fl = lwip_fcntl(this->fd_, F_GETFL, 0); if (blocking) { fl &= ~O_NONBLOCK; } else { fl |= O_NONBLOCK; } - lwip_fcntl(fd_, F_SETFL, fl); + lwip_fcntl(this->fd_, F_SETFL, fl); return 0; } - int get_fd() const override { return fd_; } + int get_fd() const override { return this->fd_; } + +#ifdef USE_SOCKET_SELECT_SUPPORT + bool ready() const override { + if (!this->loop_monitored_) + return true; + return App.is_socket_ready(this->fd_); + } +#endif protected: int fd_; - bool closed_ = false; + bool closed_{false}; +#ifdef USE_SOCKET_SELECT_SUPPORT + bool loop_monitored_{false}; +#endif }; // Helper to create a socket with optional monitoring @@ -157,7 +168,6 @@ std::unique_ptr socket_loop_monitored(int domain, int type, int protocol return create_socket(domain, type, protocol, true); } -} // namespace socket -} // namespace esphome +} // namespace esphome::socket #endif // USE_SOCKET_IMPL_LWIP_SOCKETS diff --git a/esphome/components/socket/socket.cpp b/esphome/components/socket/socket.cpp index cc9232d21a5..ffe0233abca 100644 --- a/esphome/components/socket/socket.cpp +++ b/esphome/components/socket/socket.cpp @@ -6,33 +6,10 @@ #include "esphome/core/log.h" #include "esphome/core/application.h" -namespace esphome { -namespace socket { +namespace esphome::socket { Socket::~Socket() {} -bool Socket::ready() const { -#ifdef USE_SOCKET_SELECT_SUPPORT - if (!loop_monitored_) { - // Non-monitored sockets always return true (assume data may be available) - return true; - } - - // For loop-monitored sockets, check with the Application's select() results - int fd = this->get_fd(); - if (fd < 0) { - // No valid file descriptor, assume ready (fallback behavior) - return true; - } - - return App.is_socket_ready(fd); -#else - // Without select() support, we can't monitor sockets in the loop - // Always return true (assume data may be available) - return true; -#endif -} - std::unique_ptr socket_ip(int type, int protocol) { #if USE_NETWORK_IPV6 return socket(AF_INET6, type, protocol); @@ -113,6 +90,5 @@ socklen_t set_sockaddr_any(struct sockaddr *addr, socklen_t addrlen, uint16_t po return sizeof(sockaddr_in); #endif /* USE_NETWORK_IPV6 */ } -} // namespace socket -} // namespace esphome +} // namespace esphome::socket #endif diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index 8936b2cd10c..75eb07de4ae 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -6,8 +6,7 @@ #include "headers.h" #if defined(USE_SOCKET_IMPL_LWIP_TCP) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || defined(USE_SOCKET_IMPL_BSD_SOCKETS) -namespace esphome { -namespace socket { +namespace esphome::socket { class Socket { public: @@ -54,12 +53,7 @@ class Socket { /// Check if socket has data ready to read /// For loop-monitored sockets, checks with the Application's select() results /// For non-monitored sockets, always returns true (assumes data may be available) - bool ready() const; - - protected: -#ifdef USE_SOCKET_SELECT_SUPPORT - bool loop_monitored_{false}; ///< Whether this socket is monitored by the event loop -#endif + virtual bool ready() const { return true; } }; /// Create a socket of the given domain, type and protocol. @@ -91,6 +85,5 @@ void socket_delay(uint32_t ms); void socket_wake(); #endif -} // namespace socket -} // namespace esphome +} // namespace esphome::socket #endif diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index 062bff92f8b..4ca57f2c4ac 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -6,7 +6,7 @@ from pathlib import Path from esphome import automation, external_files import esphome.codegen as cg -from esphome.components import audio, esp32, media_player, network, psram, speaker +from esphome.components import audio, esp32, media_player, network, ota, psram, speaker import esphome.config_validation as cv from esphome.const import ( CONF_BUFFER_SIZE, @@ -342,7 +342,7 @@ async def to_code(config): var = await media_player.new_media_player(config) await cg.register_component(var, config) - cg.add_define("USE_OTA_STATE_CALLBACK") + ota.request_ota_state_listeners() cg.add(var.set_buffer_size(config[CONF_BUFFER_SIZE])) diff --git a/esphome/components/speaker/media_player/speaker_media_player.cpp b/esphome/components/speaker/media_player/speaker_media_player.cpp index b45a78010a0..5722aab1952 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.cpp +++ b/esphome/components/speaker/media_player/speaker_media_player.cpp @@ -66,25 +66,8 @@ void SpeakerMediaPlayer::setup() { this->set_mute_state_(false); } -#ifdef USE_OTA - ota::get_global_ota_callback()->add_on_state_callback( - [this](ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) { - if (state == ota::OTA_STARTED) { - if (this->media_pipeline_ != nullptr) { - this->media_pipeline_->suspend_tasks(); - } - if (this->announcement_pipeline_ != nullptr) { - this->announcement_pipeline_->suspend_tasks(); - } - } else if (state == ota::OTA_ERROR) { - if (this->media_pipeline_ != nullptr) { - this->media_pipeline_->resume_tasks(); - } - if (this->announcement_pipeline_ != nullptr) { - this->announcement_pipeline_->resume_tasks(); - } - } - }); +#ifdef USE_OTA_STATE_LISTENER + ota::get_global_ota_callback()->add_global_state_listener(this); #endif this->announcement_pipeline_ = @@ -300,6 +283,27 @@ void SpeakerMediaPlayer::watch_media_commands_() { } } +#ifdef USE_OTA_STATE_LISTENER +void SpeakerMediaPlayer::on_ota_global_state(ota::OTAState state, float progress, uint8_t error, + ota::OTAComponent *comp) { + if (state == ota::OTA_STARTED) { + if (this->media_pipeline_ != nullptr) { + this->media_pipeline_->suspend_tasks(); + } + if (this->announcement_pipeline_ != nullptr) { + this->announcement_pipeline_->suspend_tasks(); + } + } else if (state == ota::OTA_ERROR) { + if (this->media_pipeline_ != nullptr) { + this->media_pipeline_->resume_tasks(); + } + if (this->announcement_pipeline_ != nullptr) { + this->announcement_pipeline_->resume_tasks(); + } + } +} +#endif + void SpeakerMediaPlayer::loop() { this->watch_media_commands_(); diff --git a/esphome/components/speaker/media_player/speaker_media_player.h b/esphome/components/speaker/media_player/speaker_media_player.h index 967772d1a5c..f1c564b63d6 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.h +++ b/esphome/components/speaker/media_player/speaker_media_player.h @@ -5,14 +5,18 @@ #include "audio_pipeline.h" #include "esphome/components/audio/audio.h" - #include "esphome/components/media_player/media_player.h" #include "esphome/components/speaker/speaker.h" #include "esphome/core/automation.h" #include "esphome/core/component.h" +#include "esphome/core/defines.h" #include "esphome/core/preferences.h" +#ifdef USE_OTA_STATE_LISTENER +#include "esphome/components/ota/ota_backend.h" +#endif + #include #include #include @@ -39,12 +43,22 @@ struct VolumeRestoreState { bool is_muted; }; -class SpeakerMediaPlayer : public Component, public media_player::MediaPlayer { +class SpeakerMediaPlayer : public Component, + public media_player::MediaPlayer +#ifdef USE_OTA_STATE_LISTENER + , + public ota::OTAGlobalStateListener +#endif +{ public: float get_setup_priority() const override { return esphome::setup_priority::PROCESSOR; } void setup() override; void loop() override; +#ifdef USE_OTA_STATE_LISTENER + void on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) override; +#endif + // MediaPlayer implementations media_player::MediaPlayerTraits get_traits() override; bool is_muted() const override { return this->is_muted_; } diff --git a/esphome/components/spi/__init__.py b/esphome/components/spi/__init__.py index 88bb3406e19..ad279dcf1ae 100644 --- a/esphome/components/spi/__init__.py +++ b/esphome/components/spi/__init__.py @@ -272,10 +272,11 @@ 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): - if CORE.using_esp_idf: + platform = get_target_platform() + if platform == PLATFORM_ESP32: + # ESP32 uses ESP-IDF SPI driver for both Arduino and IDF frameworks return ["SPI2_HOST", "SPI3_HOST"][index] # Arduino code follows - platform = get_target_platform() if platform == PLATFORM_RP2040: return ["&SPI", "&SPI1"][index] if index == 0: @@ -356,7 +357,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(configs): cg.add_define("USE_SPI") cg.add_global(spi_ns.using) - if CORE.using_arduino: + if CORE.using_arduino and not CORE.is_esp32: cg.add_library("SPI", None) for spi in configs: var = cg.new_Pvariable(spi[CONF_ID]) @@ -447,13 +448,15 @@ def final_validate_device_schema(name: str, *, require_mosi: bool, require_miso: FILTER_SOURCE_FILES = filter_source_files_from_platform( { "spi_arduino.cpp": { - PlatformFramework.ESP32_ARDUINO, PlatformFramework.ESP8266_ARDUINO, PlatformFramework.RP2040_ARDUINO, PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, }, - "spi_esp_idf.cpp": {PlatformFramework.ESP32_IDF}, + "spi_esp_idf.cpp": { + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP32_IDF, + }, } ) diff --git a/esphome/components/spi/spi.cpp b/esphome/components/spi/spi.cpp index 00e9845a03e..c4876d1a747 100644 --- a/esphome/components/spi/spi.cpp +++ b/esphome/components/spi/spi.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/application.h" -namespace esphome { -namespace spi { +namespace esphome::spi { const char *const TAG = "spi"; @@ -119,5 +118,4 @@ uint16_t SPIDelegateBitBash::transfer_(uint16_t data, size_t num_bits) { return out_data; } -} // namespace spi -} // namespace esphome +} // namespace esphome::spi diff --git a/esphome/components/spi/spi.h b/esphome/components/spi/spi.h index 5bc80350da6..256cbcc65fd 100644 --- a/esphome/components/spi/spi.h +++ b/esphome/components/spi/spi.h @@ -7,7 +7,13 @@ #include #include -#ifdef USE_ARDUINO +#ifdef USE_ESP32 + +#include "driver/spi_master.h" + +using SPIInterface = spi_host_device_t; + +#elif defined(USE_ARDUINO) #include @@ -17,26 +23,16 @@ using SPIInterface = SPIClassRP2040 *; using SPIInterface = SPIClass *; #endif -#endif +#elif defined(CLANG_TIDY) -#ifdef USE_ESP_IDF +using SPIInterface = void *; // Stub for platforms without SPI (e.g., Zephyr) -#include "driver/spi_master.h" - -using SPIInterface = spi_host_device_t; - -#endif // USE_ESP_IDF - -#ifdef USE_ZEPHYR -// TODO supprse clang-tidy. Remove after SPI driver for nrf52 is added. -using SPIInterface = void *; -#endif +#endif // USE_ESP32 / USE_ARDUINO /** * Implementation of SPI Controller mode. */ -namespace esphome { -namespace spi { +namespace esphome::spi { /// The bit-order for SPI devices. This defines how the data read from and written to the device is interpreted. enum SPIBitOrder { @@ -509,5 +505,4 @@ class SPIDevice : public SPIClient { template void transfer_array(std::array &data) { this->transfer_array(data.data(), N); } }; -} // namespace spi -} // namespace esphome +} // namespace esphome::spi diff --git a/esphome/components/spi/spi_arduino.cpp b/esphome/components/spi/spi_arduino.cpp index a34e3c3c820..4267fe63ced 100644 --- a/esphome/components/spi/spi_arduino.cpp +++ b/esphome/components/spi/spi_arduino.cpp @@ -1,9 +1,8 @@ #include "spi.h" #include -namespace esphome { -namespace spi { -#ifdef USE_ARDUINO +namespace esphome::spi { +#if defined(USE_ARDUINO) && !defined(USE_ESP32) static const char *const TAG = "spi-esp-arduino"; class SPIDelegateHw : public SPIDelegate { @@ -101,6 +100,5 @@ SPIBus *SPIComponent::get_bus(SPIInterface interface, GPIOPin *clk, GPIOPin *sdo return new SPIBusHw(clk, sdo, sdi, interface); } -#endif // USE_ARDUINO -} // namespace spi -} // namespace esphome +#endif // USE_ARDUINO && !USE_ESP32 +} // namespace esphome::spi diff --git a/esphome/components/spi/spi_esp_idf.cpp b/esphome/components/spi/spi_esp_idf.cpp index 549f516eb18..a1837fa58db 100644 --- a/esphome/components/spi/spi_esp_idf.cpp +++ b/esphome/components/spi/spi_esp_idf.cpp @@ -1,10 +1,9 @@ #include "spi.h" #include -namespace esphome { -namespace spi { +namespace esphome::spi { -#ifdef USE_ESP_IDF +#ifdef USE_ESP32 static const char *const TAG = "spi-esp-idf"; static const size_t MAX_TRANSFER_SIZE = 4092; // dictated by ESP-IDF API. @@ -266,6 +265,5 @@ SPIBus *SPIComponent::get_bus(SPIInterface interface, GPIOPin *clk, GPIOPin *sdo return new SPIBusHw(clk, sdo, sdi, interface, data_pins); } -#endif -} // namespace spi -} // namespace esphome +#endif // USE_ESP32 +} // namespace esphome::spi diff --git a/esphome/components/sprinkler/automation.h b/esphome/components/sprinkler/automation.h index d6c877ae903..b3f030805dd 100644 --- a/esphome/components/sprinkler/automation.h +++ b/esphome/components/sprinkler/automation.h @@ -4,8 +4,7 @@ #include "esphome/core/component.h" #include "esphome/components/sprinkler/sprinkler.h" -namespace esphome { -namespace sprinkler { +namespace esphome::sprinkler { template class SetDividerAction : public Action { public: @@ -181,5 +180,4 @@ template class ResumeOrStartAction : public Action { Sprinkler *sprinkler_; }; -} // namespace sprinkler -} // namespace esphome +} // namespace esphome::sprinkler diff --git a/esphome/components/sprinkler/sprinkler.cpp b/esphome/components/sprinkler/sprinkler.cpp index 8edb240a415..69452f2e9e3 100644 --- a/esphome/components/sprinkler/sprinkler.cpp +++ b/esphome/components/sprinkler/sprinkler.cpp @@ -7,8 +7,7 @@ #include #include -namespace esphome { -namespace sprinkler { +namespace esphome::sprinkler { static const char *const TAG = "sprinkler"; @@ -411,7 +410,8 @@ void Sprinkler::loop() { for (auto &vo : this->valve_op_) { vo.loop(); } - if (this->prev_req_.has_request() && this->prev_req_.valve_operator()->state() == IDLE) { + if (this->prev_req_.has_request() && this->prev_req_.has_valve_operator() && + this->prev_req_.valve_operator()->state() == IDLE) { this->prev_req_.reset(); } } @@ -808,11 +808,11 @@ bool Sprinkler::standby() { void Sprinkler::start_from_queue() { if (this->standby()) { - ESP_LOGD(TAG, "start_from_queue called but standby is enabled; no action taken"); + this->log_standby_warning_(LOG_STR("start_from_queue")); return; } if (this->multiplier() == 0) { - ESP_LOGD(TAG, "start_from_queue called but multiplier is set to zero; no action taken"); + this->log_multiplier_zero_warning_(LOG_STR("start_from_queue")); return; } if (this->queued_valves_.empty()) { @@ -832,11 +832,11 @@ void Sprinkler::start_from_queue() { void Sprinkler::start_full_cycle() { if (this->standby()) { - ESP_LOGD(TAG, "start_full_cycle called but standby is enabled; no action taken"); + this->log_standby_warning_(LOG_STR("start_full_cycle")); return; } if (this->multiplier() == 0) { - ESP_LOGD(TAG, "start_full_cycle called but multiplier is set to zero; no action taken"); + this->log_multiplier_zero_warning_(LOG_STR("start_full_cycle")); return; } if (this->auto_advance() && this->active_valve().has_value()) { @@ -855,11 +855,11 @@ void Sprinkler::start_full_cycle() { void Sprinkler::start_single_valve(const optional valve_number, optional run_duration) { if (this->standby()) { - ESP_LOGD(TAG, "start_single_valve called but standby is enabled; no action taken"); + this->log_standby_warning_(LOG_STR("start_single_valve")); return; } if (this->multiplier() == 0) { - ESP_LOGD(TAG, "start_single_valve called but multiplier is set to zero; no action taken"); + this->log_multiplier_zero_warning_(LOG_STR("start_single_valve")); return; } if (!valve_number.has_value() || (valve_number == this->active_valve())) { @@ -891,6 +891,11 @@ void Sprinkler::clear_queued_valves() { } void Sprinkler::next_valve() { + if (this->standby()) { + this->log_standby_warning_(LOG_STR("next_valve")); + return; + } + if (this->state_ == IDLE) { this->reset_cycle_states_(); // just in case auto-advance is switched on later } @@ -914,6 +919,11 @@ void Sprinkler::next_valve() { } void Sprinkler::previous_valve() { + if (this->standby()) { + this->log_standby_warning_(LOG_STR("previous_valve")); + return; + } + if (this->state_ == IDLE) { this->reset_cycle_states_(); // just in case auto-advance is switched on later } @@ -964,7 +974,7 @@ void Sprinkler::pause() { void Sprinkler::resume() { if (this->standby()) { - ESP_LOGD(TAG, "resume called but standby is enabled; no action taken"); + this->log_standby_warning_(LOG_STR("resume")); return; } @@ -1009,7 +1019,7 @@ optional Sprinkler::active_valve_request_is_from } optional Sprinkler::active_valve() { - if (!this->valve_overlap_ && this->prev_req_.has_request() && + if (!this->valve_overlap_ && this->prev_req_.has_request() && this->prev_req_.has_valve_operator() && (this->prev_req_.valve_operator()->state() == STARTING || this->prev_req_.valve_operator()->state() == ACTIVE)) { return this->prev_req_.valve_as_opt(); } @@ -1029,9 +1039,7 @@ 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 >= 0) && (valve_number < this->number_of_valves())); -} +bool Sprinkler::is_a_valid_valve(const size_t valve_number) { return (valve_number < this->number_of_valves()); } bool Sprinkler::pump_in_use(SprinklerSwitch *pump_switch) { if (pump_switch == nullptr) { @@ -1062,8 +1070,12 @@ bool Sprinkler::pump_in_use(SprinklerSwitch *pump_switch) { this->active_req_.has_request() && (this->state_ != STOPPING)) { // ...the controller is configured to keep the pump on during a valve open delay, so just return // whether or not the next valve shares the same pump - return (pump_switch->off_switch() == this->valve_pump_switch(this->active_req_.valve())->off_switch()) && - (pump_switch->on_switch() == this->valve_pump_switch(this->active_req_.valve())->on_switch()); + auto *valve_pump = this->valve_pump_switch(this->active_req_.valve()); + if (valve_pump == nullptr) { + return false; // valve has no pump, so this pump isn't in use by it + } + return (pump_switch->off_switch() == valve_pump->off_switch()) && + (pump_switch->on_switch() == valve_pump->on_switch()); } return false; } @@ -1426,8 +1438,8 @@ void Sprinkler::start_valve_(SprinklerValveRunRequest *req) { if (vo.state() == IDLE) { auto run_duration = req->run_duration() ? req->run_duration() : this->valve_run_duration_adjusted(req->valve()); ESP_LOGD(TAG, "%s is starting valve %zu for %" PRIu32 " seconds, cycle %" PRIu32 " of %" PRIu32, - this->req_as_str_(req->request_is_from()).c_str(), req->valve(), run_duration, this->repeat_count_ + 1, - this->repeat().value_or(0) + 1); + LOG_STR_ARG(this->req_as_str_(req->request_is_from())), req->valve(), run_duration, + this->repeat_count_ + 1, this->repeat().value_or(0) + 1); req->set_valve_operator(&vo); vo.set_controller(this); vo.set_valve(&this->valve_[req->valve()]); @@ -1488,7 +1500,7 @@ void Sprinkler::fsm_kick_() { } void Sprinkler::fsm_transition_() { - ESP_LOGVV(TAG, "fsm_transition_ called; state is %s", this->state_as_str_(this->state_).c_str()); + ESP_LOGVV(TAG, "fsm_transition_ called; state is %s", LOG_STR_ARG(this->state_as_str_(this->state_))); switch (this->state_) { case IDLE: // the system was off -> start it up // advances to ACTIVE @@ -1502,8 +1514,11 @@ void Sprinkler::fsm_transition_() { case STARTING: { // follows valve open delay interval - this->set_timer_duration_(sprinkler::TIMER_SM, - this->active_req_.run_duration() - this->switching_delay_.value_or(0)); + uint32_t timer_duration = this->active_req_.run_duration(); + if (timer_duration > this->switching_delay_.value_or(0)) { + timer_duration -= this->switching_delay_.value_or(0); + } + this->set_timer_duration_(sprinkler::TIMER_SM, timer_duration); this->start_timer_(sprinkler::TIMER_SM); this->start_valve_(&this->active_req_); this->state_ = ACTIVE; @@ -1531,7 +1546,7 @@ void Sprinkler::fsm_transition_() { this->set_timer_duration_(sprinkler::TIMER_SM, this->manual_selection_delay_.value_or(1)); this->start_timer_(sprinkler::TIMER_SM); } - ESP_LOGVV(TAG, "fsm_transition_ complete; new state is %s", this->state_as_str_(this->state_).c_str()); + ESP_LOGVV(TAG, "fsm_transition_ complete; new state is %s", LOG_STR_ARG(this->state_as_str_(this->state_))); } void Sprinkler::fsm_transition_from_shutdown_() { @@ -1543,8 +1558,11 @@ void Sprinkler::fsm_transition_from_shutdown_() { this->active_req_.set_run_duration(this->next_req_.run_duration()); this->next_req_.reset(); - this->set_timer_duration_(sprinkler::TIMER_SM, - this->active_req_.run_duration() - this->switching_delay_.value_or(0)); + uint32_t timer_duration = this->active_req_.run_duration(); + if (timer_duration > this->switching_delay_.value_or(0)) { + timer_duration -= this->switching_delay_.value_or(0); + } + this->set_timer_duration_(sprinkler::TIMER_SM, timer_duration); this->start_timer_(sprinkler::TIMER_SM); this->start_valve_(&this->active_req_); this->state_ = ACTIVE; @@ -1571,8 +1589,9 @@ void Sprinkler::fsm_transition_from_valve_run_() { this->load_next_valve_run_request_(this->active_req_.valve()); if (this->next_req_.has_request()) { // there is another valve to run... - bool same_pump = - this->valve_pump_switch(this->active_req_.valve()) == this->valve_pump_switch(this->next_req_.valve()); + auto *active_pump = this->valve_pump_switch(this->active_req_.valve()); + auto *next_pump = this->valve_pump_switch(this->next_req_.valve()); + bool same_pump = (active_pump != nullptr) && (next_pump != nullptr) && (active_pump == next_pump); this->active_req_.set_valve(this->next_req_.valve()); this->active_req_.set_request_from(this->next_req_.request_is_from()); @@ -1581,8 +1600,11 @@ void Sprinkler::fsm_transition_from_valve_run_() { // this->state_ = ACTIVE; // state isn't changing if (this->valve_overlap_ || !this->switching_delay_.has_value()) { - this->set_timer_duration_(sprinkler::TIMER_SM, - this->active_req_.run_duration() - this->switching_delay_.value_or(0)); + uint32_t timer_duration = this->active_req_.run_duration(); + if (timer_duration > this->switching_delay_.value_or(0)) { + timer_duration -= this->switching_delay_.value_or(0); + } + this->set_timer_duration_(sprinkler::TIMER_SM, timer_duration); this->start_timer_(sprinkler::TIMER_SM); this->start_valve_(&this->active_req_); } else { @@ -1605,41 +1627,49 @@ void Sprinkler::fsm_transition_to_shutdown_() { this->start_timer_(sprinkler::TIMER_SM); } -std::string Sprinkler::req_as_str_(SprinklerValveRunRequestOrigin origin) { +void Sprinkler::log_standby_warning_(const LogString *method_name) { + ESP_LOGW(TAG, "%s called but standby is enabled; no action taken", LOG_STR_ARG(method_name)); +} + +void Sprinkler::log_multiplier_zero_warning_(const LogString *method_name) { + ESP_LOGW(TAG, "%s called but multiplier is set to zero; no action taken", LOG_STR_ARG(method_name)); +} + +const LogString *Sprinkler::req_as_str_(SprinklerValveRunRequestOrigin origin) { switch (origin) { case USER: - return "USER"; + return LOG_STR("USER"); case CYCLE: - return "CYCLE"; + return LOG_STR("CYCLE"); case QUEUE: - return "QUEUE"; + return LOG_STR("QUEUE"); default: - return "UNKNOWN"; + return LOG_STR("UNKNOWN"); } } -std::string Sprinkler::state_as_str_(SprinklerState state) { +const LogString *Sprinkler::state_as_str_(SprinklerState state) { switch (state) { case IDLE: - return "IDLE"; + return LOG_STR("IDLE"); case STARTING: - return "STARTING"; + return LOG_STR("STARTING"); case ACTIVE: - return "ACTIVE"; + return LOG_STR("ACTIVE"); case STOPPING: - return "STOPPING"; + return LOG_STR("STOPPING"); case BYPASS: - return "BYPASS"; + return LOG_STR("BYPASS"); default: - return "UNKNOWN"; + return LOG_STR("UNKNOWN"); } } @@ -1737,5 +1767,4 @@ void Sprinkler::dump_config() { } } -} // namespace sprinkler -} // namespace esphome +} // namespace esphome::sprinkler diff --git a/esphome/components/sprinkler/sprinkler.h b/esphome/components/sprinkler/sprinkler.h index c4a8b8aeb88..7aa33c2df97 100644 --- a/esphome/components/sprinkler/sprinkler.h +++ b/esphome/components/sprinkler/sprinkler.h @@ -8,8 +8,7 @@ #include -namespace esphome { -namespace sprinkler { +namespace esphome::sprinkler { const std::string MIN_STR = "min"; @@ -490,11 +489,17 @@ class Sprinkler : public Component { /// starts up the system from IDLE state void fsm_transition_to_shutdown_(); + /// log error message when a method is called but standby is enabled + void log_standby_warning_(const LogString *method_name); + + /// log error message when a method is called but multiplier is zero + void log_multiplier_zero_warning_(const LogString *method_name); + /// return the specified SprinklerValveRunRequestOrigin as a string - std::string req_as_str_(SprinklerValveRunRequestOrigin origin); + const LogString *req_as_str_(SprinklerValveRunRequestOrigin origin); /// return the specified SprinklerState state as a string - std::string state_as_str_(SprinklerState state); + const LogString *state_as_str_(SprinklerState state); /// Start/cancel/get status of valve timers void start_timer_(SprinklerTimerIndex timer_index); @@ -607,5 +612,4 @@ class Sprinkler : public Component { std::unique_ptr> sprinkler_standby_turn_on_automation_; }; -} // namespace sprinkler -} // namespace esphome +} // namespace esphome::sprinkler diff --git a/esphome/components/switch/switch.h b/esphome/components/switch/switch.h index 6371e35292c..9319adf9ed7 100644 --- a/esphome/components/switch/switch.h +++ b/esphome/components/switch/switch.h @@ -134,8 +134,8 @@ class Switch : public EntityBase, public EntityBase_DeviceClass { // Pointer first (4 bytes) ESPPreferenceObject rtc_; - // CallbackManager (12 bytes on 32-bit - contains vector) - CallbackManager state_callback_{}; + // LazyCallbackManager (4 bytes on 32-bit - nullptr when empty) + LazyCallbackManager state_callback_{}; // Small types grouped together Deduplicator publish_dedup_; // 2 bytes (bool has_value_ + bool last_value_) diff --git a/esphome/components/sx126x/__init__.py b/esphome/components/sx126x/__init__.py index 1eb83b7a334..ed878ed0d43 100644 --- a/esphome/components/sx126x/__init__.py +++ b/esphome/components/sx126x/__init__.py @@ -1,6 +1,7 @@ from esphome import automation, pins import esphome.codegen as cg from esphome.components import spi +from esphome.components.const import CONF_CRC_ENABLE, CONF_ON_PACKET import esphome.config_validation as cv from esphome.const import CONF_BUSY_PIN, CONF_DATA, CONF_FREQUENCY, CONF_ID from esphome.core import ID, TimePeriod @@ -14,7 +15,6 @@ CONF_SX126X_ID = "sx126x_id" CONF_BANDWIDTH = "bandwidth" CONF_BITRATE = "bitrate" CONF_CODING_RATE = "coding_rate" -CONF_CRC_ENABLE = "crc_enable" CONF_CRC_INVERTED = "crc_inverted" CONF_CRC_SIZE = "crc_size" CONF_CRC_POLYNOMIAL = "crc_polynomial" @@ -23,7 +23,6 @@ CONF_DEVIATION = "deviation" CONF_DIO1_PIN = "dio1_pin" CONF_HW_VERSION = "hw_version" CONF_MODULATION = "modulation" -CONF_ON_PACKET = "on_packet" CONF_PA_POWER = "pa_power" CONF_PA_RAMP = "pa_ramp" CONF_PAYLOAD_LENGTH = "payload_length" @@ -200,9 +199,13 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_CRC_INITIAL, default=0x1D0F): cv.All( cv.hex_int, cv.Range(min=0, max=0xFFFF) ), - cv.Optional(CONF_DEVIATION, default=5000): cv.int_range(min=0, max=100000), + cv.Optional(CONF_DEVIATION, default="5kHz"): cv.All( + cv.frequency, cv.float_range(min=0, max=100000) + ), cv.Required(CONF_DIO1_PIN): pins.gpio_input_pin_schema, - cv.Required(CONF_FREQUENCY): cv.int_range(min=137000000, max=1020000000), + cv.Required(CONF_FREQUENCY): cv.All( + cv.frequency, cv.float_range(min=137.0e6, max=1020.0e6) + ), cv.Required(CONF_HW_VERSION): cv.one_of( "sx1261", "sx1262", "sx1268", "llcc68", lower=True ), diff --git a/esphome/components/sx126x/packet_transport/sx126x_transport.cpp b/esphome/components/sx126x/packet_transport/sx126x_transport.cpp index 2cfc4b700ef..59d80bd2974 100644 --- a/esphome/components/sx126x/packet_transport/sx126x_transport.cpp +++ b/esphome/components/sx126x/packet_transport/sx126x_transport.cpp @@ -12,12 +12,6 @@ void SX126xTransport::setup() { this->parent_->register_listener(this); } -void SX126xTransport::update() { - PacketTransport::update(); - this->updated_ = true; - this->resend_data_ = true; -} - void SX126xTransport::send_packet(const std::vector &buf) const { this->parent_->transmit_packet(buf); } void SX126xTransport::on_packet(const std::vector &packet, float rssi, float snr) { this->process_(packet); } diff --git a/esphome/components/sx126x/packet_transport/sx126x_transport.h b/esphome/components/sx126x/packet_transport/sx126x_transport.h index 755d30417d7..640c6a76f91 100644 --- a/esphome/components/sx126x/packet_transport/sx126x_transport.h +++ b/esphome/components/sx126x/packet_transport/sx126x_transport.h @@ -11,7 +11,6 @@ namespace sx126x { class SX126xTransport : public packet_transport::PacketTransport, public Parented, public SX126xListener { public: void setup() override; - void update() override; void on_packet(const std::vector &packet, float rssi, float snr) override; float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } diff --git a/esphome/components/sx127x/__init__.py b/esphome/components/sx127x/__init__.py index 77cb61f7f81..f3a9cca93f4 100644 --- a/esphome/components/sx127x/__init__.py +++ b/esphome/components/sx127x/__init__.py @@ -1,6 +1,7 @@ from esphome import automation, pins import esphome.codegen as cg from esphome.components import spi +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 @@ -16,11 +17,9 @@ CONF_BANDWIDTH = "bandwidth" CONF_BITRATE = "bitrate" CONF_BITSYNC = "bitsync" CONF_CODING_RATE = "coding_rate" -CONF_CRC_ENABLE = "crc_enable" CONF_DEVIATION = "deviation" CONF_DIO0_PIN = "dio0_pin" CONF_MODULATION = "modulation" -CONF_ON_PACKET = "on_packet" CONF_PA_PIN = "pa_pin" CONF_PA_POWER = "pa_power" CONF_PA_RAMP = "pa_ramp" @@ -197,9 +196,13 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_BITSYNC): cv.boolean, cv.Optional(CONF_CODING_RATE, default="CR_4_5"): cv.enum(CODING_RATE), cv.Optional(CONF_CRC_ENABLE, default=False): cv.boolean, - cv.Optional(CONF_DEVIATION, default=5000): cv.int_range(min=0, max=100000), + cv.Optional(CONF_DEVIATION, default="5kHz"): cv.All( + cv.frequency, cv.float_range(min=0, max=100000) + ), cv.Optional(CONF_DIO0_PIN): pins.internal_gpio_input_pin_schema, - cv.Required(CONF_FREQUENCY): cv.int_range(min=137000000, max=1020000000), + cv.Required(CONF_FREQUENCY): cv.All( + cv.frequency, cv.float_range(min=137.0e6, max=1020.0e6) + ), cv.Required(CONF_MODULATION): cv.enum(MOD), cv.Optional(CONF_ON_PACKET): automation.validate_automation(single=True), cv.Optional(CONF_PA_PIN, default="BOOST"): cv.enum(PA_PIN), diff --git a/esphome/components/sx127x/packet_transport/sx127x_transport.cpp b/esphome/components/sx127x/packet_transport/sx127x_transport.cpp index b1d014bb96f..893726e816e 100644 --- a/esphome/components/sx127x/packet_transport/sx127x_transport.cpp +++ b/esphome/components/sx127x/packet_transport/sx127x_transport.cpp @@ -12,12 +12,6 @@ void SX127xTransport::setup() { this->parent_->register_listener(this); } -void SX127xTransport::update() { - PacketTransport::update(); - this->updated_ = true; - this->resend_data_ = true; -} - void SX127xTransport::send_packet(const std::vector &buf) const { this->parent_->transmit_packet(buf); } void SX127xTransport::on_packet(const std::vector &packet, float rssi, float snr) { this->process_(packet); } diff --git a/esphome/components/sx127x/packet_transport/sx127x_transport.h b/esphome/components/sx127x/packet_transport/sx127x_transport.h index e27b7f8d579..62083729714 100644 --- a/esphome/components/sx127x/packet_transport/sx127x_transport.h +++ b/esphome/components/sx127x/packet_transport/sx127x_transport.h @@ -11,7 +11,6 @@ namespace sx127x { class SX127xTransport : public packet_transport::PacketTransport, public Parented, public SX127xListener { public: void setup() override; - void update() override; void on_packet(const std::vector &packet, float rssi, float snr) override; float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } diff --git a/esphome/components/syslog/esphome_syslog.cpp b/esphome/components/syslog/esphome_syslog.cpp index f5c20c891e7..83ad6b2720f 100644 --- a/esphome/components/syslog/esphome_syslog.cpp +++ b/esphome/components/syslog/esphome_syslog.cpp @@ -4,8 +4,7 @@ #include "esphome/core/application.h" #include "esphome/core/time.h" -namespace esphome { -namespace syslog { +namespace esphome::syslog { // Map log levels to syslog severity using an array, indexed by ESPHome log level (1-7) constexpr int LOG_LEVEL_TO_SYSLOG_SEVERITY[] = { @@ -34,7 +33,7 @@ void Syslog::log_(const int level, const char *tag, const char *message, size_t severity = LOG_LEVEL_TO_SYSLOG_SEVERITY[level]; } int pri = this->facility_ * 8 + severity; - auto timestamp = this->time_->now().strftime("%b %e %H:%M:%S"); + size_t len = message_len; // remove color formatting if (this->strip_ && message[0] == 0x1B && len > 11) { @@ -42,9 +41,40 @@ void Syslog::log_(const int level, const char *tag, const char *message, size_t len -= 11; } - auto data = str_sprintf("<%d>%s %s %s: %.*s", pri, timestamp.c_str(), App.get_name().c_str(), tag, len, message); - this->parent_->send_packet((const uint8_t *) data.data(), data.size()); + // Build syslog packet on stack (508 bytes chosen as practical limit for syslog over UDP) + char packet[508]; + size_t offset = 0; + size_t remaining = sizeof(packet); + + // Write PRI - abort if this fails as packet would be malformed + int ret = snprintf(packet, remaining, "<%d>", pri); + if (ret <= 0 || static_cast(ret) >= remaining) { + return; + } + offset = ret; + remaining -= ret; + + // Write timestamp directly into packet (RFC 5424: use "-" if time not valid or strftime fails) + auto now = this->time_->now(); + size_t ts_written = now.is_valid() ? now.strftime(packet + offset, remaining, "%b %e %H:%M:%S") : 0; + if (ts_written > 0) { + offset += ts_written; + remaining -= ts_written; + } else if (remaining > 0) { + packet[offset++] = '-'; + remaining--; + } + + // Write hostname, tag, and message + ret = snprintf(packet + offset, remaining, " %s %s: %.*s", App.get_name().c_str(), tag, (int) len, message); + if (ret > 0) { + // snprintf returns chars that would be written; clamp to actual buffer space + offset += std::min(static_cast(ret), remaining > 0 ? remaining - 1 : 0); + } + + if (offset > 0) { + this->parent_->send_packet(reinterpret_cast(packet), offset); + } } -} // namespace syslog -} // namespace esphome +} // namespace esphome::syslog diff --git a/esphome/components/syslog/esphome_syslog.h b/esphome/components/syslog/esphome_syslog.h index 1010993265b..bde6ab5ed49 100644 --- a/esphome/components/syslog/esphome_syslog.h +++ b/esphome/components/syslog/esphome_syslog.h @@ -7,8 +7,7 @@ #include "esphome/components/time/real_time_clock.h" #ifdef USE_NETWORK -namespace esphome { -namespace syslog { +namespace esphome::syslog { class Syslog : public Component, public Parented, public logger::LogListener { public: Syslog(int level, time::RealTimeClock *time) : log_level_(level), time_(time) {} @@ -24,6 +23,5 @@ class Syslog : public Component, public Parented, public logg bool strip_{true}; int facility_{16}; }; -} // namespace syslog -} // namespace esphome +} // namespace esphome::syslog #endif diff --git a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h index bdd3747372a..2038d8f1b06 100644 --- a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h +++ b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h @@ -39,6 +39,7 @@ enum TemplateAlarmControlPanelRestoreMode { ALARM_CONTROL_PANEL_RESTORE_DEFAULT_DISARMED, }; +#ifdef USE_BINARY_SENSOR struct SensorDataStore { bool last_chime_state; }; @@ -49,7 +50,6 @@ struct SensorInfo { uint8_t store_index; }; -#ifdef USE_BINARY_SENSOR struct AlarmSensor { binary_sensor::BinarySensor *sensor; SensorInfo info; @@ -139,6 +139,9 @@ class TemplateAlarmControlPanel final : public alarm_control_panel::AlarmControl FixedVector sensors_; // a list of automatically bypassed sensors std::vector bypassed_sensor_indicies_; + // Per sensor data store + std::vector sensor_data_; + uint8_t next_store_index_ = 0; #endif TemplateAlarmControlPanelRestoreMode restore_mode_{}; @@ -154,14 +157,11 @@ class TemplateAlarmControlPanel final : public alarm_control_panel::AlarmControl uint32_t trigger_time_; // a list of codes std::vector codes_; - // Per sensor data store - std::vector sensor_data_; // requires a code to arm bool requires_code_to_arm_ = false; bool supports_arm_home_ = false; bool supports_arm_night_ = false; bool sensors_ready_ = false; - uint8_t next_store_index_ = 0; // check if the code is valid bool is_code_valid_(optional code); diff --git a/esphome/components/text/text.cpp b/esphome/components/text/text.cpp index 933d82c85c1..d06c3508327 100644 --- a/esphome/components/text/text.cpp +++ b/esphome/components/text/text.cpp @@ -23,7 +23,7 @@ void Text::publish_state(const std::string &state) { #endif } -void Text::add_on_state_callback(std::function &&callback) { +void Text::add_on_state_callback(std::function &&callback) { this->state_callback_.add(std::move(callback)); } diff --git a/esphome/components/text/text.h b/esphome/components/text/text.h index 74d08eda8a7..b8881c59e60 100644 --- a/esphome/components/text/text.h +++ b/esphome/components/text/text.h @@ -31,7 +31,7 @@ class Text : public EntityBase { /// Instantiate a TextCall object to modify this text component's state. TextCall make_call() { return TextCall(this); } - void add_on_state_callback(std::function &&callback); + void add_on_state_callback(std::function &&callback); protected: friend class TextCall; @@ -44,7 +44,7 @@ class Text : public EntityBase { */ virtual void control(const std::string &value) = 0; - CallbackManager state_callback_; + LazyCallbackManager state_callback_; }; } // namespace text diff --git a/esphome/components/text_sensor/text_sensor.cpp b/esphome/components/text_sensor/text_sensor.cpp index 51923ebd96a..ad1dc0f5217 100644 --- a/esphome/components/text_sensor/text_sensor.cpp +++ b/esphome/components/text_sensor/text_sensor.cpp @@ -30,9 +30,7 @@ void TextSensor::publish_state(const std::string &state) { #pragma GCC diagnostic ignored "-Wdeprecated-declarations" this->raw_state = state; #pragma GCC diagnostic pop - if (this->raw_callback_) { - this->raw_callback_->call(state); - } + this->raw_callback_.call(state); ESP_LOGV(TAG, "'%s': Received new state %s", this->name_.c_str(), state.c_str()); @@ -73,14 +71,11 @@ void TextSensor::clear_filters() { this->filter_list_ = nullptr; } -void TextSensor::add_on_state_callback(std::function callback) { +void TextSensor::add_on_state_callback(std::function callback) { this->callback_.add(std::move(callback)); } -void TextSensor::add_on_raw_state_callback(std::function callback) { - if (!this->raw_callback_) { - this->raw_callback_ = make_unique>(); - } - this->raw_callback_->add(std::move(callback)); +void TextSensor::add_on_raw_state_callback(std::function callback) { + this->raw_callback_.add(std::move(callback)); } std::string TextSensor::get_state() const { return this->state; } diff --git a/esphome/components/text_sensor/text_sensor.h b/esphome/components/text_sensor/text_sensor.h index e411f57d67d..919bf81c8c2 100644 --- a/esphome/components/text_sensor/text_sensor.h +++ b/esphome/components/text_sensor/text_sensor.h @@ -55,9 +55,9 @@ class TextSensor : public EntityBase, public EntityBase_DeviceClass { /// Clear the entire filter chain. void clear_filters(); - void add_on_state_callback(std::function callback); + void add_on_state_callback(std::function callback); /// Add a callback that will be called every time the sensor sends a raw value. - void add_on_raw_state_callback(std::function callback); + void add_on_raw_state_callback(std::function callback); // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) @@ -65,9 +65,8 @@ class TextSensor : public EntityBase, public EntityBase_DeviceClass { void internal_send_state_to_frontend(const std::string &state); protected: - std::unique_ptr> - raw_callback_; ///< Storage for raw state callbacks (lazy allocated). - CallbackManager callback_; ///< Storage for filtered state callbacks. + LazyCallbackManager raw_callback_; ///< Storage for raw state callbacks. + LazyCallbackManager callback_; ///< Storage for filtered state callbacks. Filter *filter_list_{nullptr}; ///< Store all active filters. }; diff --git a/esphome/components/thermostat/thermostat_climate.cpp b/esphome/components/thermostat/thermostat_climate.cpp index e79eed40551..e588407c4a3 100644 --- a/esphome/components/thermostat/thermostat_climate.cpp +++ b/esphome/components/thermostat/thermostat_climate.cpp @@ -1330,45 +1330,64 @@ void ThermostatClimate::set_heat_deadband(float deadband) { this->heating_deadba 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); + + if (this->timer_[timer_index].active) { + // Timer is running, calculate elapsed time and adjust if needed + uint32_t current_time = App.get_loop_component_start_time(); + uint32_t elapsed = current_time - this->timer_[timer_index].started; + + if (elapsed >= new_duration_ms) { + // Timer should complete immediately (including when new_duration_ms is 0) + ESP_LOGVV(TAG, "timer %d completing immediately (elapsed %d >= new %d)", timer_index, elapsed, new_duration_ms); + this->timer_[timer_index].active = false; + // Trigger the timer callback immediately + this->timer_[timer_index].func(); + return; + } else { + // Adjust timer to run for remaining time - keep original start time + ESP_LOGVV(TAG, "timer %d adjusted: elapsed %d, new total %d, remaining %d", timer_index, elapsed, new_duration_ms, + new_duration_ms - elapsed); + this->timer_[timer_index].time = new_duration_ms; + return; + } + } + + // Original logic for non-running timers + this->timer_[timer_index].time = new_duration_ms; +} + void ThermostatClimate::set_cooling_maximum_run_time_in_sec(uint32_t time) { - this->timer_[thermostat::THERMOSTAT_TIMER_COOLING_MAX_RUN_TIME].time = - 1000 * (time < this->min_timer_duration_ ? this->min_timer_duration_ : time); + this->set_timer_duration_in_sec_(thermostat::THERMOSTAT_TIMER_COOLING_MAX_RUN_TIME, time); } void ThermostatClimate::set_cooling_minimum_off_time_in_sec(uint32_t time) { - this->timer_[thermostat::THERMOSTAT_TIMER_COOLING_OFF].time = - 1000 * (time < this->min_timer_duration_ ? this->min_timer_duration_ : time); + this->set_timer_duration_in_sec_(thermostat::THERMOSTAT_TIMER_COOLING_OFF, time); } void ThermostatClimate::set_cooling_minimum_run_time_in_sec(uint32_t time) { - this->timer_[thermostat::THERMOSTAT_TIMER_COOLING_ON].time = - 1000 * (time < this->min_timer_duration_ ? this->min_timer_duration_ : time); + this->set_timer_duration_in_sec_(thermostat::THERMOSTAT_TIMER_COOLING_ON, time); } void ThermostatClimate::set_fan_mode_minimum_switching_time_in_sec(uint32_t time) { - this->timer_[thermostat::THERMOSTAT_TIMER_FAN_MODE].time = - 1000 * (time < this->min_timer_duration_ ? this->min_timer_duration_ : time); + this->set_timer_duration_in_sec_(thermostat::THERMOSTAT_TIMER_FAN_MODE, time); } void ThermostatClimate::set_fanning_minimum_off_time_in_sec(uint32_t time) { - this->timer_[thermostat::THERMOSTAT_TIMER_FANNING_OFF].time = - 1000 * (time < this->min_timer_duration_ ? this->min_timer_duration_ : time); + this->set_timer_duration_in_sec_(thermostat::THERMOSTAT_TIMER_FANNING_OFF, time); } void ThermostatClimate::set_fanning_minimum_run_time_in_sec(uint32_t time) { - this->timer_[thermostat::THERMOSTAT_TIMER_FANNING_ON].time = - 1000 * (time < this->min_timer_duration_ ? this->min_timer_duration_ : time); + this->set_timer_duration_in_sec_(thermostat::THERMOSTAT_TIMER_FANNING_ON, time); } void ThermostatClimate::set_heating_maximum_run_time_in_sec(uint32_t time) { - this->timer_[thermostat::THERMOSTAT_TIMER_HEATING_MAX_RUN_TIME].time = - 1000 * (time < this->min_timer_duration_ ? this->min_timer_duration_ : time); + this->set_timer_duration_in_sec_(thermostat::THERMOSTAT_TIMER_HEATING_MAX_RUN_TIME, time); } void ThermostatClimate::set_heating_minimum_off_time_in_sec(uint32_t time) { - this->timer_[thermostat::THERMOSTAT_TIMER_HEATING_OFF].time = - 1000 * (time < this->min_timer_duration_ ? this->min_timer_duration_ : time); + this->set_timer_duration_in_sec_(thermostat::THERMOSTAT_TIMER_HEATING_OFF, time); } void ThermostatClimate::set_heating_minimum_run_time_in_sec(uint32_t time) { - this->timer_[thermostat::THERMOSTAT_TIMER_HEATING_ON].time = - 1000 * (time < this->min_timer_duration_ ? this->min_timer_duration_ : time); + this->set_timer_duration_in_sec_(thermostat::THERMOSTAT_TIMER_HEATING_ON, time); } void ThermostatClimate::set_idle_minimum_time_in_sec(uint32_t time) { - this->timer_[thermostat::THERMOSTAT_TIMER_IDLE_ON].time = - 1000 * (time < this->min_timer_duration_ ? this->min_timer_duration_ : 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) { diff --git a/esphome/components/thermostat/thermostat_climate.h b/esphome/components/thermostat/thermostat_climate.h index 69d2307b1c3..2443af58d6e 100644 --- a/esphome/components/thermostat/thermostat_climate.h +++ b/esphome/components/thermostat/thermostat_climate.h @@ -267,6 +267,8 @@ class ThermostatClimate : public climate::Climate, public Component { bool timer_active_(ThermostatClimateTimerIndex timer_index); uint32_t timer_duration_(ThermostatClimateTimerIndex timer_index); std::function timer_cbf_(ThermostatClimateTimerIndex timer_index); + /// Enhanced timer duration setter with running timer adjustment + void set_timer_duration_in_sec_(ThermostatClimateTimerIndex timer_index, uint32_t time); /// set_timeout() callbacks for various actions (see above) void cooling_max_run_time_timer_callback_(); diff --git a/esphome/components/time/automation.cpp b/esphome/components/time/automation.cpp index f7c1916ffe8..8bc87878d1e 100644 --- a/esphome/components/time/automation.cpp +++ b/esphome/components/time/automation.cpp @@ -4,8 +4,7 @@ #include -namespace esphome { -namespace time { +namespace esphome::time { static const char *const TAG = "automation"; static const int MAX_TIMESTAMP_DRIFT = 900; // how far can the clock drift before we consider @@ -92,5 +91,4 @@ SyncTrigger::SyncTrigger(RealTimeClock *rtc) : rtc_(rtc) { rtc->add_on_time_sync_callback([this]() { this->trigger(); }); } -} // namespace time -} // namespace esphome +} // namespace esphome::time diff --git a/esphome/components/time/automation.h b/esphome/components/time/automation.h index b5c8291533e..4ccfc641d6c 100644 --- a/esphome/components/time/automation.h +++ b/esphome/components/time/automation.h @@ -8,8 +8,7 @@ #include -namespace esphome { -namespace time { +namespace esphome::time { class CronTrigger : public Trigger<>, public Component { public: @@ -48,5 +47,4 @@ class SyncTrigger : public Trigger<>, public Component { protected: RealTimeClock *rtc_; }; -} // namespace time -} // namespace esphome +} // namespace esphome::time diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp index 175cee0c1ff..639af4457f9 100644 --- a/esphome/components/time/real_time_clock.cpp +++ b/esphome/components/time/real_time_clock.cpp @@ -17,8 +17,7 @@ #include -namespace esphome { -namespace time { +namespace esphome::time { static const char *const TAG = "time"; @@ -78,5 +77,4 @@ void RealTimeClock::apply_timezone_() { } #endif -} // namespace time -} // namespace esphome +} // namespace esphome::time diff --git a/esphome/components/time/real_time_clock.h b/esphome/components/time/real_time_clock.h index 2f17bd86d6f..70469e11b0d 100644 --- a/esphome/components/time/real_time_clock.h +++ b/esphome/components/time/real_time_clock.h @@ -7,8 +7,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/time.h" -namespace esphome { -namespace time { +namespace esphome::time { /// The RealTimeClock class exposes common timekeeping functions via the device's local real-time clock. /// @@ -75,5 +74,4 @@ template class TimeHasTimeCondition : public Condition { RealTimeClock *parent_; }; -} // namespace time -} // namespace esphome +} // namespace esphome::time diff --git a/esphome/components/uart/packet_transport/uart_transport.cpp b/esphome/components/uart/packet_transport/uart_transport.cpp index 4a9aa0fe47a..6b8eae611c0 100644 --- a/esphome/components/uart/packet_transport/uart_transport.cpp +++ b/esphome/components/uart/packet_transport/uart_transport.cpp @@ -55,12 +55,6 @@ void UARTTransport::loop() { } } -void UARTTransport::update() { - this->updated_ = true; - this->resend_data_ = true; - PacketTransport::update(); -} - /** * Write a byte to the UART bus. If the byte is a flag or control byte, it will be escaped. * @param byte The byte to write. diff --git a/esphome/components/uart/packet_transport/uart_transport.h b/esphome/components/uart/packet_transport/uart_transport.h index e84bed95e63..1c92af536ef 100644 --- a/esphome/components/uart/packet_transport/uart_transport.h +++ b/esphome/components/uart/packet_transport/uart_transport.h @@ -23,7 +23,6 @@ static const uint8_t CONTROL_BYTE = 0x7D; class UARTTransport : public packet_transport::PacketTransport, public UARTDevice { public: void loop() override; - void update() override; float get_setup_priority() const override { return setup_priority::PROCESSOR; } protected: diff --git a/esphome/components/uart/uart_component_esp_idf.cpp b/esphome/components/uart/uart_component_esp_idf.cpp index b438e4f7a60..b4f6eedf91c 100644 --- a/esphome/components/uart/uart_component_esp_idf.cpp +++ b/esphome/components/uart/uart_component_esp_idf.cpp @@ -9,6 +9,7 @@ #include "esphome/core/gpio.h" #include "driver/gpio.h" #include "soc/gpio_num.h" +#include "soc/uart_pins.h" #ifdef USE_LOGGER #include "esphome/components/logger/logger.h" @@ -139,6 +140,22 @@ void IDFUARTComponent::load_settings(bool dump_config) { return; } + int8_t tx = this->tx_pin_ != nullptr ? this->tx_pin_->get_pin() : -1; + int8_t rx = this->rx_pin_ != nullptr ? this->rx_pin_->get_pin() : -1; + int8_t flow_control = this->flow_control_pin_ != nullptr ? this->flow_control_pin_->get_pin() : -1; + + // Workaround for ESP-IDF issue: https://github.com/espressif/esp-idf/issues/17459 + // Commit 9ed617fb17 removed gpio_func_sel() calls from uart_set_pin(), which breaks + // UART on default UART0 pins that may have residual state from boot console. + // Reset these pins before configuring UART to ensure they're in a clean state. + if (tx == U0TXD_GPIO_NUM || tx == U0RXD_GPIO_NUM) { + gpio_reset_pin(static_cast(tx)); + } + if (rx == U0TXD_GPIO_NUM || rx == U0RXD_GPIO_NUM) { + gpio_reset_pin(static_cast(rx)); + } + + // Setup pins after reset to preserve open drain/pullup/pulldown flags auto setup_pin_if_needed = [](InternalGPIOPin *pin) { if (!pin) { return; @@ -154,10 +171,6 @@ void IDFUARTComponent::load_settings(bool dump_config) { setup_pin_if_needed(this->tx_pin_); } - int8_t tx = this->tx_pin_ != nullptr ? this->tx_pin_->get_pin() : -1; - int8_t rx = this->rx_pin_ != nullptr ? this->rx_pin_->get_pin() : -1; - int8_t flow_control = this->flow_control_pin_ != nullptr ? this->flow_control_pin_->get_pin() : -1; - uint32_t invert = 0; if (this->tx_pin_ != nullptr && this->tx_pin_->is_inverted()) { invert |= UART_SIGNAL_TXD_INV; diff --git a/esphome/components/udp/packet_transport/udp_transport.cpp b/esphome/components/udp/packet_transport/udp_transport.cpp index b92e0d64df0..f3e33573a5f 100644 --- a/esphome/components/udp/packet_transport/udp_transport.cpp +++ b/esphome/components/udp/packet_transport/udp_transport.cpp @@ -8,29 +8,14 @@ namespace udp { static const char *const TAG = "udp_transport"; -bool UDPTransport::should_send() { return this->should_broadcast_ && network::is_connected(); } +bool UDPTransport::should_send() { return network::is_connected(); } void UDPTransport::setup() { PacketTransport::setup(); - this->should_broadcast_ = this->ping_pong_enable_; -#ifdef USE_SENSOR - this->should_broadcast_ |= !this->sensors_.empty(); -#endif -#ifdef USE_BINARY_SENSOR - this->should_broadcast_ |= !this->binary_sensors_.empty(); -#endif - if (this->should_broadcast_) - this->parent_->set_should_broadcast(); if (!this->providers_.empty() || this->is_encrypted_()) { this->parent_->add_listener([this](std::vector &buf) { this->process_(buf); }); } } -void UDPTransport::update() { - PacketTransport::update(); - this->updated_ = true; - this->resend_data_ = this->should_broadcast_; -} - void UDPTransport::send_packet(const std::vector &buf) const { this->parent_->send_packet(buf); } } // namespace udp } // namespace esphome diff --git a/esphome/components/udp/packet_transport/udp_transport.h b/esphome/components/udp/packet_transport/udp_transport.h index c87eb627805..8d01ae0909f 100644 --- a/esphome/components/udp/packet_transport/udp_transport.h +++ b/esphome/components/udp/packet_transport/udp_transport.h @@ -12,14 +12,12 @@ namespace udp { class UDPTransport : public packet_transport::PacketTransport, public Parented { public: void setup() override; - void update() override; float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } protected: void send_packet(const std::vector &buf) const override; bool should_send() override; - bool should_broadcast_{false}; size_t get_max_packet_size() override { return MAX_PACKET_SIZE; } }; diff --git a/esphome/components/update/__init__.py b/esphome/components/update/__init__.py index 7a381c85a86..e146f7e6857 100644 --- a/esphome/components/update/__init__.py +++ b/esphome/components/update/__init__.py @@ -29,6 +29,9 @@ UpdateInfo = update_ns.struct("UpdateInfo") PerformAction = update_ns.class_( "PerformAction", automation.Action, cg.Parented.template(UpdateEntity) ) +CheckAction = update_ns.class_( + "CheckAction", automation.Action, cg.Parented.template(UpdateEntity) +) IsAvailableCondition = update_ns.class_( "IsAvailableCondition", automation.Condition, cg.Parented.template(UpdateEntity) ) @@ -143,6 +146,21 @@ async def update_perform_action_to_code(config, action_id, template_arg, args): return var +@automation.register_action( + "update.check", + CheckAction, + automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(UpdateEntity), + } + ), +) +async def update_check_action_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var + + @automation.register_condition( "update.is_available", IsAvailableCondition, diff --git a/esphome/components/update/automation.h b/esphome/components/update/automation.h index 8563b855fe4..af24c838b14 100644 --- a/esphome/components/update/automation.h +++ b/esphome/components/update/automation.h @@ -14,6 +14,11 @@ template class PerformAction : public Action, public Pare void play(const Ts &...x) override { this->parent_->perform(this->force_.value(x...)); } }; +template class CheckAction : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->check(); } +}; + template class IsAvailableCondition : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->state == UPDATE_STATE_AVAILABLE; } diff --git a/esphome/components/update/update_entity.h b/esphome/components/update/update_entity.h index 9424e80b9f2..8eba78b44bb 100644 --- a/esphome/components/update/update_entity.h +++ b/esphome/components/update/update_entity.h @@ -50,7 +50,7 @@ class UpdateEntity : public EntityBase, public EntityBase_DeviceClass { UpdateState state_{UPDATE_STATE_UNKNOWN}; UpdateInfo update_info_; - CallbackManager state_callback_{}; + LazyCallbackManager state_callback_{}; std::unique_ptr> update_available_trigger_{nullptr}; }; diff --git a/esphome/components/valve/valve.h b/esphome/components/valve/valve.h index 2cb28e4b2fe..2b3419b67a7 100644 --- a/esphome/components/valve/valve.h +++ b/esphome/components/valve/valve.h @@ -144,7 +144,7 @@ class Valve : public EntityBase, public EntityBase_DeviceClass { optional restore_state_(); - CallbackManager state_callback_{}; + LazyCallbackManager state_callback_{}; ESPPreferenceObject rtc_; }; diff --git a/esphome/components/version/version_text_sensor.cpp b/esphome/components/version/version_text_sensor.cpp index 78d0fb501ba..584b8abfb29 100644 --- a/esphome/components/version/version_text_sensor.cpp +++ b/esphome/components/version/version_text_sensor.cpp @@ -1,8 +1,9 @@ #include "version_text_sensor.h" -#include "esphome/core/log.h" #include "esphome/core/application.h" +#include "esphome/core/log.h" #include "esphome/core/version.h" #include "esphome/core/helpers.h" +#include "esphome/core/progmem.h" namespace esphome { namespace version { @@ -10,11 +11,26 @@ namespace version { static const char *const TAG = "version.text_sensor"; void VersionTextSensor::setup() { - if (this->hide_timestamp_) { - this->publish_state(ESPHOME_VERSION); - } else { - this->publish_state(str_sprintf(ESPHOME_VERSION " %s", App.get_compilation_time_ref().c_str())); + static const char PREFIX[] PROGMEM = ESPHOME_VERSION " (config hash 0x"; + static const char BUILT_STR[] PROGMEM = ", built "; + // Buffer size: PREFIX + 8 hex chars + BUILT_STR + BUILD_TIME_STR_SIZE + ")" + null + constexpr size_t buf_size = sizeof(PREFIX) + 8 + sizeof(BUILT_STR) + esphome::Application::BUILD_TIME_STR_SIZE + 2; + char version_str[buf_size]; + + ESPHOME_strncpy_P(version_str, PREFIX, sizeof(version_str)); + + size_t len = strlen(version_str); + snprintf(version_str + len, sizeof(version_str) - len, "%08" PRIx32, App.get_config_hash()); + + if (!this->hide_timestamp_) { + size_t len = strlen(version_str); + ESPHOME_strncat_P(version_str, BUILT_STR, sizeof(version_str) - len - 1); + ESPHOME_strncat_P(version_str, ESPHOME_BUILD_TIME_STR, sizeof(version_str) - strlen(version_str) - 1); } + + strncat(version_str, ")", sizeof(version_str) - strlen(version_str) - 1); + version_str[sizeof(version_str) - 1] = '\0'; + this->publish_state(version_str); } float VersionTextSensor::get_setup_priority() const { return setup_priority::DATA; } void VersionTextSensor::set_hide_timestamp(bool hide_timestamp) { this->hide_timestamp_ = hide_timestamp; } diff --git a/esphome/components/water_heater/__init__.py b/esphome/components/water_heater/__init__.py new file mode 100644 index 00000000000..5420e7c4351 --- /dev/null +++ b/esphome/components/water_heater/__init__.py @@ -0,0 +1,111 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import ( + CONF_ENTITY_CATEGORY, + CONF_ICON, + CONF_ID, + CONF_MAX_TEMPERATURE, + CONF_MIN_TEMPERATURE, + CONF_VISUAL, +) +from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.cpp_generator import MockObjClass +from esphome.types import ConfigType + +CODEOWNERS = ["@dhoeben"] + +IS_PLATFORM_COMPONENT = True + +water_heater_ns = cg.esphome_ns.namespace("water_heater") +WaterHeater = water_heater_ns.class_("WaterHeater", cg.EntityBase, cg.Component) +WaterHeaterCall = water_heater_ns.class_("WaterHeaterCall") +WaterHeaterTraits = water_heater_ns.class_("WaterHeaterTraits") + +CONF_TARGET_TEMPERATURE_STEP = "target_temperature_step" + +WaterHeaterMode = water_heater_ns.enum("WaterHeaterMode") +WATER_HEATER_MODES = { + "OFF": WaterHeaterMode.WATER_HEATER_MODE_OFF, + "ECO": WaterHeaterMode.WATER_HEATER_MODE_ECO, + "ELECTRIC": WaterHeaterMode.WATER_HEATER_MODE_ELECTRIC, + "PERFORMANCE": WaterHeaterMode.WATER_HEATER_MODE_PERFORMANCE, + "HIGH_DEMAND": WaterHeaterMode.WATER_HEATER_MODE_HIGH_DEMAND, + "HEAT_PUMP": WaterHeaterMode.WATER_HEATER_MODE_HEAT_PUMP, + "GAS": WaterHeaterMode.WATER_HEATER_MODE_GAS, +} +validate_water_heater_mode = cv.enum(WATER_HEATER_MODES, upper=True) + +_WATER_HEATER_SCHEMA = cv.ENTITY_BASE_SCHEMA.extend( + { + cv.Optional(CONF_VISUAL, default={}): cv.Schema( + { + cv.Optional(CONF_MIN_TEMPERATURE): cv.temperature, + cv.Optional(CONF_MAX_TEMPERATURE): cv.temperature, + cv.Optional(CONF_TARGET_TEMPERATURE_STEP): cv.float_, + } + ), + } +).extend(cv.COMPONENT_SCHEMA) + +_WATER_HEATER_SCHEMA.add_extra(entity_duplicate_validator("water_heater")) + + +def water_heater_schema( + class_: MockObjClass, + *, + icon: str = cv.UNDEFINED, + entity_category: str = cv.UNDEFINED, +) -> cv.Schema: + schema = {cv.GenerateID(): cv.declare_id(class_)} + + for key, default, validator in [ + (CONF_ICON, icon, cv.icon), + (CONF_ENTITY_CATEGORY, entity_category, cv.entity_category), + ]: + if default is not cv.UNDEFINED: + schema[cv.Optional(key, default=default)] = validator + + return _WATER_HEATER_SCHEMA.extend(schema) + + +async def setup_water_heater_core_(var: cg.Pvariable, config: ConfigType) -> None: + """Set up the core water heater properties in C++.""" + await setup_entity(var, config, "water_heater") + + visual = config[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)) + if (max_temp := visual.get(CONF_MAX_TEMPERATURE)) is not None: + cg.add_define("USE_WATER_HEATER_VISUAL_OVERRIDES") + cg.add(var.set_visual_max_temperature_override(max_temp)) + if (temp_step := visual.get(CONF_TARGET_TEMPERATURE_STEP)) is not None: + cg.add_define("USE_WATER_HEATER_VISUAL_OVERRIDES") + cg.add(var.set_visual_target_temperature_step_override(temp_step)) + + +async def register_water_heater(var: cg.Pvariable, config: ConfigType) -> cg.Pvariable: + if not CORE.has_id(config[CONF_ID]): + var = cg.Pvariable(config[CONF_ID], var) + + cg.add_define("USE_WATER_HEATER") + + await cg.register_component(var, config) + + cg.add(cg.App.register_water_heater(var)) + + CORE.register_platform_component("water_heater", var) + await setup_water_heater_core_(var, config) + return var + + +async def new_water_heater(config: ConfigType, *args) -> cg.Pvariable: + var = cg.new_Pvariable(config[CONF_ID], *args) + await register_water_heater(var, config) + return var + + +@coroutine_with_priority(CoroPriority.CORE) +async def to_code(config: ConfigType) -> None: + cg.add_global(water_heater_ns.using) diff --git a/esphome/components/water_heater/water_heater.cpp b/esphome/components/water_heater/water_heater.cpp new file mode 100644 index 00000000000..441872ec000 --- /dev/null +++ b/esphome/components/water_heater/water_heater.cpp @@ -0,0 +1,281 @@ +#include "water_heater.h" +#include "esphome/core/log.h" +#include "esphome/core/application.h" +#include "esphome/core/controller_registry.h" + +#include + +namespace esphome::water_heater { + +static const char *const TAG = "water_heater"; + +void log_water_heater(const char *tag, const char *prefix, const char *type, WaterHeater *obj) { + if (obj != nullptr) { + ESP_LOGCONFIG(tag, "%s%s '%s'", prefix, type, obj->get_name().c_str()); + } +} + +WaterHeaterCall::WaterHeaterCall(WaterHeater *parent) : parent_(parent) {} + +WaterHeaterCall &WaterHeaterCall::set_mode(WaterHeaterMode mode) { + this->mode_ = mode; + return *this; +} + +WaterHeaterCall &WaterHeaterCall::set_mode(const std::string &mode) { + if (str_equals_case_insensitive(mode, "OFF")) { + this->set_mode(WATER_HEATER_MODE_OFF); + } else if (str_equals_case_insensitive(mode, "ECO")) { + this->set_mode(WATER_HEATER_MODE_ECO); + } else if (str_equals_case_insensitive(mode, "ELECTRIC")) { + this->set_mode(WATER_HEATER_MODE_ELECTRIC); + } else if (str_equals_case_insensitive(mode, "PERFORMANCE")) { + this->set_mode(WATER_HEATER_MODE_PERFORMANCE); + } else if (str_equals_case_insensitive(mode, "HIGH_DEMAND")) { + this->set_mode(WATER_HEATER_MODE_HIGH_DEMAND); + } else if (str_equals_case_insensitive(mode, "HEAT_PUMP")) { + this->set_mode(WATER_HEATER_MODE_HEAT_PUMP); + } else if (str_equals_case_insensitive(mode, "GAS")) { + this->set_mode(WATER_HEATER_MODE_GAS); + } else { + ESP_LOGW(TAG, "'%s' - Unrecognized mode %s", this->parent_->get_name().c_str(), mode.c_str()); + } + return *this; +} + +WaterHeaterCall &WaterHeaterCall::set_target_temperature(float temperature) { + this->target_temperature_ = temperature; + return *this; +} + +WaterHeaterCall &WaterHeaterCall::set_target_temperature_low(float temperature) { + this->target_temperature_low_ = temperature; + return *this; +} + +WaterHeaterCall &WaterHeaterCall::set_target_temperature_high(float temperature) { + this->target_temperature_high_ = temperature; + return *this; +} + +WaterHeaterCall &WaterHeaterCall::set_away(bool away) { + if (away) { + this->state_ |= WATER_HEATER_STATE_AWAY; + } else { + this->state_ &= ~WATER_HEATER_STATE_AWAY; + } + return *this; +} + +WaterHeaterCall &WaterHeaterCall::set_on(bool on) { + if (on) { + this->state_ |= WATER_HEATER_STATE_ON; + } else { + this->state_ &= ~WATER_HEATER_STATE_ON; + } + return *this; +} + +void WaterHeaterCall::perform() { + ESP_LOGD(TAG, "'%s' - Setting", this->parent_->get_name().c_str()); + this->validate_(); + if (this->mode_.has_value()) { + ESP_LOGD(TAG, " Mode: %s", LOG_STR_ARG(water_heater_mode_to_string(*this->mode_))); + } + if (!std::isnan(this->target_temperature_)) { + ESP_LOGD(TAG, " Target Temperature: %.2f", this->target_temperature_); + } + if (!std::isnan(this->target_temperature_low_)) { + ESP_LOGD(TAG, " Target Temperature Low: %.2f", this->target_temperature_low_); + } + if (!std::isnan(this->target_temperature_high_)) { + ESP_LOGD(TAG, " Target Temperature High: %.2f", this->target_temperature_high_); + } + if (this->state_ & WATER_HEATER_STATE_AWAY) { + ESP_LOGD(TAG, " Away: YES"); + } + if (this->state_ & WATER_HEATER_STATE_ON) { + ESP_LOGD(TAG, " On: YES"); + } + this->parent_->control(*this); +} + +void WaterHeaterCall::validate_() { + auto traits = this->parent_->get_traits(); + if (this->mode_.has_value()) { + if (!traits.supports_mode(*this->mode_)) { + ESP_LOGW(TAG, "'%s' - Mode %d not supported", this->parent_->get_name().c_str(), *this->mode_); + this->mode_.reset(); + } + } + if (!std::isnan(this->target_temperature_)) { + if (traits.get_supports_two_point_target_temperature()) { + ESP_LOGW(TAG, "'%s' - Cannot set target temperature for device with two-point target temperature", + this->parent_->get_name().c_str()); + this->target_temperature_ = NAN; + } else if (this->target_temperature_ < traits.get_min_temperature() || + this->target_temperature_ > traits.get_max_temperature()) { + ESP_LOGW(TAG, "'%s' - Target temperature %.1f is out of range [%.1f - %.1f]", this->parent_->get_name().c_str(), + this->target_temperature_, traits.get_min_temperature(), traits.get_max_temperature()); + this->target_temperature_ = + std::max(traits.get_min_temperature(), std::min(this->target_temperature_, traits.get_max_temperature())); + } + } + if (!std::isnan(this->target_temperature_low_) || !std::isnan(this->target_temperature_high_)) { + if (!traits.get_supports_two_point_target_temperature()) { + ESP_LOGW(TAG, "'%s' - Cannot set low/high target temperature", this->parent_->get_name().c_str()); + this->target_temperature_low_ = NAN; + this->target_temperature_high_ = NAN; + } + } + if (!std::isnan(this->target_temperature_low_) && !std::isnan(this->target_temperature_high_)) { + if (this->target_temperature_low_ > this->target_temperature_high_) { + ESP_LOGW(TAG, "'%s' - Target temperature low %.2f must be less than high %.2f", this->parent_->get_name().c_str(), + this->target_temperature_low_, this->target_temperature_high_); + this->target_temperature_low_ = NAN; + this->target_temperature_high_ = NAN; + } + } + if ((this->state_ & WATER_HEATER_STATE_AWAY) && !traits.get_supports_away_mode()) { + ESP_LOGW(TAG, "'%s' - Away mode not supported", this->parent_->get_name().c_str()); + this->state_ &= ~WATER_HEATER_STATE_AWAY; + } + // If ON/OFF not supported, device is always on - clear the flag silently + if (!traits.has_feature_flags(WATER_HEATER_SUPPORTS_ON_OFF)) { + this->state_ &= ~WATER_HEATER_STATE_ON; + } +} + +void WaterHeater::setup() { + this->pref_ = global_preferences->make_preference(this->get_preference_hash()); +} + +void WaterHeater::publish_state() { + auto traits = this->get_traits(); + ESP_LOGD(TAG, "'%s' - Sending state:", this->name_.c_str()); + ESP_LOGD(TAG, " Mode: %s", LOG_STR_ARG(water_heater_mode_to_string(this->mode_))); + if (!std::isnan(this->current_temperature_)) { + ESP_LOGD(TAG, " Current Temperature: %.2f°C", this->current_temperature_); + } + if (traits.get_supports_two_point_target_temperature()) { + ESP_LOGD(TAG, " Target Temperature: Low: %.2f°C High: %.2f°C", this->target_temperature_low_, + this->target_temperature_high_); + } else if (!std::isnan(this->target_temperature_)) { + ESP_LOGD(TAG, " Target Temperature: %.2f°C", this->target_temperature_); + } + if (this->state_ & WATER_HEATER_STATE_AWAY) { + ESP_LOGD(TAG, " Away: YES"); + } + if (traits.has_feature_flags(WATER_HEATER_SUPPORTS_ON_OFF)) { + ESP_LOGD(TAG, " On: %s", (this->state_ & WATER_HEATER_STATE_ON) ? "YES" : "NO"); + } + +#if defined(USE_WATER_HEATER) && defined(USE_CONTROLLER_REGISTRY) + ControllerRegistry::notify_water_heater_update(this); +#endif + + SavedWaterHeaterState saved{}; + saved.mode = this->mode_; + if (traits.get_supports_two_point_target_temperature()) { + saved.target_temperature_low = this->target_temperature_low_; + saved.target_temperature_high = this->target_temperature_high_; + } else { + saved.target_temperature = this->target_temperature_; + } + saved.state = this->state_; + this->pref_.save(&saved); +} + +optional WaterHeater::restore_state() { + SavedWaterHeaterState recovered{}; + if (!this->pref_.load(&recovered)) + return {}; + + auto traits = this->get_traits(); + auto call = this->make_call(); + call.set_mode(recovered.mode); + if (traits.get_supports_two_point_target_temperature()) { + call.set_target_temperature_low(recovered.target_temperature_low); + call.set_target_temperature_high(recovered.target_temperature_high); + } else { + call.set_target_temperature(recovered.target_temperature); + } + call.set_away((recovered.state & WATER_HEATER_STATE_AWAY) != 0); + call.set_on((recovered.state & WATER_HEATER_STATE_ON) != 0); + return call; +} + +WaterHeaterTraits WaterHeater::get_traits() { + auto traits = this->traits(); +#ifdef USE_WATER_HEATER_VISUAL_OVERRIDES + if (!std::isnan(this->visual_min_temperature_override_)) { + traits.set_min_temperature(this->visual_min_temperature_override_); + } + if (!std::isnan(this->visual_max_temperature_override_)) { + traits.set_max_temperature(this->visual_max_temperature_override_); + } + if (!std::isnan(this->visual_target_temperature_step_override_)) { + traits.set_target_temperature_step(this->visual_target_temperature_step_override_); + } +#endif + 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 + +const LogString *water_heater_mode_to_string(WaterHeaterMode mode) { + switch (mode) { + case WATER_HEATER_MODE_OFF: + return LOG_STR("OFF"); + case WATER_HEATER_MODE_ECO: + return LOG_STR("ECO"); + case WATER_HEATER_MODE_ELECTRIC: + return LOG_STR("ELECTRIC"); + case WATER_HEATER_MODE_PERFORMANCE: + return LOG_STR("PERFORMANCE"); + case WATER_HEATER_MODE_HIGH_DEMAND: + return LOG_STR("HIGH_DEMAND"); + case WATER_HEATER_MODE_HEAT_PUMP: + return LOG_STR("HEAT_PUMP"); + case WATER_HEATER_MODE_GAS: + return LOG_STR("GAS"); + default: + return LOG_STR("UNKNOWN"); + } +} + +void WaterHeater::dump_traits_(const char *tag) { + auto traits = this->get_traits(); + ESP_LOGCONFIG(tag, + " Min Temperature: %.1f°C\n" + " Max Temperature: %.1f°C\n" + " Temperature Step: %.1f", + traits.get_min_temperature(), traits.get_max_temperature(), traits.get_target_temperature_step()); + if (traits.get_supports_two_point_target_temperature()) { + ESP_LOGCONFIG(tag, " Supports Two-Point Target Temperature: YES"); + } + if (traits.get_supports_away_mode()) { + ESP_LOGCONFIG(tag, " Supports Away Mode: YES"); + } + if (traits.has_feature_flags(WATER_HEATER_SUPPORTS_ON_OFF)) { + ESP_LOGCONFIG(tag, " Supports On/Off: YES"); + } + if (!traits.get_supported_modes().empty()) { + ESP_LOGCONFIG(tag, " Supported Modes:"); + for (WaterHeaterMode m : traits.get_supported_modes()) { + ESP_LOGCONFIG(tag, " - %s", LOG_STR_ARG(water_heater_mode_to_string(m))); + } + } +} + +} // namespace esphome::water_heater diff --git a/esphome/components/water_heater/water_heater.h b/esphome/components/water_heater/water_heater.h new file mode 100644 index 00000000000..e223dd59b22 --- /dev/null +++ b/esphome/components/water_heater/water_heater.h @@ -0,0 +1,259 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/entity_base.h" +#include "esphome/core/finite_set_mask.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "esphome/core/preferences.h" + +namespace esphome::water_heater { + +class WaterHeater; +struct WaterHeaterCallInternal; + +void log_water_heater(const char *tag, const char *prefix, const char *type, WaterHeater *obj); +#define LOG_WATER_HEATER(prefix, type, obj) log_water_heater(TAG, prefix, LOG_STR_LITERAL(type), obj) + +enum WaterHeaterMode : uint32_t { + WATER_HEATER_MODE_OFF = 0, + WATER_HEATER_MODE_ECO = 1, + WATER_HEATER_MODE_ELECTRIC = 2, + WATER_HEATER_MODE_PERFORMANCE = 3, + WATER_HEATER_MODE_HIGH_DEMAND = 4, + WATER_HEATER_MODE_HEAT_PUMP = 5, + WATER_HEATER_MODE_GAS = 6, +}; + +// Type alias for water heater mode bitmask +// Replaces std::set to eliminate red-black tree overhead +using WaterHeaterModeMask = + FiniteSetMask>; + +/// Feature flags for water heater capabilities (matches Home Assistant WaterHeaterEntityFeature) +enum WaterHeaterFeature : uint32_t { + /// The water heater supports reporting the current temperature. + WATER_HEATER_SUPPORTS_CURRENT_TEMPERATURE = 1 << 0, + /// The water heater supports a target temperature. + WATER_HEATER_SUPPORTS_TARGET_TEMPERATURE = 1 << 1, + /// The water heater supports operation mode selection. + WATER_HEATER_SUPPORTS_OPERATION_MODE = 1 << 2, + /// The water heater supports an away/vacation mode. + WATER_HEATER_SUPPORTS_AWAY_MODE = 1 << 3, + /// The water heater can be turned on/off. + WATER_HEATER_SUPPORTS_ON_OFF = 1 << 4, + /// The water heater supports two-point target temperature (low/high range). + WATER_HEATER_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE = 1 << 5, +}; + +/// State flags for water heater current state (bitmask) +enum WaterHeaterStateFlag : uint32_t { + /// Away/vacation mode is currently active + WATER_HEATER_STATE_AWAY = 1 << 0, + /// Water heater is on (not in standby) + WATER_HEATER_STATE_ON = 1 << 1, +}; + +struct SavedWaterHeaterState { + WaterHeaterMode mode; + union { + float target_temperature; + struct { + float target_temperature_low; + float target_temperature_high; + }; + } __attribute__((packed)); + uint32_t state; +} __attribute__((packed)); + +class WaterHeaterCall { + friend struct WaterHeaterCallInternal; + + public: + WaterHeaterCall() : parent_(nullptr) {} + + WaterHeaterCall(WaterHeater *parent); + + WaterHeaterCall &set_mode(WaterHeaterMode mode); + WaterHeaterCall &set_mode(const std::string &mode); + WaterHeaterCall &set_target_temperature(float temperature); + WaterHeaterCall &set_target_temperature_low(float temperature); + WaterHeaterCall &set_target_temperature_high(float temperature); + WaterHeaterCall &set_away(bool away); + WaterHeaterCall &set_on(bool on); + + void perform(); + + const optional &get_mode() const { return this->mode_; } + 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 + uint32_t get_state() const { return this->state_; } + + protected: + void validate_(); + WaterHeater *parent_; + optional mode_; + float target_temperature_{NAN}; + float target_temperature_low_{NAN}; + float target_temperature_high_{NAN}; + uint32_t state_{0}; +}; + +struct WaterHeaterCallInternal : public WaterHeaterCall { + WaterHeaterCallInternal(WaterHeater *parent) : WaterHeaterCall(parent) {} + + WaterHeaterCallInternal &set_from_restore(const WaterHeaterCall &restore) { + this->mode_ = restore.mode_; + this->target_temperature_ = restore.target_temperature_; + this->target_temperature_low_ = restore.target_temperature_low_; + this->target_temperature_high_ = restore.target_temperature_high_; + this->state_ = restore.state_; + return *this; + } +}; + +class WaterHeaterTraits { + public: + /// Get/set feature flags (see WaterHeaterFeature enum) + void add_feature_flags(uint32_t flags) { this->feature_flags_ |= flags; } + void clear_feature_flags(uint32_t flags) { this->feature_flags_ &= ~flags; } + bool has_feature_flags(uint32_t flags) const { return (this->feature_flags_ & flags) == flags; } + uint32_t get_feature_flags() const { return this->feature_flags_; } + + bool get_supports_current_temperature() const { + return this->has_feature_flags(WATER_HEATER_SUPPORTS_CURRENT_TEMPERATURE); + } + void set_supports_current_temperature(bool supports) { + if (supports) { + this->add_feature_flags(WATER_HEATER_SUPPORTS_CURRENT_TEMPERATURE); + } else { + this->clear_feature_flags(WATER_HEATER_SUPPORTS_CURRENT_TEMPERATURE); + } + } + + bool get_supports_away_mode() const { return this->has_feature_flags(WATER_HEATER_SUPPORTS_AWAY_MODE); } + void set_supports_away_mode(bool supports) { + if (supports) { + this->add_feature_flags(WATER_HEATER_SUPPORTS_AWAY_MODE); + } else { + this->clear_feature_flags(WATER_HEATER_SUPPORTS_AWAY_MODE); + } + } + + bool get_supports_two_point_target_temperature() const { + return this->has_feature_flags(WATER_HEATER_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE); + } + void set_supports_two_point_target_temperature(bool supports) { + if (supports) { + this->add_feature_flags(WATER_HEATER_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE); + } else { + this->clear_feature_flags(WATER_HEATER_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE); + } + } + + void set_min_temperature(float min_temperature) { this->min_temperature_ = min_temperature; } + float get_min_temperature() const { return this->min_temperature_; } + + void set_max_temperature(float max_temperature) { this->max_temperature_ = max_temperature; } + float get_max_temperature() const { return this->max_temperature_; } + + void set_target_temperature_step(float target_temperature_step) { + this->target_temperature_step_ = target_temperature_step; + } + float get_target_temperature_step() const { return this->target_temperature_step_; } + + void set_supported_modes(WaterHeaterModeMask modes) { this->supported_modes_ = modes; } + const WaterHeaterModeMask &get_supported_modes() const { return this->supported_modes_; } + bool supports_mode(WaterHeaterMode mode) const { return this->supported_modes_.count(mode); } + + protected: + // Ordered to minimize padding: 4-byte members first + uint32_t feature_flags_{0}; + float min_temperature_{0.0f}; + float max_temperature_{0.0f}; + float target_temperature_step_{0.0f}; + WaterHeaterModeMask supported_modes_; +}; + +class WaterHeater : public EntityBase, public Component { + public: + WaterHeaterMode get_mode() const { return this->mode_; } + float get_current_temperature() const { return this->current_temperature_; } + 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 the current state flags bitmask + uint32_t get_state() const { return this->state_; } + /// Check if away mode is currently active + bool is_away() const { return (this->state_ & WATER_HEATER_STATE_AWAY) != 0; } + /// Check if the water heater is on + bool is_on() const { return (this->state_ & WATER_HEATER_STATE_ON) != 0; } + + void set_current_temperature(float current_temperature) { this->current_temperature_ = current_temperature; } + + virtual void publish_state(); + virtual WaterHeaterTraits get_traits(); + 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); +#endif + virtual void control(const WaterHeaterCall &call) = 0; + + void setup() override; + + optional restore_state(); + + protected: + virtual WaterHeaterTraits traits() = 0; + + /// Log the traits of this water heater for dump_config(). + void dump_traits_(const char *tag); + + /// Set the mode of the water heater. Should only be called from control(). + void set_mode_(WaterHeaterMode mode) { this->mode_ = mode; } + /// Set the target temperature of the water heater. Should only be called from control(). + void set_target_temperature_(float target_temperature) { this->target_temperature_ = target_temperature; } + /// Set the low target temperature (for two-point control). Should only be called from control(). + void set_target_temperature_low_(float target_temperature_low) { + this->target_temperature_low_ = target_temperature_low; + } + /// Set the high target temperature (for two-point control). Should only be called from control(). + void set_target_temperature_high_(float target_temperature_high) { + this->target_temperature_high_ = target_temperature_high; + } + /// Set the state flags. Should only be called from control(). + void set_state_(uint32_t state) { this->state_ = state; } + /// Set or clear a state flag. Should only be called from control(). + void set_state_flag_(uint32_t flag, bool value) { + if (value) { + this->state_ |= flag; + } else { + this->state_ &= ~flag; + } + } + + WaterHeaterMode mode_{WATER_HEATER_MODE_OFF}; + float current_temperature_{NAN}; + float target_temperature_{NAN}; + float target_temperature_low_{NAN}; + float target_temperature_high_{NAN}; + uint32_t state_{0}; // Bitmask of WaterHeaterStateFlag + +#ifdef USE_WATER_HEATER_VISUAL_OVERRIDES + float visual_min_temperature_override_{NAN}; + float visual_max_temperature_override_{NAN}; + float visual_target_temperature_step_override_{NAN}; +#endif + + ESPPreferenceObject pref_; +}; + +/// Convert the given WaterHeaterMode to a human-readable string for logging. +const LogString *water_heater_mode_to_string(WaterHeaterMode mode); + +} // namespace esphome::water_heater diff --git a/esphome/components/web_server/list_entities.cpp b/esphome/components/web_server/list_entities.cpp index 6b275455498..16b1d1e797e 100644 --- a/esphome/components/web_server/list_entities.cpp +++ b/esphome/components/web_server/list_entities.cpp @@ -135,6 +135,13 @@ bool ListEntitiesIterator::on_alarm_control_panel(alarm_control_panel::AlarmCont } #endif +#ifdef USE_WATER_HEATER +bool ListEntitiesIterator::on_water_heater(water_heater::WaterHeater *obj) { + // Water heater web_server support not yet implemented - this stub acknowledges the entity + return true; +} +#endif + #ifdef USE_EVENT bool ListEntitiesIterator::on_event(event::Event *obj) { // Null event type, since we are just iterating over entities diff --git a/esphome/components/web_server/list_entities.h b/esphome/components/web_server/list_entities.h index 43e1cc25448..5d9049b0823 100644 --- a/esphome/components/web_server/list_entities.h +++ b/esphome/components/web_server/list_entities.h @@ -79,6 +79,9 @@ class ListEntitiesIterator : public ComponentIterator { #ifdef USE_ALARM_CONTROL_PANEL bool on_alarm_control_panel(alarm_control_panel::AlarmControlPanel *obj) override; #endif +#ifdef USE_WATER_HEATER + bool on_water_heater(water_heater::WaterHeater *obj) override; +#endif #ifdef USE_EVENT bool on_event(event::Event *obj) override; #endif diff --git a/esphome/components/web_server/ota/ota_web_server.cpp b/esphome/components/web_server/ota/ota_web_server.cpp index 7929f3647f8..f612aa056c0 100644 --- a/esphome/components/web_server/ota/ota_web_server.cpp +++ b/esphome/components/web_server/ota/ota_web_server.cpp @@ -84,9 +84,9 @@ void OTARequestHandler::report_ota_progress_(AsyncWebServerRequest *request) { } else { ESP_LOGD(TAG, "OTA in progress: %" PRIu32 " bytes read", this->ota_read_length_); } -#ifdef USE_OTA_STATE_CALLBACK - // Report progress - use call_deferred since we're in web server task - this->parent_->state_callback_.call_deferred(ota::OTA_IN_PROGRESS, percentage, 0); +#ifdef USE_OTA_STATE_LISTENER + // Report progress - use notify_state_deferred_ since we're in web server task + this->parent_->notify_state_deferred_(ota::OTA_IN_PROGRESS, percentage, 0); #endif this->last_ota_progress_ = now; } @@ -114,9 +114,9 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf // Initialize OTA on first call this->ota_init_(filename.c_str()); -#ifdef USE_OTA_STATE_CALLBACK - // Notify OTA started - use call_deferred since we're in web server task - this->parent_->state_callback_.call_deferred(ota::OTA_STARTED, 0.0f, 0); +#ifdef USE_OTA_STATE_LISTENER + // Notify OTA started - use notify_state_deferred_ since we're in web server task + this->parent_->notify_state_deferred_(ota::OTA_STARTED, 0.0f, 0); #endif // Platform-specific pre-initialization @@ -134,9 +134,9 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf this->ota_backend_ = ota::make_ota_backend(); if (!this->ota_backend_) { ESP_LOGE(TAG, "Failed to create OTA backend"); -#ifdef USE_OTA_STATE_CALLBACK - this->parent_->state_callback_.call_deferred(ota::OTA_ERROR, 0.0f, - static_cast(ota::OTA_RESPONSE_ERROR_UNKNOWN)); +#ifdef USE_OTA_STATE_LISTENER + this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, + static_cast(ota::OTA_RESPONSE_ERROR_UNKNOWN)); #endif return; } @@ -148,8 +148,8 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf if (error_code != ota::OTA_RESPONSE_OK) { ESP_LOGE(TAG, "OTA begin failed: %d", error_code); this->ota_backend_.reset(); -#ifdef USE_OTA_STATE_CALLBACK - this->parent_->state_callback_.call_deferred(ota::OTA_ERROR, 0.0f, static_cast(error_code)); +#ifdef USE_OTA_STATE_LISTENER + this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast(error_code)); #endif return; } @@ -166,8 +166,8 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf ESP_LOGE(TAG, "OTA write failed: %d", error_code); this->ota_backend_->abort(); this->ota_backend_.reset(); -#ifdef USE_OTA_STATE_CALLBACK - this->parent_->state_callback_.call_deferred(ota::OTA_ERROR, 0.0f, static_cast(error_code)); +#ifdef USE_OTA_STATE_LISTENER + this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast(error_code)); #endif return; } @@ -186,15 +186,15 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf error_code = this->ota_backend_->end(); if (error_code == ota::OTA_RESPONSE_OK) { this->ota_success_ = true; -#ifdef USE_OTA_STATE_CALLBACK - // Report completion before reboot - use call_deferred since we're in web server task - this->parent_->state_callback_.call_deferred(ota::OTA_COMPLETED, 100.0f, 0); +#ifdef USE_OTA_STATE_LISTENER + // Report completion before reboot - use notify_state_deferred_ since we're in web server task + this->parent_->notify_state_deferred_(ota::OTA_COMPLETED, 100.0f, 0); #endif this->schedule_ota_reboot_(); } else { ESP_LOGE(TAG, "OTA end failed: %d", error_code); -#ifdef USE_OTA_STATE_CALLBACK - this->parent_->state_callback_.call_deferred(ota::OTA_ERROR, 0.0f, static_cast(error_code)); +#ifdef USE_OTA_STATE_LISTENER + this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast(error_code)); #endif } this->ota_backend_.reset(); @@ -232,10 +232,6 @@ void WebServerOTAComponent::setup() { // AsyncWebServer takes ownership of the handler and will delete it when the server is destroyed base->add_handler(new OTARequestHandler(this)); // NOLINT -#ifdef USE_OTA_STATE_CALLBACK - // Register with global OTA callback system - ota::register_ota_platform(this); -#endif } void WebServerOTAComponent::dump_config() { ESP_LOGCONFIG(TAG, "Web Server OTA"); } diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 0c22c2f08d2..207eafad5c8 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -404,9 +404,11 @@ void WebServer::handle_js_request(AsyncWebServerRequest *request) { // Helper functions to reduce code size by avoiding macro expansion static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, JsonDetail start_config) { - char id_buf[160]; // object_id can be up to 128 chars + prefix + dash + null - const auto &object_id = obj->get_object_id(); - snprintf(id_buf, sizeof(id_buf), "%s-%s", prefix, object_id.c_str()); + char id_buf[160]; // prefix + dash + object_id (up to 128) + null + size_t len = strlen(prefix); + memcpy(id_buf, prefix, len); // NOLINT(bugprone-not-null-terminated-result) - null added by write_object_id_to + id_buf[len++] = '-'; + obj->write_object_id_to(id_buf + len, sizeof(id_buf) - len); root[ESPHOME_F("id")] = id_buf; if (start_config == DETAIL_ALL) { root[ESPHOME_F("name")] = obj->get_name(); @@ -1042,7 +1044,13 @@ std::string WebServer::date_json(datetime::DateEntity *obj, JsonDetail start_con json::JsonBuilder builder; JsonObject root = builder.root(); - std::string value = str_sprintf("%d-%02d-%02d", obj->year, obj->month, obj->day); + // Format: YYYY-MM-DD (max 10 chars + null) + char value[12]; +#ifdef USE_ESP8266 + snprintf_P(value, sizeof(value), PSTR("%d-%02d-%02d"), obj->year, obj->month, obj->day); +#else + snprintf(value, sizeof(value), "%d-%02d-%02d", obj->year, obj->month, obj->day); +#endif set_json_icon_state_value(root, obj, "date", value, value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); @@ -1098,7 +1106,13 @@ std::string WebServer::time_json(datetime::TimeEntity *obj, JsonDetail start_con json::JsonBuilder builder; JsonObject root = builder.root(); - std::string value = str_sprintf("%02d:%02d:%02d", obj->hour, obj->minute, obj->second); + // Format: HH:MM:SS (8 chars + null) + char value[12]; +#ifdef USE_ESP8266 + snprintf_P(value, sizeof(value), PSTR("%02d:%02d:%02d"), obj->hour, obj->minute, obj->second); +#else + snprintf(value, sizeof(value), "%02d:%02d:%02d", obj->hour, obj->minute, obj->second); +#endif set_json_icon_state_value(root, obj, "time", value, value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); @@ -1154,8 +1168,15 @@ std::string WebServer::datetime_json(datetime::DateTimeEntity *obj, JsonDetail s json::JsonBuilder builder; JsonObject root = builder.root(); - std::string value = - str_sprintf("%d-%02d-%02d %02d:%02d:%02d", obj->year, obj->month, obj->day, obj->hour, obj->minute, obj->second); + // Format: YYYY-MM-DD HH:MM:SS (max 19 chars + null) + char value[24]; +#ifdef USE_ESP8266 + snprintf_P(value, sizeof(value), PSTR("%d-%02d-%02d %02d:%02d:%02d"), obj->year, obj->month, obj->day, obj->hour, + obj->minute, obj->second); +#else + snprintf(value, sizeof(value), "%d-%02d-%02d %02d:%02d:%02d", obj->year, obj->month, obj->day, obj->hour, obj->minute, + obj->second); +#endif set_json_icon_state_value(root, obj, "datetime", value, value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index bb69d578726..98234ec1ae2 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -52,14 +52,10 @@ struct UrlMatch { } bool id_equals_entity(EntityBase *entity) const { - // Zero-copy comparison using StringRef - StringRef static_ref = entity->get_object_id_ref_for_api_(); - if (!static_ref.empty()) { - return id && id_len == static_ref.size() && memcmp(id, static_ref.c_str(), id_len) == 0; - } - // Fallback to allocation (rare) - const auto &obj_id = entity->get_object_id(); - return id && id_len == obj_id.length() && memcmp(id, obj_id.c_str(), id_len) == 0; + // Get object_id with zero heap allocation + char object_id_buf[OBJECT_ID_MAX_LEN]; + StringRef object_id = entity->get_object_id_to(object_id_buf); + return id && id_len == object_id.size() && memcmp(id, object_id.c_str(), id_len) == 0; } bool method_equals(const char *str) const { diff --git a/esphome/components/web_server/web_server_v1.cpp b/esphome/components/web_server/web_server_v1.cpp index 4f0d0cd1a95..cbc25b9dec4 100644 --- a/esphome/components/web_server/web_server_v1.cpp +++ b/esphome/components/web_server/web_server_v1.cpp @@ -15,7 +15,8 @@ void write_row(AsyncResponseStream *stream, EntityBase *obj, const std::string & stream->print("\" id=\""); stream->print(klass.c_str()); stream->print("-"); - stream->print(obj->get_object_id().c_str()); + char object_id_buf[OBJECT_ID_MAX_LEN]; + stream->print(obj->get_object_id_to(object_id_buf).c_str()); stream->print("\">"); stream->print(obj->get_name().c_str()); stream->print(""); diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index af99b85e53a..3d76b86a14d 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -117,18 +117,6 @@ void AsyncWebServer::end() { } } -void AsyncWebServer::set_lru_purge_enable(bool enable) { - if (this->lru_purge_enable_ == enable) { - return; // No change needed - } - this->lru_purge_enable_ = enable; - // If server is already running, restart it with new config - if (this->server_) { - this->end(); - this->begin(); - } -} - void AsyncWebServer::begin() { if (this->server_) { this->end(); @@ -136,8 +124,11 @@ void AsyncWebServer::begin() { httpd_config_t config = HTTPD_DEFAULT_CONFIG(); config.server_port = this->port_; config.uri_match_fn = [](const char * /*unused*/, const char * /*unused*/, size_t /*unused*/) { return true; }; - // Enable LRU purging if requested (e.g., by captive portal to handle probe bursts) - config.lru_purge_enable = this->lru_purge_enable_; + // Always enable LRU purging to handle socket exhaustion gracefully. + // When max sockets is reached, the oldest connection is closed to make room for new ones. + // This prevents "httpd_accept_conn: error in accept (23)" errors. + // See: https://github.com/esphome/esphome/issues/12464 + config.lru_purge_enable = true; // Use custom close function that shuts down before closing to prevent lwIP race conditions config.close_fn = AsyncWebServer::safe_close_with_shutdown; if (httpd_start(&this->server_, &config) == ESP_OK) { @@ -352,8 +343,9 @@ bool AsyncWebServerRequest::authenticate(const char *username, const char *passw void AsyncWebServerRequest::requestAuthentication(const char *realm) const { httpd_resp_set_hdr(*this, "Connection", "keep-alive"); - auto auth_val = str_sprintf("Basic realm=\"%s\"", realm ? realm : "Login Required"); - httpd_resp_set_hdr(*this, "WWW-Authenticate", auth_val.c_str()); + // Note: realm is never configured in ESPHome, always nullptr -> "Login Required" + (void) realm; // Unused - always use default + httpd_resp_set_hdr(*this, "WWW-Authenticate", "Basic realm=\"Login Required\""); httpd_resp_send_err(*this, HTTPD_401_UNAUTHORIZED, nullptr); } #endif diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index a139e9e4dfb..5f9f5983882 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -199,13 +199,11 @@ class AsyncWebServer { return *handler; } - void set_lru_purge_enable(bool enable); httpd_handle_t get_server() { return this->server_; } protected: uint16_t port_{}; httpd_handle_t server_{}; - bool lru_purge_enable_{false}; static esp_err_t request_handler(httpd_req_t *r); static esp_err_t request_post_handler(httpd_req_t *r); esp_err_t request_handler_(AsyncWebServerRequest *request) const; diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index d46916bfd93..242265344dd 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -2,6 +2,7 @@ #ifdef USE_WIFI #include #include +#include #ifdef USE_ESP32 #if (ESP_IDF_VERSION_MAJOR >= 5 && ESP_IDF_VERSION_MINOR >= 1) @@ -205,6 +206,21 @@ static constexpr uint32_t WIFI_COOLDOWN_DURATION_MS = 500; /// While connecting, WiFi can't beacon the AP properly, so needs longer cooldown static constexpr uint32_t WIFI_COOLDOWN_WITH_AP_ACTIVE_MS = 30000; +/// Timeout for WiFi scan operations +/// This is a fallback in case we don't receive a scan done callback from the WiFi driver. +/// Normal scans complete via callback; this only triggers if something goes wrong. +static constexpr uint32_t WIFI_SCAN_TIMEOUT_MS = 31000; + +/// Timeout for WiFi connection attempts +/// This is a fallback in case we don't receive connection success/failure callbacks. +/// Some platforms (especially LibreTiny/Beken) can take 30-60 seconds to connect, +/// particularly with fast_connect enabled where no prior scan provides channel info. +/// Do not lower this value - connection failures are detected via callbacks, not timeout. +/// If this timeout fires prematurely while a connection is still in progress, it causes +/// cascading failures: the subsequent scan will also fail because the WiFi driver is +/// still busy with the previous connection attempt. +static constexpr uint32_t WIFI_CONNECT_TIMEOUT_MS = 46000; + static constexpr uint8_t get_max_retries_for_phase(WiFiRetryPhase phase) { switch (phase) { case WiFiRetryPhase::INITIAL_CONNECT: @@ -360,7 +376,7 @@ void WiFiComponent::start() { get_mac_address_pretty_into_buffer(mac_s)); this->last_connected_ = millis(); - uint32_t hash = this->has_sta() ? fnv1_hash(App.get_compilation_time_ref().c_str()) : 88491487UL; + uint32_t hash = this->has_sta() ? App.get_config_version_hash() : 88491487UL; this->pref_ = global_preferences->make_preference(hash, true); #ifdef USE_WIFI_FAST_CONNECT @@ -379,7 +395,7 @@ void WiFiComponent::start() { if (this->has_sta()) { this->wifi_sta_pre_setup_(); - if (this->output_power_.has_value() && !this->wifi_apply_output_power_(*this->output_power_)) { + if (!std::isnan(this->output_power_) && !this->wifi_apply_output_power_(this->output_power_)) { ESP_LOGV(TAG, "Setting Output Power Option failed"); } @@ -426,7 +442,7 @@ void WiFiComponent::start() { #ifdef USE_WIFI_AP } else if (this->has_ap()) { this->setup_ap_config_(); - if (this->output_power_.has_value() && !this->wifi_apply_output_power_(*this->output_power_)) { + if (!std::isnan(this->output_power_) && !this->wifi_apply_output_power_(this->output_power_)) { ESP_LOGV(TAG, "Setting Output Power Option failed"); } #ifdef USE_CAPTIVE_PORTAL @@ -698,8 +714,8 @@ WiFiAP WiFiComponent::build_params_for_current_phase_() { case WiFiRetryPhase::RETRY_HIDDEN: // Hidden network mode: clear BSSID/channel to trigger probe request // (both explicit hidden and retry hidden use same behavior) - params.set_bssid(optional{}); - params.set_channel(optional{}); + params.clear_bssid(); + params.clear_channel(); break; case WiFiRetryPhase::SCAN_CONNECTING: @@ -751,21 +767,20 @@ void WiFiComponent::start_connecting(const WiFiAP &ap) { char bssid_s[18]; int8_t priority = 0; - if (ap.get_bssid().has_value()) { - format_mac_addr_upper(ap.get_bssid().value().data(), bssid_s); - priority = this->get_sta_priority(ap.get_bssid().value()); + if (ap.has_bssid()) { + format_mac_addr_upper(ap.get_bssid().data(), bssid_s); + priority = this->get_sta_priority(ap.get_bssid()); } ESP_LOGI(TAG, "Connecting to " LOG_SECRET("'%s'") " " LOG_SECRET("(%s)") " (priority %d, attempt %u/%u in phase %s)...", - ap.get_ssid().c_str(), ap.get_bssid().has_value() ? bssid_s : LOG_STR_LITERAL("any"), priority, - this->num_retried_ + 1, get_max_retries_for_phase(this->retry_phase_), - LOG_STR_ARG(retry_phase_to_log_string(this->retry_phase_))); + ap.get_ssid().c_str(), ap.has_bssid() ? bssid_s : LOG_STR_LITERAL("any"), priority, this->num_retried_ + 1, + get_max_retries_for_phase(this->retry_phase_), LOG_STR_ARG(retry_phase_to_log_string(this->retry_phase_))); #ifdef ESPHOME_LOG_HAS_VERBOSE ESP_LOGV(TAG, "Connection Params:"); ESP_LOGV(TAG, " SSID: '%s'", ap.get_ssid().c_str()); - if (ap.get_bssid().has_value()) { + if (ap.has_bssid()) { ESP_LOGV(TAG, " BSSID: %s", bssid_s); } else { ESP_LOGV(TAG, " BSSID: Not Set"); @@ -793,8 +808,8 @@ void WiFiComponent::start_connecting(const WiFiAP &ap) { #ifdef USE_WIFI_WPA2_EAP } #endif - if (ap.get_channel().has_value()) { - ESP_LOGV(TAG, " Channel: %u", *ap.get_channel()); + if (ap.has_channel()) { + ESP_LOGV(TAG, " Channel: %u", ap.get_channel()); } else { ESP_LOGV(TAG, " Channel not set"); } @@ -904,8 +919,8 @@ void WiFiComponent::print_connect_params_() { get_wifi_channel(), wifi_subnet_mask_().str().c_str(), wifi_gateway_ip_().str().c_str(), wifi_dns_ip_(0).str().c_str(), wifi_dns_ip_(1).str().c_str()); #ifdef ESPHOME_LOG_HAS_VERBOSE - if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_bssid().has_value()) { - ESP_LOGV(TAG, " Priority: %d", this->get_sta_priority(*config->get_bssid())); + if (const WiFiAP *config = this->get_selected_sta_(); config && config->has_bssid()) { + ESP_LOGV(TAG, " Priority: %d", this->get_sta_priority(config->get_bssid())); } #endif #ifdef USE_WIFI_11KV_SUPPORT @@ -1016,26 +1031,33 @@ template static void insertion_sort_scan_results(VectorType } } -// Helper function to log scan results - marked noinline to prevent re-inlining into loop +// Helper function to log matching scan results - marked noinline to prevent re-inlining into loop __attribute__((noinline)) static void log_scan_result(const WiFiScanResult &res) { char bssid_s[18]; auto bssid = res.get_bssid(); format_mac_addr_upper(bssid.data(), bssid_s); - if (res.get_matches()) { - ESP_LOGI(TAG, "- '%s' %s" LOG_SECRET("(%s) ") "%s", res.get_ssid().c_str(), - res.get_is_hidden() ? LOG_STR_LITERAL("(HIDDEN) ") : LOG_STR_LITERAL(""), bssid_s, - LOG_STR_ARG(get_signal_bars(res.get_rssi()))); - ESP_LOGD(TAG, " Channel: %2u, RSSI: %3d dB, Priority: %4d", res.get_channel(), res.get_rssi(), res.get_priority()); - } else { - ESP_LOGD(TAG, "- " LOG_SECRET("'%s'") " " LOG_SECRET("(%s) ") "%s", res.get_ssid().c_str(), bssid_s, - LOG_STR_ARG(get_signal_bars(res.get_rssi()))); - } + ESP_LOGI(TAG, "- '%s' %s" LOG_SECRET("(%s) ") "%s", res.get_ssid().c_str(), + res.get_is_hidden() ? LOG_STR_LITERAL("(HIDDEN) ") : LOG_STR_LITERAL(""), bssid_s, + LOG_STR_ARG(get_signal_bars(res.get_rssi()))); + ESP_LOGD(TAG, " Channel: %2u, RSSI: %3d dB, Priority: %4d", res.get_channel(), res.get_rssi(), res.get_priority()); } +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE +// Helper function to log non-matching scan results at verbose level +__attribute__((noinline)) static void log_scan_result_non_matching(const WiFiScanResult &res) { + char bssid_s[18]; + auto bssid = res.get_bssid(); + format_mac_addr_upper(bssid.data(), bssid_s); + + ESP_LOGV(TAG, "- " LOG_SECRET("'%s'") " " LOG_SECRET("(%s) ") "%s", res.get_ssid().c_str(), bssid_s, + LOG_STR_ARG(get_signal_bars(res.get_rssi()))); +} +#endif + void WiFiComponent::check_scanning_finished() { if (!this->scan_done_) { - if (millis() - this->action_started_ > 30000) { + if (millis() - this->action_started_ > WIFI_SCAN_TIMEOUT_MS) { ESP_LOGE(TAG, "Scan timeout"); this->retry_connect(); } @@ -1069,8 +1091,20 @@ void WiFiComponent::check_scanning_finished() { // Sort scan results using insertion sort for better memory efficiency insertion_sort_scan_results(this->scan_result_); + size_t non_matching_count = 0; for (auto &res : this->scan_result_) { - log_scan_result(res); + if (res.get_matches()) { + log_scan_result(res); + } else { +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + log_scan_result_non_matching(res); +#else + non_matching_count++; +#endif + } + } + if (non_matching_count > 0) { + ESP_LOGD(TAG, "- %zu non-matching (VERBOSE to show)", non_matching_count); } // SYNCHRONIZATION POINT: Establish link between scan_result_[0] and selected_sta_index_ @@ -1184,8 +1218,9 @@ void WiFiComponent::check_connecting_finished() { } uint32_t now = millis(); - if (now - this->action_started_ > 30000) { - ESP_LOGW(TAG, "Connection timeout"); + if (now - this->action_started_ > WIFI_CONNECT_TIMEOUT_MS) { + ESP_LOGW(TAG, "Connection timeout, aborting connection attempt"); + this->wifi_disconnect_(); this->retry_connect(); return; } @@ -1405,6 +1440,10 @@ bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) { // without disrupting the captive portal/improv connection if (!this->is_captive_portal_active_() && !this->is_esp32_improv_active_()) { this->restart_adapter(); + } else { + // Even when skipping full restart, disconnect to clear driver state + // Without this, platforms like LibreTiny may think we're still connecting + this->wifi_disconnect_(); } // Clear scan flag - we're starting a new retry cycle this->did_scan_this_cycle_ = false; @@ -1475,9 +1514,9 @@ void WiFiComponent::log_and_adjust_priority_for_failed_connect_() { if (this->retry_phase_ == WiFiRetryPhase::SCAN_CONNECTING && !this->scan_result_.empty()) { // Scan-based phase: always use best result (index 0) failed_bssid = this->scan_result_[0].get_bssid(); - } else if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_bssid()) { + } else if (const WiFiAP *config = this->get_selected_sta_(); config && config->has_bssid()) { // Config has specific BSSID (fast_connect or user-specified) - failed_bssid = *config->get_bssid(); + failed_bssid = config->get_bssid(); } if (!failed_bssid.has_value()) { @@ -1745,24 +1784,27 @@ void WiFiComponent::save_fast_connect_settings_() { #endif void WiFiAP::set_ssid(const std::string &ssid) { this->ssid_ = ssid; } -void WiFiAP::set_bssid(bssid_t bssid) { this->bssid_ = bssid; } -void WiFiAP::set_bssid(optional bssid) { this->bssid_ = bssid; } +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_ = password; } #ifdef USE_WIFI_WPA2_EAP void WiFiAP::set_eap(optional eap_auth) { this->eap_ = std::move(eap_auth); } #endif -void WiFiAP::set_channel(optional channel) { this->channel_ = channel; } +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 std::string &WiFiAP::get_ssid() const { return this->ssid_; } -const optional &WiFiAP::get_bssid() const { return this->bssid_; } +const bssid_t &WiFiAP::get_bssid() const { return this->bssid_; } +bool WiFiAP::has_bssid() const { return this->bssid_ != bssid_t{}; } const std::string &WiFiAP::get_password() const { return this->password_; } #ifdef USE_WIFI_WPA2_EAP const optional &WiFiAP::get_eap() const { return this->eap_; } #endif -const optional &WiFiAP::get_channel() const { return this->channel_; } +uint8_t WiFiAP::get_channel() const { return this->channel_; } +bool WiFiAP::has_channel() const { return this->channel_ != 0; } #ifdef USE_WIFI_MANUAL_IP const optional &WiFiAP::get_manual_ip() const { return this->manual_ip_; } #endif @@ -1790,7 +1832,7 @@ bool WiFiScanResult::matches(const WiFiAP &config) const { // network is configured without SSID - match other settings } // If BSSID configured, only match for correct BSSIDs - if (config.get_bssid().has_value() && *config.get_bssid() != this->bssid_) + if (config.has_bssid() && config.get_bssid() != this->bssid_) return false; #ifdef USE_WIFI_WPA2_EAP @@ -1808,7 +1850,7 @@ bool WiFiScanResult::matches(const WiFiAP &config) const { #endif // If channel configured, only match networks on that channel. - if (config.get_channel().has_value() && *config.get_channel() != this->channel_) { + if (config.has_channel() && config.get_channel() != this->channel_) { return false; } return true; diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index be94e9462b1..604efa8a7ef 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -151,25 +151,28 @@ template using wifi_scan_vector_t = FixedVector; class WiFiAP { public: void set_ssid(const std::string &ssid); - void set_bssid(bssid_t bssid); - void set_bssid(optional bssid); + void set_bssid(const bssid_t &bssid); + void clear_bssid(); void set_password(const std::string &password); #ifdef USE_WIFI_WPA2_EAP void set_eap(optional eap_auth); #endif // USE_WIFI_WPA2_EAP - void set_channel(optional channel); + void set_channel(uint8_t channel); + void clear_channel(); void set_priority(int8_t priority) { priority_ = priority; } #ifdef USE_WIFI_MANUAL_IP void set_manual_ip(optional manual_ip); #endif void set_hidden(bool hidden); const std::string &get_ssid() const; - const optional &get_bssid() const; + const bssid_t &get_bssid() const; + bool has_bssid() const; const std::string &get_password() const; #ifdef USE_WIFI_WPA2_EAP const optional &get_eap() const; #endif // USE_WIFI_WPA2_EAP - const optional &get_channel() const; + uint8_t get_channel() const; + bool has_channel() const; int8_t get_priority() const { return priority_; } #ifdef USE_WIFI_MANUAL_IP const optional &get_manual_ip() const; @@ -179,16 +182,17 @@ class WiFiAP { protected: std::string ssid_; std::string password_; - optional bssid_; #ifdef USE_WIFI_WPA2_EAP optional eap_; #endif // USE_WIFI_WPA2_EAP #ifdef USE_WIFI_MANUAL_IP optional manual_ip_; #endif - optional channel_; - int8_t priority_{0}; - bool hidden_{false}; + // Group small types together to minimize padding + bssid_t bssid_{}; // 6 bytes, all zeros = any/not set + uint8_t channel_{0}; // 1 byte, 0 = auto/not set + int8_t priority_{0}; // 1 byte + bool hidden_{false}; // 1 byte (+ 3 bytes end padding to 4-byte align) }; class WiFiScanResult { @@ -590,7 +594,7 @@ class WiFiComponent : public Component { #ifdef USE_WIFI_AP WiFiAP ap_; #endif - optional output_power_; + float output_power_{NAN}; #ifdef USE_WIFI_LISTENERS std::vector ip_state_listeners_; std::vector scan_results_listeners_; diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 3b1a442bdbd..550b5579ff5 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -257,9 +257,9 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { memcpy(reinterpret_cast(conf.ssid), ap.get_ssid().c_str(), ap.get_ssid().size()); memcpy(reinterpret_cast(conf.password), ap.get_password().c_str(), ap.get_password().size()); - if (ap.get_bssid().has_value()) { + if (ap.has_bssid()) { conf.bssid_set = 1; - memcpy(conf.bssid, ap.get_bssid()->data(), 6); + memcpy(conf.bssid, ap.get_bssid().data(), 6); } else { conf.bssid_set = 0; } @@ -381,8 +381,8 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { } #endif /* USE_NETWORK_IPV6 */ - if (ap.get_channel().has_value()) { - ret = wifi_set_channel(*ap.get_channel()); + if (ap.has_channel()) { + ret = wifi_set_channel(ap.get_channel()); if (!ret) { ESP_LOGV(TAG, "wifi_set_channel failed"); return false; @@ -528,6 +528,16 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { for (auto *listener : global_wifi_component->connect_state_listeners_) { listener->on_wifi_connect_state(global_wifi_component->wifi_ssid(), global_wifi_component->wifi_bssid()); } + // For static IP configurations, GOT_IP event may not fire, so notify IP listeners here +#ifdef USE_WIFI_MANUAL_IP + if (const WiFiAP *config = global_wifi_component->get_selected_sta_(); + config && config->get_manual_ip().has_value()) { + for (auto *listener : global_wifi_component->ip_state_listeners_) { + listener->on_ip_state(global_wifi_component->wifi_sta_ip_addresses(), + global_wifi_component->get_dns_address(0), global_wifi_component->get_dns_address(1)); + } + } +#endif #endif break; } @@ -845,7 +855,7 @@ bool WiFiComponent::wifi_start_ap_(const WiFiAP &ap) { } memcpy(reinterpret_cast(conf.ssid), ap.get_ssid().c_str(), ap.get_ssid().size()); conf.ssid_len = static_cast(ap.get_ssid().size()); - conf.channel = ap.get_channel().value_or(1); + conf.channel = ap.has_channel() ? ap.get_channel() : 1; conf.ssid_hidden = ap.get_hidden(); conf.max_connection = 5; conf.beacon_interval = 100; diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 1f4eb1e42c1..212514af934 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -339,14 +339,14 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { conf.sta.rm_enabled = this->rrm_; #endif - if (ap.get_bssid().has_value()) { + if (ap.has_bssid()) { conf.sta.bssid_set = true; - memcpy(conf.sta.bssid, ap.get_bssid()->data(), 6); + memcpy(conf.sta.bssid, ap.get_bssid().data(), 6); } else { conf.sta.bssid_set = false; } - if (ap.get_channel().has_value()) { - conf.sta.channel = *ap.get_channel(); + if (ap.has_channel()) { + conf.sta.channel = ap.get_channel(); conf.sta.scan_method = WIFI_FAST_SCAN; } else { conf.sta.scan_method = WIFI_ALL_CHANNEL_SCAN; @@ -720,6 +720,7 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_STA_STOP) { ESP_LOGV(TAG, "STA stop"); s_sta_started = false; + s_sta_connecting = false; } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_STA_AUTHMODE_CHANGE) { const auto &it = data->data.sta_authmode_change; @@ -738,6 +739,14 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { for (auto *listener : this->connect_state_listeners_) { listener->on_wifi_connect_state(this->wifi_ssid(), this->wifi_bssid()); } + // For static IP configurations, GOT_IP event may not fire, so notify IP listeners here +#ifdef USE_WIFI_MANUAL_IP + if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_manual_ip().has_value()) { + for (auto *listener : this->ip_state_listeners_) { + listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1)); + } + } +#endif #endif } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_STA_DISCONNECTED) { @@ -1002,7 +1011,7 @@ bool WiFiComponent::wifi_start_ap_(const WiFiAP &ap) { return false; } memcpy(reinterpret_cast(conf.ap.ssid), ap.get_ssid().c_str(), ap.get_ssid().size()); - conf.ap.channel = ap.get_channel().value_or(1); + conf.ap.channel = ap.has_channel() ? ap.get_channel() : 1; conf.ap.ssid_hidden = ap.get_ssid().size(); conf.ap.max_connection = 5; conf.ap.beacon_interval = 100; diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 4fd64bdfa36..340537b2289 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -139,8 +139,8 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { s_sta_connecting = true; WiFiStatus status = WiFi.begin(ap.get_ssid().c_str(), ap.get_password().empty() ? NULL : ap.get_password().c_str(), - ap.get_channel().has_value() ? *ap.get_channel() : 0, - ap.get_bssid().has_value() ? ap.get_bssid()->data() : NULL); + ap.get_channel(), // 0 = auto + ap.has_bssid() ? ap.get_bssid().data() : NULL); if (status != WL_CONNECTED) { ESP_LOGW(TAG, "esp_wifi_connect failed: %d", status); return false; @@ -291,6 +291,7 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ } case ESPHOME_EVENT_ID_WIFI_STA_STOP: { ESP_LOGV(TAG, "STA stop"); + s_sta_connecting = false; break; } case ESPHOME_EVENT_ID_WIFI_STA_CONNECTED: { @@ -304,6 +305,14 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ for (auto *listener : this->connect_state_listeners_) { listener->on_wifi_connect_state(this->wifi_ssid(), this->wifi_bssid()); } + // For static IP configurations, GOT_IP event may not fire, so notify IP listeners here +#ifdef USE_WIFI_MANUAL_IP + if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_manual_ip().has_value()) { + for (auto *listener : this->ip_state_listeners_) { + listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1)); + } + } +#endif #endif break; } @@ -322,7 +331,7 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ // wifi_sta_connect_status_() to return IDLE. The main loop then sees // "Unknown connection status 0" (wifi_component.cpp check_connecting_finished) // and calls retry_connect(), aborting a connection that may succeed moments later. - // Real connection failures will have ssid/bssid populated, or we'll hit the 30s timeout. + // Real connection failures will have ssid/bssid populated, or we'll hit the connection timeout. if (it.ssid_len == 0 && s_sta_connecting) { ESP_LOGV(TAG, "Ignoring disconnect event with empty ssid while connecting (reason=%s)", get_disconnect_reason_str(it.reason)); @@ -521,13 +530,18 @@ bool WiFiComponent::wifi_start_ap_(const WiFiAP &ap) { yield(); return WiFi.softAP(ap.get_ssid().c_str(), ap.get_password().empty() ? NULL : ap.get_password().c_str(), - ap.get_channel().value_or(1), ap.get_hidden()); + ap.has_channel() ? ap.get_channel() : 1, ap.get_hidden()); } network::IPAddress WiFiComponent::wifi_soft_ap_ip() { return {WiFi.softAPIP()}; } #endif // USE_WIFI_AP -bool WiFiComponent::wifi_disconnect_() { return WiFi.disconnect(); } +bool WiFiComponent::wifi_disconnect_() { + // Clear connecting flag first so disconnect events aren't ignored + // and wifi_sta_connect_status_() returns IDLE instead of CONNECTING + s_sta_connecting = false; + return WiFi.disconnect(); +} bssid_t WiFiComponent::wifi_bssid() { bssid_t bssid{}; diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index 02287554324..61709852ff4 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -192,7 +192,7 @@ bool WiFiComponent::wifi_start_ap_(const WiFiAP &ap) { } #endif - WiFi.beginAP(ap.get_ssid().c_str(), ap.get_password().c_str(), ap.get_channel().value_or(1)); + WiFi.beginAP(ap.get_ssid().c_str(), ap.get_password().c_str(), ap.has_channel() ? ap.get_channel() : 1); return true; } @@ -259,6 +259,15 @@ void WiFiComponent::wifi_loop_() { for (auto *listener : this->connect_state_listeners_) { listener->on_wifi_connect_state(this->wifi_ssid(), this->wifi_bssid()); } + // For static IP configurations, notify IP listeners immediately as the IP is already configured +#ifdef USE_WIFI_MANUAL_IP + if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_manual_ip().has_value()) { + s_sta_had_ip = true; + for (auto *listener : this->ip_state_listeners_) { + listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1)); + } + } +#endif #endif } else if (!is_connected && s_sta_was_connected) { // Just disconnected diff --git a/esphome/components/wifi_signal/wifi_signal_sensor.h b/esphome/components/wifi_signal/wifi_signal_sensor.h index 5d7f4b45624..9f581f1eb22 100644 --- a/esphome/components/wifi_signal/wifi_signal_sensor.h +++ b/esphome/components/wifi_signal/wifi_signal_sensor.h @@ -16,7 +16,12 @@ class WiFiSignalSensor : public sensor::Sensor, public PollingComponent { #ifdef USE_WIFI_LISTENERS void setup() override { wifi::global_wifi_component->add_connect_state_listener(this); } #endif - void update() override { this->publish_state(wifi::global_wifi_component->wifi_rssi()); } + void update() override { + int8_t rssi = wifi::global_wifi_component->wifi_rssi(); + if (rssi != wifi::WIFI_RSSI_DISCONNECTED) { + this->publish_state(rssi); + } + } void dump_config() override; float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } diff --git a/esphome/components/zwave_proxy/zwave_proxy.cpp b/esphome/components/zwave_proxy/zwave_proxy.cpp index e0ca5529b8e..bd3f85772ba 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.cpp +++ b/esphome/components/zwave_proxy/zwave_proxy.cpp @@ -1,4 +1,7 @@ #include "zwave_proxy.h" + +#ifdef USE_API + #include "esphome/components/api/api_server.h" #include "esphome/core/application.h" #include "esphome/core/helpers.h" @@ -344,3 +347,5 @@ bool ZWaveProxy::response_handler_() { ZWaveProxy *global_zwave_proxy = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) } // namespace esphome::zwave_proxy + +#endif // USE_API diff --git a/esphome/components/zwave_proxy/zwave_proxy.h b/esphome/components/zwave_proxy/zwave_proxy.h index e23e202bea5..137a1206e34 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.h +++ b/esphome/components/zwave_proxy/zwave_proxy.h @@ -1,5 +1,8 @@ #pragma once +#include "esphome/core/defines.h" +#ifdef USE_API + #include "esphome/components/api/api_connection.h" #include "esphome/components/api/api_pb2.h" #include "esphome/core/component.h" @@ -89,3 +92,5 @@ class ZWaveProxy : public uart::UARTDevice, public Component { extern ZWaveProxy *global_zwave_proxy; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) } // namespace esphome::zwave_proxy + +#endif // USE_API diff --git a/esphome/config.py b/esphome/config.py index 694716be349..6f6ad4886bb 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -1010,14 +1010,14 @@ def validate_config( result.add_error(err) return result - CORE.raw_config = config - # 1.1. Merge packages if CONF_PACKAGES in config: from esphome.components.packages import merge_packages config = merge_packages(config) + CORE.raw_config = config + # 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/config_validation.py b/esphome/config_validation.py index c52b791120f..08fffa6cec2 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -71,6 +71,7 @@ from esphome.const import ( PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_RP2040, + SCHEDULER_DONT_RUN, TYPE_GIT, TYPE_LOCAL, VALID_SUBSTITUTIONS_CHARACTERS, @@ -894,7 +895,7 @@ def time_period_in_minutes_(value): def update_interval(value): if value == "never": - return 4294967295 # uint32_t max + return TimePeriodMilliseconds(milliseconds=SCHEDULER_DONT_RUN) return positive_time_period_milliseconds(value) @@ -2009,7 +2010,7 @@ def polling_component_schema(default_update_interval): if default_update_interval is None: return COMPONENT_SCHEMA.extend( { - Required(CONF_UPDATE_INTERVAL): default_update_interval, + Required(CONF_UPDATE_INTERVAL): update_interval, } ) assert isinstance(default_update_interval, str) diff --git a/esphome/const.py b/esphome/const.py index c94ead0be43..1d46e81f9d1 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -123,6 +123,7 @@ CONF_ADDRESS = "address" CONF_ADDRESSABLE_LIGHT_ID = "addressable_light_id" CONF_ADVANCED = "advanced" CONF_AFTER = "after" +CONF_ALGORITHM_TUNING = "algorithm_tuning" CONF_ALL = "all" CONF_ALLOW_OTHER_USES = "allow_other_uses" CONF_ALPHA = "alpha" @@ -435,6 +436,7 @@ CONF_GAIN_FACTOR = "gain_factor" CONF_GAMMA_CORRECT = "gamma_correct" CONF_GAS_RESISTANCE = "gas_resistance" CONF_GATEWAY = "gateway" +CONF_GATING_MAX_DURATION_MINUTES = "gating_max_duration_minutes" CONF_GLASS_ATTENUATION_FACTOR = "glass_attenuation_factor" CONF_GLYPHS = "glyphs" CONF_GPIO = "gpio" @@ -497,6 +499,7 @@ CONF_INCLUDE_INTERNAL = "include_internal" CONF_INCLUDES = "includes" CONF_INCLUDES_C = "includes_c" CONF_INDEX = "index" +CONF_INDEX_OFFSET = "index_offset" CONF_INDOOR = "indoor" CONF_INFRARED = "infrared" CONF_INIT_SEQUENCE = "init_sequence" @@ -534,6 +537,8 @@ CONF_LAMBDA = "lambda" CONF_LAST_CONFIDENCE = "last_confidence" CONF_LAST_FINGER_ID = "last_finger_id" CONF_LATITUDE = "latitude" +CONF_LEARNING_TIME_GAIN_HOURS = "learning_time_gain_hours" +CONF_LEARNING_TIME_OFFSET_HOURS = "learning_time_offset_hours" CONF_LED = "led" CONF_LEGEND = "legend" CONF_LENGTH = "length" @@ -645,7 +650,9 @@ CONF_NEVER = "never" CONF_NEW_PASSWORD = "new_password" CONF_NITROGEN_DIOXIDE = "nitrogen_dioxide" CONF_NOISE_LEVEL = "noise_level" +CONF_NORMALIZED_OFFSET_SLOPE = "normalized_offset_slope" CONF_NOTIFY = "notify" +CONF_NOX = "nox" CONF_NUM_ATTEMPTS = "num_attempts" CONF_NUM_CHANNELS = "num_channels" CONF_NUM_CHIPS = "num_chips" @@ -672,6 +679,7 @@ CONF_ON_CLIENT_CONNECTED = "on_client_connected" CONF_ON_CLIENT_DISCONNECTED = "on_client_disconnected" CONF_ON_CONNECT = "on_connect" CONF_ON_CONTROL = "on_control" +CONF_ON_DATA = "on_data" CONF_ON_DIRECTION_SET = "on_direction_set" CONF_ON_DISCONNECT = "on_disconnect" CONF_ON_DOUBLE_CLICK = "on_double_click" @@ -939,6 +947,7 @@ CONF_STATE_TOPIC = "state_topic" CONF_STATIC_IP = "static_ip" CONF_STATUS = "status" CONF_STB_PIN = "stb_pin" +CONF_STD_INITIAL = "std_initial" CONF_STEP = "step" CONF_STEP_DELAY = "step_delay" CONF_STEP_MODE = "step_mode" @@ -1006,6 +1015,7 @@ CONF_TILT_COMMAND_TOPIC = "tilt_command_topic" CONF_TILT_LAMBDA = "tilt_lambda" CONF_TILT_STATE_TOPIC = "tilt_state_topic" CONF_TIME = "time" +CONF_TIME_CONSTANT = "time_constant" CONF_TIME_ID = "time_id" CONF_TIMEOUT = "timeout" CONF_TIMES = "times" @@ -1060,6 +1070,8 @@ CONF_VERSION = "version" CONF_VIBRATIONS = "vibrations" CONF_VISIBLE = "visible" CONF_VISUAL = "visual" +CONF_VOC = "voc" +CONF_VOC_BASELINE = "voc_baseline" CONF_VOLTAGE = "voltage" CONF_VOLTAGE_ATTENUATION = "voltage_attenuation" CONF_VOLTAGE_DIVIDER = "voltage_divider" @@ -1074,6 +1086,7 @@ CONF_WARM_WHITE = "warm_white" CONF_WARM_WHITE_COLOR_TEMPERATURE = "warm_white_color_temperature" CONF_WATCHDOG_THRESHOLD = "watchdog_threshold" CONF_WATCHDOG_TIMEOUT = "watchdog_timeout" +CONF_WATER_HEATER = "water_heater" CONF_WEB_SERVER = "web_server" CONF_WEB_SERVER_ID = "web_server_id" CONF_WEIGHT = "weight" @@ -1167,6 +1180,7 @@ ICON_TIMELAPSE = "mdi:timelapse" ICON_TIMER = "mdi:timer-outline" ICON_VIBRATE = "mdi:vibrate" ICON_WATER = "mdi:water" +ICON_WATER_HEATER = "mdi:water-boiler" ICON_WATER_PERCENT = "mdi:water-percent" ICON_WEATHER_SUNSET = "mdi:weather-sunset" ICON_WEATHER_SUNSET_DOWN = "mdi:weather-sunset-down" diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 721cd5787da..ad9844a3bf3 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -608,6 +608,8 @@ class EsphomeCore: self.current_component: str | None = None # Address cache for DNS and mDNS lookups from command line arguments self.address_cache: AddressCache | None = None + # Cached config hash (computed lazily) + self._config_hash: int | None = None def reset(self): from esphome.pins import PIN_SCHEMA_REGISTRY @@ -636,6 +638,7 @@ class EsphomeCore: self.unique_ids = {} self.current_component = None self.address_cache = None + self._config_hash = None PIN_SCHEMA_REGISTRY.reset() @contextmanager @@ -685,6 +688,21 @@ class EsphomeCore: return None + @property + def config_hash(self) -> int: + """Get the FNV-1a 32-bit hash of the config. + + The hash is computed lazily and cached for performance. + Uses sort_keys=True to ensure deterministic ordering. + """ + 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) + self._config_hash = fnv1a_32bit_hash(config_str) + return self._config_hash + @property def config_dir(self) -> Path: if self.config_path.is_dir(): diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index a85d671a070..4c9cc6b2b63 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -1,5 +1,12 @@ #include "esphome/core/application.h" +#include "esphome/core/build_info_data.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" +#include + +#ifdef USE_ESP8266 +#include +#endif #include "esphome/core/version.h" #include "esphome/core/hal.h" #include @@ -191,7 +198,9 @@ void Application::loop() { if (this->dump_config_at_ < this->components_.size()) { if (this->dump_config_at_ == 0) { - ESP_LOGI(TAG, "ESPHome version " ESPHOME_VERSION " compiled on %s", this->compilation_time_); + char build_time_str[Application::BUILD_TIME_STR_SIZE]; + this->get_build_time_string(build_time_str); + ESP_LOGI(TAG, "ESPHome version " ESPHOME_VERSION " compiled on %s", build_time_str); #ifdef ESPHOME_PROJECT_NAME ESP_LOGI(TAG, "Project " ESPHOME_PROJECT_NAME " version " ESPHOME_PROJECT_VERSION); #endif @@ -711,4 +720,9 @@ void Application::wake_loop_threadsafe() { } #endif // defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) +void Application::get_build_time_string(std::span buffer) { + ESPHOME_strncpy_P(buffer.data(), ESPHOME_BUILD_TIME_STR, buffer.size()); + buffer[buffer.size() - 1] = '\0'; +} + } // namespace esphome diff --git a/esphome/core/application.h b/esphome/core/application.h index 8e2035b7c5e..d2146a6c16a 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -1,9 +1,12 @@ #pragma once #include +#include #include +#include #include #include +#include "esphome/core/build_info_data.h" #include "esphome/core/component.h" #include "esphome/core/defines.h" #include "esphome/core/hal.h" @@ -11,6 +14,7 @@ #include "esphome/core/preferences.h" #include "esphome/core/scheduler.h" #include "esphome/core/string_ref.h" +#include "esphome/core/version.h" #ifdef USE_DEVICES #include "esphome/core/device.h" @@ -83,6 +87,9 @@ #ifdef USE_ALARM_CONTROL_PANEL #include "esphome/components/alarm_control_panel/alarm_control_panel.h" #endif +#ifdef USE_WATER_HEATER +#include "esphome/components/water_heater/water_heater.h" +#endif #ifdef USE_EVENT #include "esphome/components/event/event.h" #endif @@ -101,7 +108,7 @@ static const uint32_t TEARDOWN_TIMEOUT_REBOOT_MS = 1000; // 1 second for quick class Application { public: void pre_setup(const std::string &name, const std::string &friendly_name, const char *comment, - const char *compilation_time, bool name_add_mac_suffix) { + bool name_add_mac_suffix) { arch_init(); this->name_add_mac_suffix_ = name_add_mac_suffix; if (name_add_mac_suffix) { @@ -121,7 +128,6 @@ class Application { this->friendly_name_ = friendly_name; } this->comment_ = comment; - this->compilation_time_ = compilation_time; } #ifdef USE_DEVICES @@ -214,6 +220,10 @@ class Application { } #endif +#ifdef USE_WATER_HEATER + void register_water_heater(water_heater::WaterHeater *water_heater) { this->water_heaters_.push_back(water_heater); } +#endif + #ifdef USE_EVENT void register_event(event::Event *event) { this->events_.push_back(event); } #endif @@ -261,9 +271,30 @@ class Application { bool is_name_add_mac_suffix_enabled() const { return this->name_add_mac_suffix_; } - std::string get_compilation_time() const { return this->compilation_time_; } - /// Get the compilation time as StringRef (for API usage) - StringRef get_compilation_time_ref() const { return StringRef(this->compilation_time_); } + /// Size of buffer required for build time string (including null terminator) + static constexpr size_t BUILD_TIME_STR_SIZE = 26; + + /// Get the config hash as a 32-bit integer + constexpr uint32_t get_config_hash() { return ESPHOME_CONFIG_HASH; } + + /// Get the config hash extended with ESPHome version + constexpr uint32_t get_config_version_hash() { return fnv1a_hash_extend(ESPHOME_CONFIG_HASH, ESPHOME_VERSION); } + + /// Get the build time as a Unix timestamp + constexpr time_t get_build_time() { return ESPHOME_BUILD_TIME; } + + /// Copy the build time string into the provided buffer + /// 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_; } @@ -413,6 +444,11 @@ class Application { GET_ENTITY_METHOD(alarm_control_panel::AlarmControlPanel, alarm_control_panel, alarm_control_panels) #endif +#ifdef USE_WATER_HEATER + auto &get_water_heaters() const { return this->water_heaters_; } + GET_ENTITY_METHOD(water_heater::WaterHeater, water_heater, water_heaters) +#endif + #ifdef USE_EVENT auto &get_events() const { return this->events_; } GET_ENTITY_METHOD(event::Event, event, events) @@ -478,7 +514,6 @@ class Application { // Pointer-sized members first Component *current_component_{nullptr}; const char *comment_{nullptr}; - const char *compilation_time_{nullptr}; // std::vector (3 pointers each: begin, end, capacity) // Partitioned vector design for looping components @@ -611,6 +646,9 @@ class Application { StaticVector alarm_control_panels_{}; #endif +#ifdef USE_WATER_HEATER + StaticVector water_heaters_{}; +#endif #ifdef USE_UPDATE StaticVector updates_{}; #endif diff --git a/esphome/core/build_info_data.h b/esphome/core/build_info_data.h new file mode 100644 index 00000000000..5e424ffaca8 --- /dev/null +++ b/esphome/core/build_info_data.h @@ -0,0 +1,10 @@ +#pragma once + +// This file is not used by the runtime, instead, a version is generated during +// compilation with the actual build info values. +// +// This file is only used by static analyzers and IDEs. + +#define ESPHOME_CONFIG_HASH 0x12345678U // NOLINT +#define ESPHOME_BUILD_TIME 1700000000 // NOLINT +static const char ESPHOME_BUILD_TIME_STR[] = "2024-01-01 00:00:00 +0000"; diff --git a/esphome/core/component_iterator.cpp b/esphome/core/component_iterator.cpp index 8c6a7b95b55..4015d8ec604 100644 --- a/esphome/core/component_iterator.cpp +++ b/esphome/core/component_iterator.cpp @@ -163,6 +163,12 @@ void ComponentIterator::advance() { break; #endif +#ifdef USE_WATER_HEATER + case IteratorState::WATER_HEATER: + this->process_platform_item_(App.get_water_heaters(), &ComponentIterator::on_water_heater); + break; +#endif + #ifdef USE_EVENT case IteratorState::EVENT: this->process_platform_item_(App.get_events(), &ComponentIterator::on_event); diff --git a/esphome/core/component_iterator.h b/esphome/core/component_iterator.h index 1b1bd80ac50..37d19606015 100644 --- a/esphome/core/component_iterator.h +++ b/esphome/core/component_iterator.h @@ -84,6 +84,9 @@ class ComponentIterator { #ifdef USE_ALARM_CONTROL_PANEL virtual bool on_alarm_control_panel(alarm_control_panel::AlarmControlPanel *a_alarm_control_panel) = 0; #endif +#ifdef USE_WATER_HEATER + virtual bool on_water_heater(water_heater::WaterHeater *water_heater) = 0; +#endif #ifdef USE_EVENT virtual bool on_event(event::Event *event) = 0; #endif @@ -161,6 +164,9 @@ class ComponentIterator { #ifdef USE_ALARM_CONTROL_PANEL ALARM_CONTROL_PANEL, #endif +#ifdef USE_WATER_HEATER + WATER_HEATER, +#endif #ifdef USE_EVENT EVENT, #endif diff --git a/esphome/core/config.py b/esphome/core/config.py index 3adaf7eb9e1..507a39b4013 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -382,10 +382,15 @@ def include_file(path: Path, basename: Path, is_c_header: bool = False): ARDUINO_GLUE_CODE = """\ +#undef yield #define yield() esphome::yield() +#undef millis #define millis() esphome::millis() +#undef micros #define micros() esphome::micros() +#undef delay #define delay(x) esphome::delay(x) +#undef delayMicroseconds #define delayMicroseconds(x) esphome::delayMicroseconds(x) """ @@ -501,7 +506,6 @@ async def to_code(config: ConfigType) -> None: config[CONF_NAME], config[CONF_FRIENDLY_NAME], config.get(CONF_COMMENT, ""), - cg.RawExpression('__DATE__ ", " __TIME__'), config[CONF_NAME_ADD_MAC_SUFFIX], ) ) @@ -537,7 +541,7 @@ async def to_code(config: ConfigType) -> None: if config[CONF_DEBUG_SCHEDULER]: cg.add_define("ESPHOME_DEBUG_SCHEDULER") - if CORE.using_arduino and not CORE.is_bk72xx: + if CORE.using_arduino: CORE.add_job(add_arduino_global_workaround) if config[CONF_INCLUDES]: diff --git a/esphome/core/controller.h b/esphome/core/controller.h index 697017217db..632b46c8937 100644 --- a/esphome/core/controller.h +++ b/esphome/core/controller.h @@ -58,6 +58,9 @@ #ifdef USE_ALARM_CONTROL_PANEL #include "esphome/components/alarm_control_panel/alarm_control_panel.h" #endif +#ifdef USE_WATER_HEATER +#include "esphome/components/water_heater/water_heater.h" +#endif #ifdef USE_EVENT #include "esphome/components/event/event.h" #endif @@ -123,6 +126,9 @@ class Controller { #ifdef USE_ALARM_CONTROL_PANEL virtual void on_alarm_control_panel_update(alarm_control_panel::AlarmControlPanel *obj){}; #endif +#ifdef USE_WATER_HEATER + virtual void on_water_heater_update(water_heater::WaterHeater *obj){}; +#endif #ifdef USE_EVENT virtual void on_event(event::Event *obj){}; #endif diff --git a/esphome/core/controller_registry.cpp b/esphome/core/controller_registry.cpp index 0a84bb0d0d5..13b505e8e95 100644 --- a/esphome/core/controller_registry.cpp +++ b/esphome/core/controller_registry.cpp @@ -98,6 +98,10 @@ CONTROLLER_REGISTRY_NOTIFY(media_player::MediaPlayer, media_player) CONTROLLER_REGISTRY_NOTIFY(alarm_control_panel::AlarmControlPanel, alarm_control_panel) #endif +#ifdef USE_WATER_HEATER +CONTROLLER_REGISTRY_NOTIFY(water_heater::WaterHeater, water_heater) +#endif + #ifdef USE_EVENT CONTROLLER_REGISTRY_NOTIFY_NO_UPDATE_SUFFIX(event::Event, event) #endif diff --git a/esphome/core/controller_registry.h b/esphome/core/controller_registry.h index 640a276a0a0..d6452d8827e 100644 --- a/esphome/core/controller_registry.h +++ b/esphome/core/controller_registry.h @@ -119,6 +119,12 @@ class AlarmControlPanel; } #endif +#ifdef USE_WATER_HEATER +namespace water_heater { +class WaterHeater; +} +#endif + #ifdef USE_EVENT namespace event { class Event; @@ -228,6 +234,10 @@ class ControllerRegistry { static void notify_alarm_control_panel_update(alarm_control_panel::AlarmControlPanel *obj); #endif +#ifdef USE_WATER_HEATER + static void notify_water_heater_update(water_heater::WaterHeater *obj); +#endif + #ifdef USE_EVENT static void notify_event(event::Event *obj); #endif diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 750cab5bbaf..11c50621408 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -113,6 +113,8 @@ #define USE_UART_WAKE_LOOP_ON_RX #define USE_UPDATE #define USE_VALVE +#define USE_WATER_HEATER +#define USE_WATER_HEATER_VISUAL_OVERRIDES #define USE_ZWAVE_PROXY // Feature flags which do not work for zephyr @@ -146,7 +148,7 @@ #define USE_OTA_PASSWORD #define USE_OTA_SHA256 #define ALLOW_OTA_DOWNGRADE_MD5 -#define USE_OTA_STATE_CALLBACK +#define USE_OTA_STATE_LISTENER #define USE_OTA_VERSION 2 #define USE_TIME_TIMEZONE #define USE_WIFI @@ -165,12 +167,12 @@ // IDF-specific feature flags #ifdef USE_ESP_IDF #define USE_MQTT_IDF_ENQUEUE -#define ESPHOME_LOOP_TASK_STACK_SIZE 8192 #endif // ESP32-specific feature flags #ifdef USE_ESP32 #define USE_ESPHOME_TASK_LOG_BUFFER +#define USE_OTA_ROLLBACK #define USE_BLUETOOTH_PROXY #define BLUETOOTH_PROXY_MAX_CONNECTIONS 3 @@ -197,6 +199,7 @@ #define ESPHOME_ESP32_BLE_GATTC_EVENT_HANDLER_COUNT 1 #define ESPHOME_ESP32_BLE_GATTS_EVENT_HANDLER_COUNT 1 #define ESPHOME_ESP32_BLE_BLE_STATUS_EVENT_HANDLER_COUNT 2 +#define ESPHOME_LOOP_TASK_STACK_SIZE 8192 #define USE_ESP32_CAMERA_JPEG_ENCODER #define USE_HTTP_REQUEST_RESPONSE #define USE_I2C @@ -336,3 +339,4 @@ #define ESPHOME_ENTITY_TIME_COUNT 1 #define ESPHOME_ENTITY_UPDATE_COUNT 1 #define ESPHOME_ENTITY_VALVE_COUNT 1 +#define ESPHOME_ENTITY_WATER_HEATER_COUNT 1 diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index 046f99d8ccb..b7616a9ad38 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -60,15 +60,6 @@ std::string EntityBase::get_object_id() const { // `App.get_friendly_name()` is constant. return this->object_id_c_str_ == nullptr ? "" : this->object_id_c_str_; } -StringRef EntityBase::get_object_id_ref_for_api_() const { - static constexpr auto EMPTY_STRING = StringRef::from_lit(""); - // Return empty for dynamic case (MAC suffix) - if (this->is_object_id_dynamic_()) { - return EMPTY_STRING; - } - // For static case, return the string or empty if null - return this->object_id_c_str_ == nullptr ? EMPTY_STRING : StringRef(this->object_id_c_str_); -} void EntityBase::set_object_id(const char *object_id) { this->object_id_c_str_ = object_id; this->calc_object_id_(); @@ -82,8 +73,41 @@ void EntityBase::set_name_and_object_id(const char *name, const char *object_id) // Calculate Object ID Hash from Entity Name void EntityBase::calc_object_id_() { - this->object_id_hash_ = - fnv1_hash(this->is_object_id_dynamic_() ? this->get_object_id().c_str() : this->object_id_c_str_); + char buf[OBJECT_ID_MAX_LEN]; + StringRef object_id = this->get_object_id_to(buf); + this->object_id_hash_ = fnv1_hash(object_id.c_str()); +} + +// Format dynamic object_id: sanitized snake_case of friendly_name +static size_t format_dynamic_object_id(char *buf, size_t buf_size) { + const std::string &name = App.get_friendly_name(); + size_t len = std::min(name.size(), buf_size - 1); + for (size_t i = 0; i < len; i++) { + buf[i] = to_sanitized_char(to_snake_case_char(name[i])); + } + buf[len] = '\0'; + return len; +} + +size_t EntityBase::write_object_id_to(char *buf, size_t buf_size) const { + if (this->is_object_id_dynamic_()) { + return format_dynamic_object_id(buf, buf_size); + } + const char *src = this->object_id_c_str_ == nullptr ? "" : this->object_id_c_str_; + size_t len = strlen(src); + if (len >= buf_size) + len = buf_size - 1; + memcpy(buf, src, len); + buf[len] = '\0'; + return len; +} + +StringRef EntityBase::get_object_id_to(std::span buf) const { + if (this->is_object_id_dynamic_()) { + size_t len = format_dynamic_object_id(buf.data(), buf.size()); + return StringRef(buf.data(), len); + } + return this->object_id_c_str_ == nullptr ? StringRef() : StringRef(this->object_id_c_str_); } uint32_t EntityBase::get_object_id_hash() { return this->object_id_hash_; } diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index fdf3f6300a1..eb1ba46c94f 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -1,7 +1,8 @@ #pragma once -#include #include +#include +#include #include "string_ref.h" #include "helpers.h" #include "log.h" @@ -12,14 +13,8 @@ namespace esphome { -// Forward declaration for friend access -namespace api { -class APIConnection; -} // namespace api - -namespace web_server { -struct UrlMatch; -} // namespace web_server +// Maximum size for object_id buffer (friendly_name max ~120 + margin) +static constexpr size_t OBJECT_ID_MAX_LEN = 128; enum EntityCategory : uint8_t { ENTITY_CATEGORY_NONE = 0, @@ -47,6 +42,15 @@ class EntityBase { // Get the unique Object ID of this Entity uint32_t get_object_id_hash(); + /// Get object_id with zero heap allocation + /// For static case: returns StringRef to internal storage (buffer unused) + /// For dynamic case: formats into buffer and returns StringRef to buffer + StringRef get_object_id_to(std::span buf) const; + + /// Write object_id directly to buffer, returns length written (excluding null) + /// Useful for building compound strings without intermediate buffer + size_t write_object_id_to(char *buf, size_t buf_size) const; + // Get/set whether this Entity should be hidden outside ESPHome bool is_internal() const { return this->flags_.internal; } void set_internal(bool internal) { this->flags_.internal = internal; } @@ -125,13 +129,6 @@ class EntityBase { } protected: - friend class api::APIConnection; - friend struct web_server::UrlMatch; - - // Get object_id as StringRef when it's static (for API usage) - // Returns empty StringRef if object_id is dynamic (needs allocation) - StringRef get_object_id_ref_for_api_() const; - void calc_object_id_(); /// Check if the object_id is dynamic (changes with MAC suffix) diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index fb96869d21f..f55f53f16b4 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -143,11 +143,12 @@ uint16_t crc16be(const uint8_t *data, uint16_t len, uint16_t crc, uint16_t poly, return refout ? (crc ^ 0xffff) : crc; } +// FNV-1 hash - deprecated, use fnv1a_hash() for new code uint32_t fnv1_hash(const char *str) { - uint32_t hash = 2166136261UL; + uint32_t hash = FNV1_OFFSET_BASIS; if (str) { while (*str) { - hash *= 16777619UL; + hash *= FNV1_PRIME; hash ^= *str++; } } @@ -189,21 +190,18 @@ template std::string str_ctype_transform(const std::string &str) std::string str_lower_case(const std::string &str) { return str_ctype_transform(str); } std::string str_upper_case(const std::string &str) { return str_ctype_transform(str); } std::string str_snake_case(const std::string &str) { - std::string result; - result.resize(str.length()); - std::transform(str.begin(), str.end(), result.begin(), ::tolower); - std::replace(result.begin(), result.end(), ' ', '_'); + std::string result = str; + for (char &c : result) { + c = to_snake_case_char(c); + } return result; } std::string str_sanitize(const std::string &str) { - std::string out = str; - std::replace_if( - out.begin(), out.end(), - [](const char &c) { - return c != '-' && c != '_' && (c < '0' || c > '9') && (c < 'a' || c > 'z') && (c < 'A' || c > 'Z'); - }, - '_'); - return out; + std::string result = str; + for (char &c : result) { + c = to_sanitized_char(c); + } + return result; } std::string str_snprintf(const char *fmt, size_t len, ...) { std::string str; @@ -238,17 +236,16 @@ std::string str_sprintf(const char *fmt, ...) { // Maximum size for name with suffix: 120 (max friendly name) + 1 (separator) + 6 (MAC suffix) + 1 (null term) static constexpr size_t MAX_NAME_WITH_SUFFIX_SIZE = 128; -std::string make_name_with_suffix(const char *name, size_t name_len, char sep, const char *suffix_ptr, - size_t suffix_len) { - char buffer[MAX_NAME_WITH_SUFFIX_SIZE]; +size_t make_name_with_suffix_to(char *buffer, size_t buffer_size, const char *name, size_t name_len, char sep, + const char *suffix_ptr, size_t suffix_len) { size_t total_len = name_len + 1 + suffix_len; // Silently truncate if needed: prioritize keeping the full suffix - if (total_len >= MAX_NAME_WITH_SUFFIX_SIZE) { - // NOTE: This calculation could underflow if suffix_len >= MAX_NAME_WITH_SUFFIX_SIZE - 2, + if (total_len >= buffer_size) { + // NOTE: This calculation could underflow if suffix_len >= buffer_size - 2, // but this is safe because this helper is only called with small suffixes: // MAC suffixes (6-12 bytes), ".local" (5 bytes), etc. - name_len = MAX_NAME_WITH_SUFFIX_SIZE - suffix_len - 2; // -2 for separator and null terminator + name_len = buffer_size - suffix_len - 2; // -2 for separator and null terminator total_len = name_len + 1 + suffix_len; } @@ -256,7 +253,14 @@ std::string make_name_with_suffix(const char *name, size_t name_len, char sep, c buffer[name_len] = sep; memcpy(buffer + name_len + 1, suffix_ptr, suffix_len); buffer[total_len] = '\0'; - return std::string(buffer, total_len); + return total_len; +} + +std::string make_name_with_suffix(const char *name, size_t name_len, char sep, const char *suffix_ptr, + size_t suffix_len) { + char buffer[MAX_NAME_WITH_SUFFIX_SIZE]; + size_t len = make_name_with_suffix_to(buffer, sizeof(buffer), name, name_len, sep, suffix_ptr, suffix_len); + return std::string(buffer, len); } std::string make_name_with_suffix(const std::string &name, char sep, const char *suffix_ptr, size_t suffix_len) { @@ -473,10 +477,14 @@ std::string base64_encode(const uint8_t *buf, size_t buf_len) { } size_t base64_decode(const std::string &encoded_string, uint8_t *buf, size_t buf_len) { - int in_len = encoded_string.size(); + return base64_decode(reinterpret_cast(encoded_string.data()), encoded_string.size(), buf, buf_len); +} + +size_t base64_decode(const uint8_t *encoded_data, size_t encoded_len, uint8_t *buf, size_t buf_len) { + size_t in_len = encoded_len; int i = 0; int j = 0; - int in = 0; + size_t in = 0; size_t out = 0; uint8_t char_array_4[4], char_array_3[3]; bool truncated = false; @@ -484,8 +492,8 @@ size_t base64_decode(const std::string &encoded_string, uint8_t *buf, size_t buf // SAFETY: The loop condition checks is_base64() before processing each character. // This ensures base64_find_char() is only called on valid base64 characters, // preventing the edge case where invalid chars would return 0 (same as 'A'). - while (in_len-- && (encoded_string[in] != '=') && is_base64(encoded_string[in])) { - char_array_4[i++] = encoded_string[in]; + while (in_len-- && (encoded_data[in] != '=') && is_base64(encoded_data[in])) { + char_array_4[i++] = encoded_data[in]; in++; if (i == 4) { for (i = 0; i < 4; i++) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 3e44e08dd40..b575a14d14a 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -378,9 +378,32 @@ uint16_t crc16be(const uint8_t *data, uint16_t len, uint16_t crc = 0, uint16_t p bool refout = false); /// Calculate a FNV-1 hash of \p str. +/// Note: FNV-1a (fnv1a_hash) is preferred for new code due to better avalanche characteristics. uint32_t fnv1_hash(const char *str); inline uint32_t fnv1_hash(const std::string &str) { return fnv1_hash(str.c_str()); } +/// FNV-1 32-bit offset basis +constexpr uint32_t FNV1_OFFSET_BASIS = 2166136261UL; +/// FNV-1 32-bit prime +constexpr uint32_t FNV1_PRIME = 16777619UL; + +/// Extend a FNV-1a hash with additional string data. +constexpr uint32_t fnv1a_hash_extend(uint32_t hash, const char *str) { + if (str) { + while (*str) { + hash ^= *str++; + hash *= FNV1_PRIME; + } + } + return hash; +} +inline uint32_t fnv1a_hash_extend(uint32_t hash, const std::string &str) { + return fnv1a_hash_extend(hash, str.c_str()); +} +/// Calculate a FNV-1a hash of \p str. +constexpr uint32_t fnv1a_hash(const char *str) { return fnv1a_hash_extend(FNV1_OFFSET_BASIS, str); } +inline uint32_t fnv1a_hash(const std::string &str) { return fnv1a_hash(str.c_str()); } + /// Return a random 32-bit unsigned integer. uint32_t random_uint32(); /// Return a random float between 0 and 1. @@ -493,9 +516,16 @@ std::string str_until(const std::string &str, char ch); std::string str_lower_case(const std::string &str); /// Convert the string to upper case. std::string str_upper_case(const std::string &str); + +/// Convert a single char to snake_case: lowercase and space to underscore. +constexpr char to_snake_case_char(char c) { return (c == ' ') ? '_' : (c >= 'A' && c <= 'Z') ? c + ('a' - 'A') : c; } /// Convert the string to snake case (lowercase with underscores). std::string str_snake_case(const std::string &str); +/// Sanitize a single char: keep alphanumerics, dashes, underscores; replace others with underscore. +constexpr char to_sanitized_char(char c) { + return (c == '-' || c == '_' || (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) ? c : '_'; +} /// Sanitizes the input string by removing all characters but alphanumerics, dashes and underscores. std::string str_sanitize(const std::string &str); @@ -526,6 +556,18 @@ std::string make_name_with_suffix(const std::string &name, char sep, const char std::string make_name_with_suffix(const char *name, size_t name_len, char sep, const char *suffix_ptr, size_t suffix_len); +/// Zero-allocation version: format name + separator + suffix directly into buffer. +/// @param buffer Output buffer (must have space for result + null terminator) +/// @param buffer_size Size of the output buffer +/// @param name The base name string +/// @param name_len Length of the name +/// @param sep Single character separator +/// @param suffix_ptr Pointer to the suffix characters +/// @param suffix_len Length of the suffix +/// @return Length written (excluding null terminator) +size_t make_name_with_suffix_to(char *buffer, size_t buffer_size, const char *name, size_t name_len, char sep, + const char *suffix_ptr, size_t suffix_len); + ///@} /// @name Parsing & formatting @@ -855,6 +897,7 @@ std::string base64_encode(const std::vector &buf); std::vector base64_decode(const std::string &encoded_string); size_t base64_decode(std::string const &encoded_string, uint8_t *buf, size_t buf_len); +size_t base64_decode(const uint8_t *encoded_data, size_t encoded_len, uint8_t *buf, size_t buf_len); ///@} @@ -911,6 +954,50 @@ template class CallbackManager { std::vector> callbacks_; }; +template class LazyCallbackManager; + +/** Lazy-allocating callback manager that only allocates memory when callbacks are registered. + * + * This is a drop-in replacement for CallbackManager that saves memory when no callbacks + * are registered (common case after the Controller Registry eliminated per-entity callbacks + * from API and web_server components). + * + * Memory overhead comparison (32-bit systems): + * - CallbackManager: 12 bytes (empty std::vector) + * - LazyCallbackManager: 4 bytes (nullptr unique_ptr) + * + * @tparam Ts The arguments for the callbacks, wrapped in void(). + */ +template class LazyCallbackManager { + public: + /// Add a callback to the list. Allocates the underlying CallbackManager on first use. + void add(std::function &&callback) { + if (!this->callbacks_) { + this->callbacks_ = make_unique>(); + } + this->callbacks_->add(std::move(callback)); + } + + /// Call all callbacks in this manager. No-op if no callbacks registered. + void call(Ts... args) { + if (this->callbacks_) { + this->callbacks_->call(args...); + } + } + + /// Return the number of registered callbacks. + size_t size() const { return this->callbacks_ ? this->callbacks_->size() : 0; } + + /// Check if any callbacks are registered. + bool empty() const { return !this->callbacks_ || this->callbacks_->size() == 0; } + + /// Call all callbacks in this manager. + void operator()(Ts... args) { this->call(args...); } + + protected: + std::unique_ptr> callbacks_; +}; + /// Helper class to deduplicate items in a series of values. template class Deduplicator { public: diff --git a/esphome/core/progmem.h b/esphome/core/progmem.h index 67131fd113a..d1594f47e73 100644 --- a/esphome/core/progmem.h +++ b/esphome/core/progmem.h @@ -1,16 +1,18 @@ #pragma once // Platform-agnostic macros for PROGMEM string handling -// On ESP32 (both Arduino and IDF): Use plain strings (no PROGMEM) // On ESP8266/Arduino: Use Arduino's F() macro for PROGMEM strings +// On other platforms: Use plain strings (no PROGMEM) -#ifdef USE_ESP32 -#define ESPHOME_F(string_literal) (string_literal) -#define ESPHOME_PGM_P const char * -#define ESPHOME_strncpy_P strncpy -#else -// ESP8266 and other Arduino platforms use Arduino macros +#ifdef USE_ESP8266 +// ESP8266 uses Arduino macros #define ESPHOME_F(string_literal) F(string_literal) #define ESPHOME_PGM_P PGM_P #define ESPHOME_strncpy_P strncpy_P +#define ESPHOME_strncat_P strncat_P +#else +#define ESPHOME_F(string_literal) (string_literal) +#define ESPHOME_PGM_P const char * +#define ESPHOME_strncpy_P strncpy +#define ESPHOME_strncat_P strncat #endif diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 1a47b346b77..ddccb574e44 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -51,15 +51,19 @@ class AssignmentExpression(Expression): class VariableDeclarationExpression(Expression): - __slots__ = ("type", "modifier", "name") + __slots__ = ("type", "modifier", "name", "static") - def __init__(self, type_, modifier, name): + def __init__( + self, type_: "MockObj", modifier: str, name: ID, *, static: bool = False + ) -> None: self.type = type_ self.modifier = modifier self.name = name + self.static = static - def __str__(self): - return f"{self.type} {self.modifier}{self.name}" + def __str__(self) -> str: + prefix = "static " if self.static else "" + return f"{prefix}{self.type} {self.modifier}{self.name}" class ExpressionList(Expression): @@ -507,13 +511,17 @@ def with_local_variable(id_: ID, rhs: SafeExpType, callback: Callable, *args) -> CORE.add(RawStatement("}")) # output closing curly brace -def new_variable(id_: ID, rhs: SafeExpType, type_: "MockObj" = None) -> "MockObj": +def new_variable( + id_: ID, rhs: SafeExpType, type_: "MockObj" = None, *, static: bool = True +) -> "MockObj": """Declare and define a new variable, not pointer type, in the code generation. :param id_: The ID used to declare the variable. :param rhs: The expression to place on the right hand side of the assignment. :param type_: Manually define a type for the variable, only use this when it's not possible to do so during config validation phase (for example because of template arguments). + :param static: If True (default), declare with static storage class for optimization. + Set to False when the variable must have external linkage (e.g., to match library declarations). :return: The new variable as a MockObj. """ @@ -522,7 +530,7 @@ def new_variable(id_: ID, rhs: SafeExpType, type_: "MockObj" = None) -> "MockObj obj = MockObj(id_, ".") if type_ is not None: id_.type = type_ - decl = VariableDeclarationExpression(id_.type, "", id_) + decl = VariableDeclarationExpression(id_.type, "", id_, static=static) CORE.add_global(decl) assignment = AssignmentExpression(None, "", id_, rhs) CORE.add(assignment) @@ -544,7 +552,7 @@ def Pvariable(id_: ID, rhs: SafeExpType, type_: "MockObj" = None) -> "MockObj": obj = MockObj(id_, "->") if type_ is not None: id_.type = type_ - decl = VariableDeclarationExpression(id_.type, "*", id_) + decl = VariableDeclarationExpression(id_.type, "*", id_, static=True) CORE.add_global(decl) assignment = AssignmentExpression(None, None, id_, rhs) CORE.add(assignment) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 804a2b99afc..f94d8eea221 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -164,8 +164,24 @@ def websocket_method(name): 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(tornado.websocket.WebSocketHandler): +class EsphomeCommandWebSocket(CheckOriginMixin, tornado.websocket.WebSocketHandler): """Base class for ESPHome websocket commands.""" def __init__( @@ -183,18 +199,6 @@ class EsphomeCommandWebSocket(tornado.websocket.WebSocketHandler): # use Popen() with a reading thread instead self._use_popen = os.name == "nt" - def check_origin(self, origin): - 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 - def open(self, *args: str, **kwargs: str) -> None: """Handle new WebSocket connection.""" # Ensure messages from the subprocess are sent immediately @@ -601,7 +605,7 @@ DASHBOARD_SUBSCRIBER = DashboardSubscriber() @websocket_class -class DashboardEventsWebSocket(tornado.websocket.WebSocketHandler): +class DashboardEventsWebSocket(CheckOriginMixin, tornado.websocket.WebSocketHandler): """WebSocket handler for real-time dashboard events.""" _event_listeners: list[Callable[[], None]] | None = None diff --git a/esphome/espota2.py b/esphome/espota2.py index c29506224ca..6349ad0fa8d 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -322,8 +322,8 @@ def perform_ota( hash_func, nonce_size, hash_name = _AUTH_METHODS[auth] perform_auth(sock, password, hash_func, nonce_size, hash_name) - # Set higher timeout during upload - sock.settimeout(30.0) + # Timeout must match device-side OTA_SOCKET_TIMEOUT_DATA to prevent premature failures + sock.settimeout(90.0) upload_size = len(upload_contents) upload_size_encoded = [ diff --git a/esphome/helpers.py b/esphome/helpers.py index ea6abff50af..d1623d1d3c5 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -424,9 +424,13 @@ def write_file_if_changed(path: Path, text: str) -> bool: return True -def copy_file_if_changed(src: Path, dst: Path) -> None: +def copy_file_if_changed(src: Path, dst: Path) -> bool: + """Copy file from src to dst if contents differ. + + Returns True if file was copied, False if files already matched. + """ if file_compare(src, dst): - return + return False dst.parent.mkdir(parents=True, exist_ok=True) try: shutil.copyfile(src, dst) @@ -441,11 +445,12 @@ def copy_file_if_changed(src: Path, dst: Path) -> None: with suppress(OSError): os.unlink(dst) shutil.copyfile(src, dst) - return + return True from esphome.core import EsphomeError raise EsphomeError(f"Error copying file {src} to {dst}: {err}") from err + return True def list_starts_with(list_, sub): diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 9bb59672487..4573391bc11 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -27,3 +27,7 @@ dependencies: version: "1.7.6~1" rules: - if: "target in [esp32s2, esp32s3, esp32p4]" + esphome/esp-hub75: + version: 0.1.7 + rules: + - if: "target in [esp32, esp32s2, esp32s3, esp32p4]" diff --git a/esphome/loader.py b/esphome/loader.py index 387443c032e..968c8cf3e05 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -187,7 +187,14 @@ def install_meta_finder( 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) diff --git a/esphome/writer.py b/esphome/writer.py index 721db07f96c..183fff8730f 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -1,11 +1,13 @@ from collections.abc import Callable import importlib +import json import logging import os from pathlib import Path import re import shutil import stat +import time from types import TracebackType from esphome import loader @@ -23,6 +25,7 @@ from esphome.helpers import ( is_ha_addon, read_file, walk_files, + write_file, write_file_if_changed, ) from esphome.storage_json import StorageJSON, storage_path @@ -173,6 +176,7 @@ VERSION_H_FORMAT = """\ """ DEFINES_H_TARGET = "esphome/core/defines.h" VERSION_H_TARGET = "esphome/core/version.h" +BUILD_INFO_DATA_H_TARGET = "esphome/core/build_info_data.h" ESPHOME_README_TXT = """ THIS DIRECTORY IS AUTO-GENERATED, DO NOT MODIFY @@ -206,10 +210,16 @@ def copy_src_tree(): include_s = "\n".join(include_l) source_files_copy = source_files_map.copy() - ignore_targets = [Path(x) for x in (DEFINES_H_TARGET, VERSION_H_TARGET)] + ignore_targets = [ + Path(x) for x in (DEFINES_H_TARGET, VERSION_H_TARGET, BUILD_INFO_DATA_H_TARGET) + ] for t in ignore_targets: - source_files_copy.pop(t) + source_files_copy.pop(t, None) + # Files to exclude from sources_changed tracking (generated files) + generated_files = {Path("esphome/core/build_info_data.h")} + + sources_changed = False for fname in walk_files(CORE.relative_src_path("esphome")): p = Path(fname) if p.suffix not in SOURCE_FILE_EXTENSIONS: @@ -223,28 +233,80 @@ def copy_src_tree(): if target not in source_files_copy: # Source file removed, delete target p.unlink() + if target not in generated_files: + sources_changed = True else: src_file = source_files_copy.pop(target) with src_file.path() as src_path: - copy_file_if_changed(src_path, p) + if copy_file_if_changed(src_path, p) and target not in generated_files: + sources_changed = True # Now copy new files for target, src_file in source_files_copy.items(): dst_path = CORE.relative_src_path(*target.parts) with src_file.path() as src_path: - copy_file_if_changed(src_path, dst_path) + if ( + copy_file_if_changed(src_path, dst_path) + and target not in generated_files + ): + sources_changed = True # Finally copy defines - write_file_if_changed( + if write_file_if_changed( CORE.relative_src_path("esphome", "core", "defines.h"), generate_defines_h() - ) + ): + sources_changed = True write_file_if_changed(CORE.relative_build_path("README.txt"), ESPHOME_README_TXT) - write_file_if_changed( + if write_file_if_changed( CORE.relative_src_path("esphome.h"), ESPHOME_H_FORMAT.format(include_s) - ) - write_file_if_changed( + ): + sources_changed = True + if write_file_if_changed( CORE.relative_src_path("esphome", "core", "version.h"), generate_version_h() + ): + sources_changed = True + + # Generate new build_info files if needed + build_info_data_h_path = CORE.relative_src_path( + "esphome", "core", "build_info_data.h" ) + build_info_json_path = CORE.relative_build_path("build_info.json") + config_hash, build_time, build_time_str = get_build_info() + + # Defensively force a rebuild if the build_info files don't exist, or if + # there was a config change which didn't actually cause a source change + if not build_info_data_h_path.exists(): + sources_changed = True + else: + try: + existing = json.loads(build_info_json_path.read_text(encoding="utf-8")) + if ( + existing.get("config_hash") != config_hash + or existing.get("esphome_version") != __version__ + ): + sources_changed = True + except (json.JSONDecodeError, KeyError, OSError): + sources_changed = True + + # Write build_info header and JSON metadata + if sources_changed: + write_file( + build_info_data_h_path, + generate_build_info_data_h(config_hash, build_time, build_time_str), + ) + write_file( + build_info_json_path, + json.dumps( + { + "config_hash": config_hash, + "build_time": build_time, + "build_time_str": build_time_str, + "esphome_version": __version__, + }, + indent=2, + ) + + "\n", + ) platform = "esphome.components." + CORE.target_platform try: @@ -270,6 +332,35 @@ def generate_version_h(): ) +def get_build_info() -> tuple[int, int, str]: + """Calculate build_info values from current config. + + Returns: + Tuple of (config_hash, build_time, build_time_str) + """ + config_hash = CORE.config_hash + build_time = int(time.time()) + build_time_str = time.strftime("%Y-%m-%d %H:%M:%S %z", time.localtime(build_time)) + return config_hash, build_time, build_time_str + + +def generate_build_info_data_h( + config_hash: int, build_time: int, build_time_str: str +) -> str: + """Generate build_info_data.h header with config hash and build time.""" + return f"""#pragma once +// Auto-generated build_info data +#define ESPHOME_CONFIG_HASH 0x{config_hash:08x}U // NOLINT +#define ESPHOME_BUILD_TIME {build_time} // NOLINT +#ifdef USE_ESP8266 +#include +static const char ESPHOME_BUILD_TIME_STR[] PROGMEM = "{build_time_str}"; +#else +static const char ESPHOME_BUILD_TIME_STR[] = "{build_time_str}"; +#endif +""" + + def write_cpp(code_s): path = CORE.relative_src_path("main.cpp") if path.is_file(): diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 359b72b48f8..bba4bbf4872 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Callable +from contextlib import suppress import functools import inspect from io import BytesIO, TextIOBase, TextIOWrapper @@ -501,13 +502,17 @@ def _load_yaml_internal_with_type( loader.dispose() -def dump(dict_, show_secrets=False): +def dump(dict_, show_secrets=False, sort_keys=False): """Dump YAML to a string and remove null.""" if show_secrets: _SECRET_VALUES.clear() _SECRET_CACHE.clear() return yaml.dump( - dict_, default_flow_style=False, allow_unicode=True, Dumper=ESPHomeDumper + dict_, + default_flow_style=False, + allow_unicode=True, + Dumper=ESPHomeDumper, + sort_keys=sort_keys, ) @@ -543,6 +548,9 @@ class ESPHomeDumper(yaml.SafeDumper): best_style = True if hasattr(mapping, "items"): mapping = list(mapping.items()) + if self.sort_keys: + with suppress(TypeError): + mapping = sorted(mapping) for item_key, item_value in mapping: node_key = self.represent_data(item_key) node_value = self.represent_data(item_value) diff --git a/platformio.ini b/platformio.ini index d37c798c05f..a27fb1f5378 100644 --- a/platformio.ini +++ b/platformio.ini @@ -156,7 +156,6 @@ lib_deps = esphome/ESP32-audioI2S@2.3.0 ; i2s_audio droscy/esp_wireguard@0.4.2 ; wireguard esphome/esp-audio-libs@2.0.1 ; audio - esphome/esp-hub75@0.1.6 ; hub75 build_flags = ${common:arduino.build_flags} @@ -180,7 +179,6 @@ lib_deps = droscy/esp_wireguard@0.4.2 ; wireguard kahrendt/ESPMicroSpeechFeatures@1.1.0 ; micro_wake_word esphome/esp-audio-libs@2.0.1 ; audio - esphome/esp-hub75@0.1.6 ; hub75 build_flags = ${common:idf.build_flags} -Wno-nonnull-compare diff --git a/requirements.txt b/requirements.txt index 7a50e1296f3..5718ced6172 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,10 @@ cryptography==45.0.1 -voluptuous==0.15.2 +voluptuous==0.16.0 PyYAML==6.0.3 paho-mqtt==1.6.1 colorama==0.4.6 icmplib==3.0.4 -tornado==6.5.3 +tornado==6.5.4 tzlocal==5.3.1 # from time tzdata>=2021.1 # from time pyserial==3.5 @@ -12,10 +12,10 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.1.0 click==8.1.7 esphome-dashboard==20251013.0 -aioesphomeapi==43.2.1 +aioesphomeapi==43.5.0 zeroconf==0.148.0 puremagic==1.30 -ruamel.yaml==0.18.16 # dashboard_import +ruamel.yaml==0.18.17 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import esphome-glyphsets==0.2.0 pillow==11.3.0 diff --git a/requirements_test.txt b/requirements_test.txt index bfb833e04de..f00bcd0a0df 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.4 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.14.9 # also change in .pre-commit-config.yaml when updating +ruff==0.14.10 # 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/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 3412fac5db4..cb09ef7050a 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -1215,6 +1215,9 @@ class FixedArrayRepeatedType(TypeInfo): """Helper to generate encode statement for a single element.""" if isinstance(self._ti, EnumType): return f"buffer.{self._ti.encode_func}({self.number}, static_cast({element}), true);" + # MessageType.encode_message doesn't have a force parameter + if isinstance(self._ti, MessageType): + return f"buffer.{self._ti.encode_func}({self.number}, {element});" return f"buffer.{self._ti.encode_func}({self.number}, {element}, true);" @property @@ -1536,6 +1539,15 @@ class RepeatedTypeInfo(TypeInfo): # std::vector is specialized for bool, reference does not work return isinstance(self._ti, BoolType) + def _encode_element_call(self, element: str) -> str: + """Helper to generate encode call for a single element.""" + if isinstance(self._ti, EnumType): + return f"buffer.{self._ti.encode_func}({self.number}, static_cast({element}), true);" + # MessageType.encode_message doesn't have a force parameter + if isinstance(self._ti, MessageType): + return f"buffer.{self._ti.encode_func}({self.number}, {element});" + return f"buffer.{self._ti.encode_func}({self.number}, {element}, true);" + @property def encode_content(self) -> str: if self._use_pointer: @@ -1546,17 +1558,11 @@ class RepeatedTypeInfo(TypeInfo): o += f" buffer.{self._ti.encode_func}({self.number}, it, strlen(it), true);\n" else: o = f"for (const auto &it : *this->{self.field_name}) {{\n" - if isinstance(self._ti, EnumType): - o += f" buffer.{self._ti.encode_func}({self.number}, static_cast(it), true);\n" - else: - o += f" buffer.{self._ti.encode_func}({self.number}, it, true);\n" + o += f" {self._encode_element_call('it')}\n" o += "}" return o o = f"for (auto {'' if self._ti_is_bool else '&'}it : this->{self.field_name}) {{\n" - if isinstance(self._ti, EnumType): - o += f" buffer.{self._ti.encode_func}({self.number}, static_cast(it), true);\n" - else: - o += f" buffer.{self._ti.encode_func}({self.number}, it, true);\n" + o += f" {self._encode_element_call('it')}\n" o += "}" return o diff --git a/script/clang-tidy b/script/clang-tidy index 142b616119b..17bcafacc79 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -253,19 +253,31 @@ def main(): print(f"Split {args.split_at}/{args.split_num}: checking {len(files)} files") # Print file count before adding header file - print(f"\nTotal files to check: {len(files)}") + print(f"\nTotal cpp files to check: {len(files)}") + + # Add header file for checking (before early exit check) + if args.all_headers and args.split_at in (None, 1): + # When --changed is used, only include changed headers instead of all headers + if args.changed: + all_headers = [ + os.path.relpath(p, cwd) for p in git_ls_files(["esphome/**/*.h"]) + ] + changed_headers = filter_changed(all_headers) + if changed_headers: + build_all_include(changed_headers) + files.insert(0, temp_header_file) + else: + print("No changed headers to check") + else: + build_all_include() + files.insert(0, temp_header_file) + print(f"Added all-include header file, new total: {len(files)}") # Early exit if no files to check if not files: print("No files to check - exiting early") return 0 - # Only build header file if we have actual files to check - if args.all_headers and args.split_at in (None, 1): - build_all_include() - files.insert(0, temp_header_file) - print(f"Added all-include header file, new total: {len(files)}") - # Print final file list before loading idedata print_file_list(files, "Final files to process:") diff --git a/script/helpers.py b/script/helpers.py index 06a50a30923..202ac9b5fc7 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -156,22 +156,25 @@ def print_error_for_file(file: str | Path, body: str | None) -> None: print() -def build_all_include() -> None: - # Build a cpp file that includes all header files in this repo. - # Otherwise header-only integrations would not be tested by clang-tidy +def build_all_include(header_files: list[str] | None = None) -> None: + # Build a cpp file that includes header files for clang-tidy to check. + # If header_files is provided, only include those headers. + # Otherwise, include all header files in the esphome directory. - # Use git ls-files to find all .h files in the esphome directory - # This is much faster than walking the filesystem - cmd = ["git", "ls-files", "esphome/**/*.h"] - proc = subprocess.run(cmd, capture_output=True, text=True, check=True) + if header_files is None: + # Use git ls-files to find all .h files in the esphome directory + # This is much faster than walking the filesystem + cmd = ["git", "ls-files", "esphome/**/*.h"] + proc = subprocess.run(cmd, capture_output=True, text=True, check=True) - # Process git output - git already returns paths relative to repo root - headers = [ - f'#include "{include_p}"' - for line in proc.stdout.strip().split("\n") - if (include_p := line.replace(os.path.sep, "/")) - ] + # Process git output - git already returns paths relative to repo root + header_files = [ + line.replace(os.path.sep, "/") + for line in proc.stdout.strip().split("\n") + if line + ] + headers = [f'#include "{h}"' for h in header_files] headers.sort() headers.append("") content = "\n".join(headers) diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 322efb701ab..72ca3f6e9ce 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -13,7 +13,6 @@ CONFIG_ESP_TASK_WDT=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 -CONFIG_AUTOSTART_ARDUINO=y # esp32_ble CONFIG_BT_ENABLED=y diff --git a/tests/component_tests/packages/test_packages.py b/tests/component_tests/packages/test_packages.py index 3829e540d72..22fb2c4e325 100644 --- a/tests/component_tests/packages/test_packages.py +++ b/tests/component_tests/packages/test_packages.py @@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch import pytest from esphome.components.packages import CONFIG_SCHEMA, do_packages_pass, merge_packages +import esphome.config as config_module from esphome.config import resolve_extend_remove from esphome.config_helpers import Extend, Remove import esphome.config_validation as cv @@ -33,6 +34,7 @@ from esphome.const import ( CONF_VARS, CONF_WIFI, ) +from esphome.core import CORE from esphome.util import OrderedDict # Test strings @@ -991,3 +993,35 @@ def test_package_merge_invalid(invalid_package) -> None: with pytest.raises(cv.Invalid): merge_packages(config) + + +def test_raw_config_contains_merged_esphome_from_package(tmp_path) -> None: + """Test that CORE.raw_config contains esphome section from merged package. + + This is a regression test for the bug where CORE.raw_config was set before + packages were merged, causing KeyError when components accessed + CORE.raw_config[CONF_ESPHOME] and the esphome section came from a package. + """ + # Create a config where esphome section comes from a package + test_config = OrderedDict() + test_config[CONF_PACKAGES] = { + "base": { + CONF_ESPHOME: {CONF_NAME: TEST_DEVICE_NAME}, + } + } + test_config["esp32"] = {"board": "esp32dev"} + + # Set up CORE for the test + test_yaml = tmp_path / "test.yaml" + test_yaml.write_text("# test config") + CORE.reset() + CORE.config_path = test_yaml + + # Call validate_config - this should merge packages and set CORE.raw_config + config_module.validate_config(test_config, {}) + + # Verify that CORE.raw_config contains the esphome section from the package + assert CONF_ESPHOME in CORE.raw_config, ( + "CORE.raw_config should contain esphome section after package merge" + ) + assert CORE.raw_config[CONF_ESPHOME][CONF_NAME] == TEST_DEVICE_NAME diff --git a/tests/components/cc1101/common.yaml b/tests/components/cc1101/common.yaml index 9373ca43e1d..93f03e582ee 100644 --- a/tests/components/cc1101/common.yaml +++ b/tests/components/cc1101/common.yaml @@ -1,13 +1,26 @@ cc1101: id: transceiver cs_pin: ${cs_pin} + gdo0_pin: ${gdo0_pin} frequency: 433.92MHz if_frequency: 153kHz filter_bandwidth: 203kHz channel: 0 channel_spacing: 200kHz - symbol_rate: 5000 - modulation_type: ASK/OOK + symbol_rate: 4800 + modulation_type: GFSK + packet_mode: true + packet_length: 8 + crc_enable: true + whitening: false + sync_mode: "16/16" + sync0: 0x91 + sync1: 0xD3 + num_preamble: 2 + on_packet: + then: + - lambda: |- + ESP_LOGD("cc1101", "packet %s rssi %.1f dBm lqi %u", format_hex(x).c_str(), rssi, lqi); button: - platform: template @@ -18,3 +31,7 @@ button: - cc1101.begin_rx: transceiver - cc1101.set_idle: transceiver - cc1101.reset: transceiver + - cc1101.send_packet: + data: [0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef] + - cc1101.send_packet: !lambda |- + return {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}; diff --git a/tests/components/cc1101/test.esp32-idf.yaml b/tests/components/cc1101/test.esp32-idf.yaml index e075629679e..966f11bb64a 100644 --- a/tests/components/cc1101/test.esp32-idf.yaml +++ b/tests/components/cc1101/test.esp32-idf.yaml @@ -1,8 +1,8 @@ substitutions: cs_pin: GPIO5 + gdo0_pin: GPIO4 packages: spi: !include ../../test_build_components/common/spi/esp32-idf.yaml - remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/cc1101/test.esp8266.yaml b/tests/components/cc1101/test.esp8266.yaml index 7900658bc10..6f0f0785079 100644 --- a/tests/components/cc1101/test.esp8266.yaml +++ b/tests/components/cc1101/test.esp8266.yaml @@ -1,8 +1,8 @@ substitutions: cs_pin: GPIO5 + gdo0_pin: GPIO4 packages: spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml - remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml <<: !include common.yaml diff --git a/tests/components/deep_sleep/test.bk72xx-ard.yaml b/tests/components/deep_sleep/test.bk72xx-ard.yaml new file mode 100644 index 00000000000..2385fbb4dbf --- /dev/null +++ b/tests/components/deep_sleep/test.bk72xx-ard.yaml @@ -0,0 +1,14 @@ +deep_sleep: + run_duration: 30s + sleep_duration: 12h + wakeup_pin: + - pin: + number: P6 + - pin: P7 + wakeup_pin_mode: KEEP_AWAKE + - pin: + number: P10 + inverted: true + wakeup_pin_mode: INVERT_WAKEUP + +<<: !include common.yaml diff --git a/tests/components/esp32/test.esp32-idf.yaml b/tests/components/esp32/test.esp32-idf.yaml index 6338fe98dd8..0e220623a18 100644 --- a/tests/components/esp32/test.esp32-idf.yaml +++ b/tests/components/esp32/test.esp32-idf.yaml @@ -3,6 +3,7 @@ esp32: framework: type: esp-idf advanced: + enable_ota_rollback: true enable_lwip_mdns_queries: true enable_lwip_bridge_interface: true disable_libc_locks_in_iram: false # Test explicit opt-out of RAM optimization diff --git a/tests/components/espnow/common.yaml b/tests/components/espnow/common.yaml index 895ffb9d155..b724af54e05 100644 --- a/tests/components/espnow/common.yaml +++ b/tests/components/espnow/common.yaml @@ -62,7 +62,7 @@ packet_transport: sensors: - temp_sensor providers: - - name: test_provider + - name: test-provider encryption: key: "0123456789abcdef0123456789abcdef" @@ -71,6 +71,6 @@ sensor: id: temp_sensor - platform: packet_transport - provider: test_provider + provider: test-provider remote_id: temp_sensor id: remote_temp diff --git a/tests/components/ethernet/test-lan8720-with-expander.esp32-idf.yaml b/tests/components/ethernet/test-lan8720-with-expander.esp32-idf.yaml new file mode 100644 index 00000000000..09da8d90d97 --- /dev/null +++ b/tests/components/ethernet/test-lan8720-with-expander.esp32-idf.yaml @@ -0,0 +1,15 @@ +<<: !include common-lan8720.yaml + +sn74hc165: + - id: sn74hc165_hub + clock_pin: GPIO13 + data_pin: GPIO14 + load_pin: GPIO15 + sr_count: 3 + +binary_sensor: + - platform: gpio + pin: + sn74hc165: sn74hc165_hub + number: 19 + id: relay_2 diff --git a/tests/components/hmac_sha256/common.yaml b/tests/components/hmac_sha256/common.yaml new file mode 100644 index 00000000000..9bbed295fdd --- /dev/null +++ b/tests/components/hmac_sha256/common.yaml @@ -0,0 +1,34 @@ +esphome: + on_boot: + - lambda: |- + // Test HMAC-SHA256 functionality + #ifdef USE_SHA256 + using esphome::hmac_sha256::HmacSHA256; + HmacSHA256 hmac; + + // Test with key "key" and message "The quick brown fox jumps over the lazy dog" + const char* key = "key"; + const char* message = "The quick brown fox jumps over the lazy dog"; + + hmac.init(key, strlen(key)); + hmac.add(message, strlen(message)); + hmac.calculate(); + + char hex_output[65]; + hmac.get_hex(hex_output); + hex_output[64] = '\0'; + + ESP_LOGD("HMAC_SHA256", "HMAC-SHA256('%s', '%s') = %s", key, message, hex_output); + + // Expected: f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8 + const char* expected = "f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8"; + if (strcmp(hex_output, expected) == 0) { + ESP_LOGI("HMAC_SHA256", "Test PASSED"); + } else { + ESP_LOGE("HMAC_SHA256", "Test FAILED. Expected %s", expected); + } + #else + ESP_LOGW("HMAC_SHA256", "HMAC-SHA256 not available on this platform"); + #endif + +hmac_sha256: diff --git a/tests/components/hmac_sha256/test.bk72xx-ard.yaml b/tests/components/hmac_sha256/test.bk72xx-ard.yaml new file mode 100644 index 00000000000..dade44d145b --- /dev/null +++ b/tests/components/hmac_sha256/test.bk72xx-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/hmac_sha256/test.esp32-ard.yaml b/tests/components/hmac_sha256/test.esp32-ard.yaml new file mode 100644 index 00000000000..dade44d145b --- /dev/null +++ b/tests/components/hmac_sha256/test.esp32-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/hmac_sha256/test.esp32-idf.yaml b/tests/components/hmac_sha256/test.esp32-idf.yaml new file mode 100644 index 00000000000..dade44d145b --- /dev/null +++ b/tests/components/hmac_sha256/test.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/hmac_sha256/test.esp8266-ard.yaml b/tests/components/hmac_sha256/test.esp8266-ard.yaml new file mode 100644 index 00000000000..dade44d145b --- /dev/null +++ b/tests/components/hmac_sha256/test.esp8266-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/hmac_sha256/test.host.yaml b/tests/components/hmac_sha256/test.host.yaml new file mode 100644 index 00000000000..dade44d145b --- /dev/null +++ b/tests/components/hmac_sha256/test.host.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/hmac_sha256/test.rp2040-ard.yaml b/tests/components/hmac_sha256/test.rp2040-ard.yaml new file mode 100644 index 00000000000..dade44d145b --- /dev/null +++ b/tests/components/hmac_sha256/test.rp2040-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/hmac_sha256/test.rtl87xx-ard.yaml b/tests/components/hmac_sha256/test.rtl87xx-ard.yaml new file mode 100644 index 00000000000..dade44d145b --- /dev/null +++ b/tests/components/hmac_sha256/test.rtl87xx-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/hub75/common.yaml b/tests/components/hub75/common.yaml new file mode 100644 index 00000000000..87e9e1c1282 --- /dev/null +++ b/tests/components/hub75/common.yaml @@ -0,0 +1,12 @@ +esphome: + on_boot: + # Test simple value + - hub75.set_brightness: 200 + + # Test templatable value + - hub75.set_brightness: !lambda 'return 100;' + + # Test with explicit ID + - hub75.set_brightness: + id: my_hub75 + brightness: 50 diff --git a/tests/components/hub75/test.esp32-idf.yaml b/tests/components/hub75/test.esp32-idf.yaml index 9f6bd572927..dad2a02c240 100644 --- a/tests/components/hub75/test.esp32-idf.yaml +++ b/tests/components/hub75/test.esp32-idf.yaml @@ -1,8 +1,3 @@ -esp32: - board: esp32dev - framework: - type: esp-idf - display: - platform: hub75 id: my_hub75 @@ -37,3 +32,5 @@ display: then: lambda: |- ESP_LOGD("display", "1 -> 2"); + +<<: !include common.yaml diff --git a/tests/components/hub75/test.esp32-s3-idf-board.yaml b/tests/components/hub75/test.esp32-s3-idf-board.yaml index 9568ccf3aa5..3723a800064 100644 --- a/tests/components/hub75/test.esp32-s3-idf-board.yaml +++ b/tests/components/hub75/test.esp32-s3-idf-board.yaml @@ -1,8 +1,3 @@ -esp32: - board: esp32-s3-devkitc-1 - framework: - type: esp-idf - display: - platform: hub75 id: hub75_display_board @@ -24,3 +19,5 @@ display: then: lambda: |- ESP_LOGD("display", "1 -> 2"); + +<<: !include common.yaml diff --git a/tests/components/hub75/test.esp32-s3-idf.yaml b/tests/components/hub75/test.esp32-s3-idf.yaml index db678c98a4d..f8ee26e73d6 100644 --- a/tests/components/hub75/test.esp32-s3-idf.yaml +++ b/tests/components/hub75/test.esp32-s3-idf.yaml @@ -1,8 +1,3 @@ -esp32: - board: esp32-s3-devkitc-1 - framework: - type: esp-idf - display: - platform: hub75 id: my_hub75 @@ -37,3 +32,5 @@ display: then: lambda: |- ESP_LOGD("display", "1 -> 2"); + +<<: !include common.yaml diff --git a/tests/components/mmc5603/common.yaml b/tests/components/mmc5603/common.yaml index 6f6e35e9af1..ddb5c3b25e9 100644 --- a/tests/components/mmc5603/common.yaml +++ b/tests/components/mmc5603/common.yaml @@ -2,6 +2,7 @@ sensor: - platform: mmc5603 i2c_id: i2c_bus address: 0x30 + auto_set_reset: true field_strength_x: name: HMC5883L Field Strength X field_strength_y: diff --git a/tests/components/pmsx003/common.yaml b/tests/components/pmsx003/common.yaml index 3c609958043..9dd79723d10 100644 --- a/tests/components/pmsx003/common.yaml +++ b/tests/components/pmsx003/common.yaml @@ -25,4 +25,7 @@ sensor: name: Particulate Count >5.0um pm_10_0um: name: Particulate Count >10.0um + aqi: + name: AQI + calculation_type: AQI update_interval: 30s diff --git a/tests/components/update/common.yaml b/tests/components/update/common.yaml index 521a0a6a5cf..40042945c8b 100644 --- a/tests/components/update/common.yaml +++ b/tests/components/update/common.yaml @@ -9,6 +9,8 @@ esphome: update.is_available: then: - logger.log: "Update available" + else: + - update.check: - update.perform: force_update: true diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index 385841b1c89..10ca6061e63 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -1567,3 +1567,90 @@ async def test_dashboard_yaml_loading_with_packages_and_secrets( # 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() diff --git a/tests/dummy_main.cpp b/tests/dummy_main.cpp index afd393c095e..5849f4eb952 100644 --- a/tests/dummy_main.cpp +++ b/tests/dummy_main.cpp @@ -12,7 +12,7 @@ using namespace esphome; void setup() { - App.pre_setup("livingroom", "LivingRoom", "comment", __DATE__ ", " __TIME__, false); + App.pre_setup("livingroom", "LivingRoom", "comment", false); auto *log = new logger::Logger(115200, 512); // NOLINT log->pre_setup(); log->set_uart_selection(logger::UART_SELECTION_UART0); diff --git a/tests/integration/fixtures/alarm_control_panel_state_transitions.yaml b/tests/integration/fixtures/alarm_control_panel_state_transitions.yaml new file mode 100644 index 00000000000..1edb401a0de --- /dev/null +++ b/tests/integration/fixtures/alarm_control_panel_state_transitions.yaml @@ -0,0 +1,106 @@ +esphome: + name: alarm-state-transitions + friendly_name: "Alarm Control Panel State Transitions Test" + +logger: + +host: + +globals: + - id: door_sensor_state + type: bool + initial_value: "false" + - id: chime_sensor_state + type: bool + initial_value: "false" + +switch: + # Switch to control the door sensor state + - platform: template + id: door_sensor_switch + name: "Door Sensor Switch" + optimistic: true + turn_on_action: + - globals.set: + id: door_sensor_state + value: "true" + turn_off_action: + - globals.set: + id: door_sensor_state + value: "false" + # Switch to control the chime sensor state + - platform: template + id: chime_sensor_switch + name: "Chime Sensor Switch" + optimistic: true + turn_on_action: + - globals.set: + id: chime_sensor_state + value: "true" + turn_off_action: + - globals.set: + id: chime_sensor_state + value: "false" + +binary_sensor: + - platform: template + id: door_sensor + name: "Door Sensor" + lambda: |- + return id(door_sensor_state); + - platform: template + id: chime_sensor + name: "Chime Sensor" + lambda: |- + return id(chime_sensor_state); + +alarm_control_panel: + - platform: template + id: test_alarm + name: "Test Alarm" + codes: + - "1234" + requires_code_to_arm: true + # Short timeouts for faster testing + arming_away_time: 50ms + arming_home_time: 50ms + arming_night_time: 50ms + pending_time: 50ms + trigger_time: 100ms + restore_mode: ALWAYS_DISARMED + binary_sensors: + - input: door_sensor + bypass_armed_home: false + bypass_armed_night: false + chime: false + trigger_mode: DELAYED + - input: chime_sensor + bypass_armed_home: true + bypass_armed_night: true + chime: true + trigger_mode: DELAYED + on_state: + - logger.log: "State changed" + on_disarmed: + - logger.log: "Alarm disarmed" + on_arming: + - logger.log: "Alarm arming" + on_armed_away: + - logger.log: "Alarm armed away" + on_armed_home: + - logger.log: "Alarm armed home" + on_armed_night: + - logger.log: "Alarm armed night" + on_pending: + - logger.log: "Alarm pending" + on_triggered: + - logger.log: "Alarm triggered" + on_cleared: + - logger.log: "Alarm cleared" + on_chime: + - logger.log: "Chime activated" + on_ready: + - logger.log: "Sensors ready state changed" + +api: + batch_delay: 0ms diff --git a/tests/integration/fixtures/build_info.yaml b/tests/integration/fixtures/build_info.yaml new file mode 100644 index 00000000000..5d6101543af --- /dev/null +++ b/tests/integration/fixtures/build_info.yaml @@ -0,0 +1,31 @@ +esphome: + name: build-info-test +host: +api: +logger: + +text_sensor: + - platform: template + name: "Config Hash" + id: config_hash_sensor + update_interval: 100ms + lambda: |- + char buf[16]; + snprintf(buf, sizeof(buf), "0x%08x", App.get_config_hash()); + return std::string(buf); + - platform: template + name: "Build Time" + id: build_time_sensor + update_interval: 100ms + lambda: |- + char buf[32]; + snprintf(buf, sizeof(buf), "%ld", (long)App.get_build_time()); + return std::string(buf); + - platform: template + name: "Build Time String" + id: build_time_str_sensor + update_interval: 100ms + lambda: |- + char buf[Application::BUILD_TIME_STR_SIZE]; + App.get_build_time_string(buf); + return std::string(buf); diff --git a/tests/integration/fixtures/fnv1a_hash.yaml b/tests/integration/fixtures/fnv1a_hash.yaml new file mode 100644 index 00000000000..d9c80601b88 --- /dev/null +++ b/tests/integration/fixtures/fnv1a_hash.yaml @@ -0,0 +1,60 @@ +esphome: + name: fnv1a-hash-test + platformio_options: + build_flags: + - "-DDEBUG" + on_boot: + - lambda: |- + using esphome::fnv1a_hash; + using esphome::fnv1a_hash_extend; + + // Test empty string (should return offset basis) + uint32_t hash_empty = fnv1a_hash(""); + if (hash_empty == 0x811c9dc5) { + ESP_LOGI("FNV1A", "empty PASSED"); + } else { + ESP_LOGE("FNV1A", "empty FAILED: 0x%08x != 0x811c9dc5", hash_empty); + } + + // Test known FNV-1a hashes + uint32_t hash_hello = fnv1a_hash("hello"); + if (hash_hello == 0x4f9f2cab) { + ESP_LOGI("FNV1A", "known_hello PASSED"); + } else { + ESP_LOGE("FNV1A", "known_hello FAILED: 0x%08x != 0x4f9f2cab", hash_hello); + } + + uint32_t hash_helloworld = fnv1a_hash("helloworld"); + if (hash_helloworld == 0x3b9f5c61) { + ESP_LOGI("FNV1A", "known_helloworld PASSED"); + } else { + ESP_LOGE("FNV1A", "known_helloworld FAILED: 0x%08x != 0x3b9f5c61", hash_helloworld); + } + + // Test fnv1a_hash_extend consistency + uint32_t hash1 = fnv1a_hash("hello"); + hash1 = fnv1a_hash_extend(hash1, "world"); + uint32_t hash2 = fnv1a_hash("helloworld"); + + if (hash1 == hash2) { + ESP_LOGI("FNV1A", "extend PASSED"); + } else { + ESP_LOGE("FNV1A", "extend FAILED: 0x%08x != 0x%08x", hash1, hash2); + } + + // Test with std::string + std::string str1 = "foo"; + std::string str2 = "bar"; + uint32_t hash3 = fnv1a_hash(str1); + hash3 = fnv1a_hash_extend(hash3, str2); + uint32_t hash4 = fnv1a_hash("foobar"); + + if (hash3 == hash4) { + ESP_LOGI("FNV1A", "string PASSED"); + } else { + ESP_LOGE("FNV1A", "string FAILED: 0x%08x != 0x%08x", hash3, hash4); + } + +host: +api: +logger: diff --git a/tests/integration/fixtures/syslog.yaml b/tests/integration/fixtures/syslog.yaml new file mode 100644 index 00000000000..df376087e32 --- /dev/null +++ b/tests/integration/fixtures/syslog.yaml @@ -0,0 +1,43 @@ +esphome: + name: syslog-test + +host: + +api: + services: + - service: log_long_message + then: + - lambda: |- + // Log a message that exceeds 508 bytes to test truncation + ESP_LOGI("trunctest", "START|%s|END", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" + "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC" + "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD" + "EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE" + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"); + - service: log_short_message + then: + - lambda: |- + // Log a short message that should arrive complete (not truncated) + ESP_LOGI("shorttest", "BEGIN|SHORT_MESSAGE_CONTENT|FINISH"); + +logger: + level: DEBUG + +time: + - platform: host + id: host_time + +udp: + - id: syslog_udp + addresses: + - "127.0.0.1" + +syslog: + udp_id: syslog_udp + time_id: host_time + port: SYSLOG_PORT_PLACEHOLDER + level: DEBUG + strip: true + facility: 16 diff --git a/tests/integration/test_alarm_control_panel_state_transitions.py b/tests/integration/test_alarm_control_panel_state_transitions.py new file mode 100644 index 00000000000..09348f5beaa --- /dev/null +++ b/tests/integration/test_alarm_control_panel_state_transitions.py @@ -0,0 +1,335 @@ +"""Integration test for alarm control panel state transitions.""" + +from __future__ import annotations + +import asyncio +import re + +import aioesphomeapi +from aioesphomeapi import ( + AlarmControlPanelCommand, + AlarmControlPanelEntityState, + AlarmControlPanelInfo, + AlarmControlPanelState, + SwitchInfo, +) +import pytest + +from .state_utils import InitialStateHelper +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_alarm_control_panel_state_transitions( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test alarm control panel state transitions. + + This comprehensive test verifies all state transitions and listener callbacks: + + 1. Basic arm/disarm sequences: + - DISARMED -> ARMING -> ARMED_AWAY -> DISARMED + - DISARMED -> ARMING -> ARMED_HOME -> DISARMED + - DISARMED -> ARMING -> ARMED_NIGHT -> DISARMED + + 2. Wrong code rejection + + 3. Sensor triggering while armed: + - ARMED_AWAY -> PENDING -> TRIGGERED (delayed sensor) + - TRIGGERED -> ARMED_AWAY (auto-reset after trigger_time, fires on_cleared) + + 4. Chime functionality: + - Sensor open while DISARMED triggers on_chime + + 5. Ready state: + - Sensor state changes trigger on_ready + """ + loop = asyncio.get_running_loop() + + # Track log messages for callback verification + log_lines: list[str] = [] + chime_future: asyncio.Future[bool] = loop.create_future() + ready_futures: list[asyncio.Future[bool]] = [] + cleared_future: asyncio.Future[bool] = loop.create_future() + + # Patterns to match log output from callbacks + chime_pattern = re.compile(r"Chime activated") + ready_pattern = re.compile(r"Sensors ready state changed") + cleared_pattern = re.compile(r"Alarm cleared") + + def on_log_line(line: str) -> None: + log_lines.append(line) + if not chime_future.done() and chime_pattern.search(line): + chime_future.set_result(True) + if ready_pattern.search(line): + # Create new future for each ready event + for fut in ready_futures: + if not fut.done(): + fut.set_result(True) + break + if not cleared_future.done() and cleared_pattern.search(line): + cleared_future.set_result(True) + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + + # Find entities + alarm_info: AlarmControlPanelInfo | None = None + door_switch_info: SwitchInfo | None = None + chime_switch_info: SwitchInfo | None = None + + for entity in entities: + if isinstance(entity, AlarmControlPanelInfo): + alarm_info = entity + elif isinstance(entity, SwitchInfo): + if entity.name == "Door Sensor Switch": + door_switch_info = entity + elif entity.name == "Chime Sensor Switch": + chime_switch_info = entity + + assert alarm_info is not None, "Alarm control panel not found" + assert door_switch_info is not None, "Door sensor switch not found" + assert chime_switch_info is not None, "Chime sensor switch not found" + + # Track state changes + states_received: list[AlarmControlPanelState] = [] + state_event = asyncio.Event() + + def on_state(state: aioesphomeapi.EntityState) -> None: + if ( + isinstance(state, AlarmControlPanelEntityState) + and state.key == alarm_info.key + ): + states_received.append(state.state) + state_event.set() + + # Use InitialStateHelper to handle initial state broadcast + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + + # Wait for initial states from all entities + await initial_state_helper.wait_for_initial_states() + + # Verify alarm panel started in DISARMED state + initial_alarm_state = initial_state_helper.initial_states.get(alarm_info.key) + assert initial_alarm_state is not None, "No initial alarm state received" + assert isinstance(initial_alarm_state, AlarmControlPanelEntityState) + assert initial_alarm_state.state == AlarmControlPanelState.DISARMED + + # Helper to wait for specific state + async def wait_for_state( + expected: AlarmControlPanelState, timeout: float = 5.0 + ) -> None: + deadline = loop.time() + timeout + while True: + remaining = deadline - loop.time() + if remaining <= 0: + raise TimeoutError( + f"Timeout waiting for state {expected}, " + f"last state: {states_received[-1] if states_received else 'none'}" + ) + await asyncio.wait_for(state_event.wait(), timeout=remaining) + state_event.clear() + if states_received[-1] == expected: + return + + # ===== Test wrong code rejection ===== + client.alarm_control_panel_command( + alarm_info.key, + AlarmControlPanelCommand.ARM_AWAY, + code="0000", # Wrong code + ) + + # Should NOT transition - wait a bit and verify no state changes + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(state_event.wait(), timeout=0.5) + # No state changes should have occurred (list is empty) + assert len(states_received) == 0, f"Unexpected state changes: {states_received}" + + # ===== Test ARM_AWAY sequence ===== + client.alarm_control_panel_command( + alarm_info.key, + AlarmControlPanelCommand.ARM_AWAY, + code="1234", + ) + await wait_for_state(AlarmControlPanelState.ARMING) + await wait_for_state(AlarmControlPanelState.ARMED_AWAY) + + # Disarm + client.alarm_control_panel_command( + alarm_info.key, + AlarmControlPanelCommand.DISARM, + code="1234", + ) + await wait_for_state(AlarmControlPanelState.DISARMED) + + # ===== Test ARM_HOME sequence ===== + client.alarm_control_panel_command( + alarm_info.key, + AlarmControlPanelCommand.ARM_HOME, + code="1234", + ) + await wait_for_state(AlarmControlPanelState.ARMING) + await wait_for_state(AlarmControlPanelState.ARMED_HOME) + + # Disarm + client.alarm_control_panel_command( + alarm_info.key, + AlarmControlPanelCommand.DISARM, + code="1234", + ) + await wait_for_state(AlarmControlPanelState.DISARMED) + + # ===== Test ARM_NIGHT sequence ===== + client.alarm_control_panel_command( + alarm_info.key, + AlarmControlPanelCommand.ARM_NIGHT, + code="1234", + ) + await wait_for_state(AlarmControlPanelState.ARMING) + await wait_for_state(AlarmControlPanelState.ARMED_NIGHT) + + # Disarm + client.alarm_control_panel_command( + alarm_info.key, + AlarmControlPanelCommand.DISARM, + code="1234", + ) + await wait_for_state(AlarmControlPanelState.DISARMED) + + # Verify basic state sequence (initial DISARMED is handled by InitialStateHelper) + expected_states = [ + AlarmControlPanelState.ARMING, # Arm away + AlarmControlPanelState.ARMED_AWAY, + AlarmControlPanelState.DISARMED, + AlarmControlPanelState.ARMING, # Arm home + AlarmControlPanelState.ARMED_HOME, + AlarmControlPanelState.DISARMED, + AlarmControlPanelState.ARMING, # Arm night + AlarmControlPanelState.ARMED_NIGHT, + AlarmControlPanelState.DISARMED, + ] + assert states_received == expected_states, ( + f"State sequence mismatch.\nExpected: {expected_states}\n" + f"Got: {states_received}" + ) + + # ===== Test PENDING -> TRIGGERED -> CLEARED sequence ===== + # This tests on_pending, on_triggered, and on_cleared callbacks + + # Arm away first + client.alarm_control_panel_command( + alarm_info.key, + AlarmControlPanelCommand.ARM_AWAY, + code="1234", + ) + await wait_for_state(AlarmControlPanelState.ARMING) + await wait_for_state(AlarmControlPanelState.ARMED_AWAY) + + # Trip the door sensor (delayed mode triggers PENDING first) + client.switch_command(door_switch_info.key, True) + + # Should go to PENDING (delayed sensor) + await wait_for_state(AlarmControlPanelState.PENDING) + + # Should go to TRIGGERED after pending_time (50ms) + await wait_for_state(AlarmControlPanelState.TRIGGERED) + + # Close the sensor + client.switch_command(door_switch_info.key, False) + + # Wait for trigger_time to expire and auto-reset (100ms) + # The alarm should go back to ARMED_AWAY after trigger_time + # This transition FROM TRIGGERED fires on_cleared + await wait_for_state(AlarmControlPanelState.ARMED_AWAY, timeout=2.0) + + # Verify on_cleared was logged + try: + await asyncio.wait_for(cleared_future, timeout=1.0) + except TimeoutError: + pytest.fail(f"on_cleared callback not fired. Log lines: {log_lines[-20:]}") + + # Disarm + client.alarm_control_panel_command( + alarm_info.key, + AlarmControlPanelCommand.DISARM, + code="1234", + ) + await wait_for_state(AlarmControlPanelState.DISARMED) + + # Verify trigger sequence was added + assert AlarmControlPanelState.PENDING in states_received + assert AlarmControlPanelState.TRIGGERED in states_received + + # ===== Test chime (sensor open while disarmed) ===== + # The chime_sensor has chime: true, so opening it while disarmed + # should trigger on_chime callback + + # We're currently DISARMED - open the chime sensor + client.switch_command(chime_switch_info.key, True) + + # Wait for chime callback to be logged + try: + await asyncio.wait_for(chime_future, timeout=2.0) + except TimeoutError: + pytest.fail(f"on_chime callback not fired. Log lines: {log_lines[-20:]}") + + # Close the chime sensor and wait for alarm to become ready again + # We need to wait for this transition before testing door sensor, + # otherwise there's a race where the door sensor state change could + # arrive before the chime sensor state change, leaving the alarm in + # a continuous "not ready" state with no on_ready callback fired. + ready_after_chime_close: asyncio.Future[bool] = loop.create_future() + ready_futures.append(ready_after_chime_close) + + client.switch_command(chime_switch_info.key, False) + + # Wait for alarm to become ready again (chime sensor closed) + try: + await asyncio.wait_for(ready_after_chime_close, timeout=2.0) + except TimeoutError: + pytest.fail( + f"on_ready callback not fired when chime sensor closed. " + f"Log lines: {log_lines[-20:]}" + ) + + # ===== Test ready state changes ===== + # Now the alarm is confirmed ready. Opening/closing door sensor + # should trigger on_ready callbacks. + + # Set up futures for door sensor state changes + ready_future_1: asyncio.Future[bool] = loop.create_future() + ready_future_2: asyncio.Future[bool] = loop.create_future() + ready_futures.extend([ready_future_1, ready_future_2]) + + # Open door sensor (makes alarm not ready) + client.switch_command(door_switch_info.key, True) + + # Wait for first on_ready callback (not ready) + try: + await asyncio.wait_for(ready_future_1, timeout=2.0) + except TimeoutError: + pytest.fail( + f"on_ready callback not fired when sensor opened. " + f"Log lines: {log_lines[-20:]}" + ) + + # Close door sensor (makes alarm ready again) + client.switch_command(door_switch_info.key, False) + + # Wait for second on_ready callback (ready) + try: + await asyncio.wait_for(ready_future_2, timeout=2.0) + except TimeoutError: + pytest.fail( + f"on_ready callback not fired when sensor closed. " + f"Log lines: {log_lines[-20:]}" + ) + + # Final state should still be DISARMED + assert states_received[-1] == AlarmControlPanelState.DISARMED diff --git a/tests/integration/test_api_homeassistant.py b/tests/integration/test_api_homeassistant.py index 1343691f5fc..3fe0dfe0453 100644 --- a/tests/integration/test_api_homeassistant.py +++ b/tests/integration/test_api_homeassistant.py @@ -179,6 +179,12 @@ async def test_api_homeassistant( client.send_home_assistant_state("binary_sensor.external_motion", "", "ON") client.send_home_assistant_state("weather.home", "condition", "sunny") + # Test edge cases for zero-copy implementation safety + # Empty entity_id should be silently ignored (no crash) + client.send_home_assistant_state("", "", "should_be_ignored") + # Empty state with valid entity should work (use different entity to not interfere with test) + client.send_home_assistant_state("sensor.edge_case_empty_state", "", "") + # List entities and services _, services = await client.list_entities_services() diff --git a/tests/integration/test_build_info.py b/tests/integration/test_build_info.py new file mode 100644 index 00000000000..7079594471f --- /dev/null +++ b/tests/integration/test_build_info.py @@ -0,0 +1,117 @@ +"""Integration test for build_info values.""" + +from __future__ import annotations + +import asyncio +from datetime import datetime +import re +import time + +from aioesphomeapi import EntityState, TextSensorState +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_build_info( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that build_info values are sane.""" + 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 == "build-info-test" + + # Verify compilation_time from device_info is present and parseable + # The format is ISO 8601 with timezone: "YYYY-MM-DD HH:MM:SS +ZZZZ" + compilation_time = device_info.compilation_time + assert compilation_time is not None + + # Validate the ISO format: "YYYY-MM-DD HH:MM:SS +ZZZZ" + parsed = datetime.strptime(compilation_time, "%Y-%m-%d %H:%M:%S %z") + assert parsed.year >= time.localtime().tm_year + + # Get entities + entities, _ = await client.list_entities_services() + + # Find our text sensors by object_id + config_hash_entity = next( + (e for e in entities if e.object_id == "config_hash"), None + ) + build_time_entity = next( + (e for e in entities if e.object_id == "build_time"), None + ) + build_time_str_entity = next( + (e for e in entities if e.object_id == "build_time_string"), None + ) + + assert config_hash_entity is not None, "Config Hash sensor not found" + assert build_time_entity is not None, "Build Time sensor not found" + assert build_time_str_entity is not None, "Build Time String sensor not found" + + # Wait for all three text sensors to have valid states + loop = asyncio.get_running_loop() + states: dict[int, TextSensorState] = {} + all_received = loop.create_future() + expected_keys = { + config_hash_entity.key, + build_time_entity.key, + build_time_str_entity.key, + } + + def on_state(state: EntityState) -> None: + if isinstance(state, TextSensorState) and not state.missing_state: + states[state.key] = state + if expected_keys <= states.keys() and not all_received.done(): + all_received.set_result(True) + + client.subscribe_states(on_state) + + try: + await asyncio.wait_for(all_received, timeout=5.0) + except TimeoutError: + pytest.fail( + f"Timeout waiting for text sensor states. Got: {list(states.keys())}" + ) + + config_hash_state = states[config_hash_entity.key] + build_time_state = states[build_time_entity.key] + build_time_str_state = states[build_time_str_entity.key] + + # Validate config_hash format (0x followed by 8 hex digits) + config_hash = config_hash_state.state + assert re.match(r"^0x[0-9a-f]{8}$", config_hash), ( + f"config_hash should be 0x followed by 8 hex digits, got: {config_hash}" + ) + + # Validate build_time is a reasonable Unix timestamp + build_time = int(build_time_state.state) + current_time = int(time.time()) + # Build time should be within last hour and not in the future + assert build_time <= current_time, ( + f"build_time {build_time} should not be in the future (current: {current_time})" + ) + assert build_time > current_time - 3600, ( + f"build_time {build_time} should be within the last hour" + ) + + # Validate build_time_str matches the new ISO format + build_time_str = build_time_str_state.state + # Format: "YYYY-MM-DD HH:MM:SS +ZZZZ" + parsed_build_time = datetime.strptime(build_time_str, "%Y-%m-%d %H:%M:%S %z") + assert parsed_build_time.year >= time.localtime().tm_year + + # Verify build_time_str matches what we get from build_time timestamp + expected_str = time.strftime("%Y-%m-%d %H:%M:%S %z", time.localtime(build_time)) + assert build_time_str == expected_str, ( + f"build_time_str '{build_time_str}' should match timestamp '{expected_str}'" + ) + + # Verify compilation_time matches build_time_str (they should be the same) + assert compilation_time == build_time_str, ( + f"compilation_time '{compilation_time}' should match " + f"build_time_str '{build_time_str}'" + ) diff --git a/tests/integration/test_fnv1a_hash.py b/tests/integration/test_fnv1a_hash.py new file mode 100644 index 00000000000..366ea42cda4 --- /dev/null +++ b/tests/integration/test_fnv1a_hash.py @@ -0,0 +1,69 @@ +"""Integration test for FNV-1a hash functions.""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_fnv1a_hash( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that FNV-1a hash functions work correctly.""" + + test_results = {} + all_tests_complete = asyncio.Event() + expected_tests = {"empty", "known_hello", "known_helloworld", "extend", "string"} + + def on_log_line(line: str) -> None: + """Capture log lines with test results.""" + # Strip ANSI escape codes + clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line) + # Look for our test result messages + # Format: "[timestamp][level][FNV1A:line]: test_name PASSED" + match = re.search(r"\[FNV1A:\d+\]:\s+(\w+)\s+(PASSED|FAILED)", clean_line) + if match: + test_name = match.group(1) + result = match.group(2) + test_results[test_name] = result + if set(test_results.keys()) >= expected_tests: + all_tests_complete.set() + + 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 == "fnv1a-hash-test" + + # Wait for all tests to complete or timeout + try: + await asyncio.wait_for(all_tests_complete.wait(), timeout=2.0) + except TimeoutError: + pytest.fail(f"Tests timed out. Got results for: {set(test_results.keys())}") + + # Verify all tests passed + assert "empty" in test_results, "empty string test not found" + assert test_results["empty"] == "PASSED", "empty string test failed" + + assert "known_hello" in test_results, "known_hello test not found" + assert test_results["known_hello"] == "PASSED", "known_hello test failed" + + assert "known_helloworld" in test_results, "known_helloworld test not found" + assert test_results["known_helloworld"] == "PASSED", ( + "known_helloworld test failed" + ) + + assert "extend" in test_results, "fnv1a_hash_extend test not found" + assert test_results["extend"] == "PASSED", "fnv1a_hash_extend test failed" + + assert "string" in test_results, "std::string test not found" + assert test_results["string"] == "PASSED", "std::string test failed" diff --git a/tests/integration/test_syslog.py b/tests/integration/test_syslog.py new file mode 100644 index 00000000000..b31a19392c0 --- /dev/null +++ b/tests/integration/test_syslog.py @@ -0,0 +1,284 @@ +"""Integration test for syslog component.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncGenerator +import contextlib +from contextlib import asynccontextmanager +from dataclasses import dataclass, field +import re +import socket +from typing import TypedDict + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +class ParsedSyslogMessage(TypedDict): + """Parsed syslog message components.""" + + pri: int + facility: int + severity: int + timestamp: str + hostname: str + tag: str + message: str + + +# RFC 3164 syslog message pattern: +# TIMESTAMP HOSTNAME TAG: MESSAGE +# Example: <134>Dec 20 14:30:45 syslog-test app: [D][app:029]: Running... +SYSLOG_PATTERN = re.compile( + r"<(\d+)>" # PRI (priority = facility * 8 + severity) + r"(\S+ +\d+ \d+:\d+:\d+|-)" # TIMESTAMP (BSD-style "%b %e %H:%M:%S", e.g. "Dec 20 14:30:45", or NILVALUE "-") + r" (\S+)" # HOSTNAME + r" (\S+):" # TAG + r" (.*)" # MESSAGE +) + + +@dataclass +class SyslogReceiver: + """Collects syslog messages received over UDP.""" + + messages: list[str] = field(default_factory=list) + message_received: asyncio.Event = field(default_factory=asyncio.Event) + _waiters: list[tuple[re.Pattern, asyncio.Event]] = field(default_factory=list) + + def on_message(self, msg: str) -> None: + """Called when a message is received.""" + self.messages.append(msg) + self.message_received.set() + # Check pattern waiters + for pattern, event in self._waiters: + if pattern.search(msg): + event.set() + + async def wait_for_messages(self, timeout: float = 10.0) -> None: + """Wait for at least one message to be received.""" + await asyncio.wait_for(self.message_received.wait(), timeout=timeout) + + async def wait_for_pattern(self, pattern: str, timeout: float = 5.0) -> str: + """Wait for a message matching the pattern.""" + compiled = re.compile(pattern) + event = asyncio.Event() + self._waiters.append((compiled, event)) + try: + # Check existing messages first + for msg in self.messages: + if compiled.search(msg): + return msg + # Wait for new message + await asyncio.wait_for(event.wait(), timeout=timeout) + # Find and return the matching message + for msg in reversed(self.messages): + if compiled.search(msg): + return msg + raise RuntimeError("Event set but no matching message found") + finally: + self._waiters.remove((compiled, event)) + + +@asynccontextmanager +async def syslog_udp_listener() -> AsyncGenerator[tuple[int, SyslogReceiver]]: + """Async context manager that listens for syslog UDP messages. + + Yields: + Tuple of (port, SyslogReceiver) where port is the UDP port to send to + and SyslogReceiver contains the received messages. + """ + # Create and bind UDP socket + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(("127.0.0.1", 0)) + sock.setblocking(False) + port = sock.getsockname()[1] + + receiver = SyslogReceiver() + + async def receive_messages() -> None: + """Background task to receive syslog messages.""" + loop = asyncio.get_running_loop() + while True: + try: + data = await loop.sock_recv(sock, 4096) + if data: + msg = data.decode("utf-8", errors="replace") + receiver.on_message(msg) + except BlockingIOError: + await asyncio.sleep(0.01) + except Exception: + break + + task = asyncio.create_task(receive_messages()) + try: + yield port, receiver + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + sock.close() + + +def parse_syslog_message(msg: str) -> ParsedSyslogMessage | None: + """Parse a syslog message and return its components.""" + match = SYSLOG_PATTERN.match(msg) + if not match: + return None + pri, timestamp, hostname, tag, message = match.groups() + pri_val = int(pri) + # PRI = facility * 8 + severity + facility = pri_val // 8 + severity = pri_val % 8 + return ParsedSyslogMessage( + pri=pri_val, + facility=facility, + severity=severity, + timestamp=timestamp, + hostname=hostname, + tag=tag, + message=message, + ) + + +@pytest.mark.asyncio +async def test_syslog( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test syslog component sends properly formatted messages.""" + async with syslog_udp_listener() as (udp_port, receiver): + # Replace the placeholder port in the config + config = yaml_config.replace("SYSLOG_PORT_PLACEHOLDER", str(udp_port)) + + async with run_compiled(config), api_client_connected() as client: + # Verify device is running + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "syslog-test" + + # Wait for syslog messages (ESPHome logs during startup) + try: + await receiver.wait_for_messages(timeout=10.0) + except TimeoutError: + pytest.fail("No syslog messages received within timeout") + + # Give it a moment to collect more messages + await asyncio.sleep(0.5) + + # Verify we received messages + assert len(receiver.messages) > 0, "No syslog messages received" + + # Parse and validate all messages + parsed_messages: list[ParsedSyslogMessage] = [] + for msg in receiver.messages: + parsed = parse_syslog_message(msg) + if parsed: + parsed_messages.append(parsed) + + assert len(parsed_messages) > 0, ( + f"No valid syslog messages found. Received: {receiver.messages[:5]}" + ) + + # Validate message format for all parsed messages + for parsed in parsed_messages: + # Validate PRI is in valid range (0-191) + assert 0 <= parsed["pri"] <= 191, f"Invalid PRI: {parsed['pri']}" + + # Validate facility matches config (16 = local0) + assert parsed["facility"] == 16, ( + f"Expected facility 16, got {parsed['facility']}" + ) + + # Validate severity is in valid range (0-7) + assert 0 <= parsed["severity"] <= 7, ( + f"Invalid severity: {parsed['severity']}" + ) + + # Validate hostname matches device name + assert parsed["hostname"] == "syslog-test", ( + f"Unexpected hostname: {parsed['hostname']}" + ) + + # Validate timestamp format (BSD or NILVALUE) + if parsed["timestamp"] != "-": + assert re.match( + r"[A-Z][a-z]{2} +\d+ \d{2}:\d{2}:\d{2}", + parsed["timestamp"], + ), f"Invalid timestamp format: {parsed['timestamp']}" + + # Verify we see different severity levels in the logs + severities_seen = {p["severity"] for p in parsed_messages} + # ESPHome startup logs should include at least INFO (5) or DEBUG (7) + assert len(severities_seen) >= 1, "Expected to see at least one severity" + + # Verify messages don't contain ANSI color codes (strip=true) + for parsed in parsed_messages: + assert "\x1b[" not in parsed["message"], ( + f"Color codes not stripped: {parsed['message'][:50]}" + ) + + # Verify message content is not empty for most messages + non_empty_messages = [p for p in parsed_messages if p["message"].strip()] + assert len(non_empty_messages) > 0, "All messages are empty" + + # Verify tag format (should be component name like "app", "wifi", etc.) + for parsed in parsed_messages: + assert len(parsed["tag"]) > 0, "Empty tag" + # Tag should not contain spaces or colons + assert " " not in parsed["tag"], f"Tag contains space: {parsed['tag']}" + + # Test message truncation - call service that logs a very long message + _, services = await client.list_entities_services() + log_service = next( + (s for s in services if s.name == "log_long_message"), None + ) + assert log_service is not None, "log_long_message service not found" + + # Call the service to trigger a long log message + await client.execute_service(log_service, {}) + + # Wait specifically for the truncation test message + try: + trunc_msg = await receiver.wait_for_pattern(r"trunctest.*START\|") + except TimeoutError: + pytest.fail( + f"Truncation test message not received. Got: {receiver.messages}" + ) + + # Verify message is truncated to max 508 bytes + assert len(trunc_msg) <= 508, f"Message exceeds 508 bytes: {len(trunc_msg)}" + + # Verify the message starts correctly but is truncated (no "|END") + assert "START|" in trunc_msg, "Message should contain START marker" + assert "|END" not in trunc_msg, ( + "Message should be truncated before END marker" + ) + + # Test short message - should arrive complete (not truncated) + short_service = next( + (s for s in services if s.name == "log_short_message"), None + ) + assert short_service is not None, "log_short_message service not found" + + await client.execute_service(short_service, {}) + + try: + short_msg = await receiver.wait_for_pattern(r"shorttest.*BEGIN\|") + except TimeoutError: + pytest.fail( + f"Short test message not received. Got: {receiver.messages[-10:]}" + ) + + # Verify short message arrived complete with both markers + assert "BEGIN|" in short_msg, "Short message missing BEGIN marker" + assert "|FINISH" in short_msg, ( + f"Short message truncated unexpectedly: {short_msg}" + ) + assert "SHORT_MESSAGE_CONTENT" in short_msg, ( + f"Short message content missing: {short_msg}" + ) diff --git a/tests/script/test_clang_tidy_hash.py b/tests/script/test_clang_tidy_hash.py index b1690a6a2de..e19e7886a27 100644 --- a/tests/script/test_clang_tidy_hash.py +++ b/tests/script/test_clang_tidy_hash.py @@ -49,7 +49,7 @@ def test_calculate_clang_tidy_hash_with_sdkconfig(tmp_path: Path) -> None: 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"CONFIG_AUTOSTART_ARDUINO=y\n" + sdkconfig_content = b"" requirements_content = "clang-tidy==18.1.5\n" # Create temporary files diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index fc61841500d..1a1bfffd03d 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -58,6 +58,7 @@ def mock_write_file_if_changed() -> Generator[Mock, None, None]: def mock_copy_file_if_changed() -> Generator[Mock, None, None]: """Mock copy_file_if_changed for core.config.""" with patch("esphome.core.config.copy_file_if_changed") as mock: + mock.return_value = True yield mock diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 90b2f5edba3..ab7bdbb98c1 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -892,3 +892,74 @@ async def test_add_includes_overwrites_existing_files( mock_copy_file_if_changed.assert_called_once_with( include_file, CORE.build_path / "src" / "header.h" ) + + +def test_config_hash_returns_int() -> None: + """Test that config_hash returns an integer.""" + CORE.reset() + CORE.config = {"esphome": {"name": "test"}} + assert isinstance(CORE.config_hash, int) + + +def test_config_hash_is_cached() -> None: + """Test that config_hash is computed once and cached.""" + CORE.reset() + CORE.config = {"esphome": {"name": "test"}} + + # First access computes the hash + hash1 = CORE.config_hash + + # Modify config (without resetting cache) + CORE.config = {"esphome": {"name": "different"}} + + # Second access returns cached value + hash2 = CORE.config_hash + + assert hash1 == hash2 + + +def test_config_hash_reset_clears_cache() -> None: + """Test that reset() clears the cached config_hash.""" + CORE.reset() + CORE.config = {"esphome": {"name": "test"}} + hash1 = CORE.config_hash + + # Reset clears the cache + CORE.reset() + CORE.config = {"esphome": {"name": "different"}} + + hash2 = CORE.config_hash + + # After reset, hash should be recomputed + assert hash1 != hash2 + + +def test_config_hash_deterministic_key_order() -> None: + """Test that config_hash is deterministic regardless of key insertion order.""" + CORE.reset() + # Create two configs with same content but different key order + config1 = {"z_key": 1, "a_key": 2, "nested": {"z_nested": "z", "a_nested": "a"}} + config2 = {"a_key": 2, "z_key": 1, "nested": {"a_nested": "a", "z_nested": "z"}} + + CORE.config = config1 + hash1 = CORE.config_hash + + CORE.reset() + CORE.config = config2 + hash2 = CORE.config_hash + + # Hashes should be equal because keys are sorted during serialization + assert hash1 == hash2 + + +def test_config_hash_different_for_different_configs() -> None: + """Test that different configs produce different hashes.""" + CORE.reset() + CORE.config = {"esphome": {"name": "test1"}} + hash1 = CORE.config_hash + + CORE.reset() + CORE.config = {"esphome": {"name": "test2"}} + hash2 = CORE.config_hash + + assert hash1 != hash2 diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index bd143950374..36a284c3827 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -4,9 +4,11 @@ from __future__ import annotations from collections.abc import Generator from dataclasses import dataclass +import json import logging from pathlib import Path import re +import time from typing import Any from unittest.mock import MagicMock, Mock, patch @@ -22,6 +24,7 @@ from esphome.__main__ import ( command_rename, command_update_all, command_wizard, + compile_program, detect_external_components, get_port_type, has_ip_address, @@ -2605,3 +2608,197 @@ def test_command_analyze_memory_no_idedata( assert result == 1 assert "Failed to get IDE data for memory analysis" in caplog.text + + +@pytest.fixture +def mock_compile_build_info_run_compile() -> Generator[Mock]: + """Mock platformio_api.run_compile for build_info tests.""" + with patch("esphome.platformio_api.run_compile", return_value=0) as mock: + yield mock + + +@pytest.fixture +def mock_compile_build_info_get_idedata() -> Generator[Mock]: + """Mock platformio_api.get_idedata for build_info tests.""" + mock_idedata = MagicMock() + with patch("esphome.platformio_api.get_idedata", return_value=mock_idedata) as mock: + yield mock + + +def _setup_build_info_test( + tmp_path: Path, + *, + create_firmware: bool = True, + create_build_info: bool = True, + build_info_content: str | None = None, + firmware_first: bool = False, +) -> tuple[Path, Path]: + """Set up build directory structure for build_info tests. + + Args: + tmp_path: Temporary directory path. + create_firmware: Whether to create firmware.bin file. + create_build_info: Whether to create build_info.json file. + build_info_content: Custom content for build_info.json, or None for default. + firmware_first: If True, create firmware before build_info (makes firmware older). + + Returns: + Tuple of (build_info_path, firmware_path). + """ + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device") + + build_path = tmp_path / ".esphome" / "build" / "test_device" + pioenvs_path = build_path / ".pioenvs" / "test_device" + pioenvs_path.mkdir(parents=True, exist_ok=True) + + build_info_path = build_path / "build_info.json" + firmware_path = pioenvs_path / "firmware.bin" + + default_build_info = json.dumps( + { + "config_hash": 0x12345678, + "build_time": int(time.time()), + "build_time_str": "Dec 13 2025, 12:00:00", + "esphome_version": "2025.1.0", + } + ) + + def create_build_info_file() -> None: + if create_build_info: + content = ( + build_info_content + if build_info_content is not None + else default_build_info + ) + build_info_path.write_text(content) + + def create_firmware_file() -> None: + if create_firmware: + firmware_path.write_bytes(b"fake firmware") + + if firmware_first: + create_firmware_file() + time.sleep(0.01) # Ensure different timestamps + create_build_info_file() + else: + create_build_info_file() + time.sleep(0.01) # Ensure different timestamps + create_firmware_file() + + return build_info_path, firmware_path + + +def test_compile_program_emits_build_info_when_firmware_rebuilt( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + mock_compile_build_info_run_compile: Mock, + mock_compile_build_info_get_idedata: Mock, +) -> None: + """Test that compile_program logs build_info when firmware is rebuilt.""" + _setup_build_info_test(tmp_path, firmware_first=False) + + config: dict[str, Any] = {CONF_ESPHOME: {CONF_NAME: "test_device"}} + args = MockArgs() + + with caplog.at_level(logging.INFO): + result = compile_program(args, config) + + assert result == 0 + assert "Build Info: config_hash=0x12345678" in caplog.text + + +def test_compile_program_no_build_info_when_firmware_not_rebuilt( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + mock_compile_build_info_run_compile: Mock, + mock_compile_build_info_get_idedata: Mock, +) -> None: + """Test that compile_program doesn't log build_info when firmware wasn't rebuilt.""" + _setup_build_info_test(tmp_path, firmware_first=True) + + config: dict[str, Any] = {CONF_ESPHOME: {CONF_NAME: "test_device"}} + args = MockArgs() + + with caplog.at_level(logging.INFO): + result = compile_program(args, config) + + assert result == 0 + assert "Build Info:" not in caplog.text + + +def test_compile_program_no_build_info_when_firmware_missing( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + mock_compile_build_info_run_compile: Mock, + mock_compile_build_info_get_idedata: Mock, +) -> None: + """Test that compile_program doesn't log build_info when firmware.bin doesn't exist.""" + _setup_build_info_test(tmp_path, create_firmware=False) + + config: dict[str, Any] = {CONF_ESPHOME: {CONF_NAME: "test_device"}} + args = MockArgs() + + with caplog.at_level(logging.INFO): + result = compile_program(args, config) + + assert result == 0 + assert "Build Info:" not in caplog.text + + +def test_compile_program_no_build_info_when_json_missing( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + mock_compile_build_info_run_compile: Mock, + mock_compile_build_info_get_idedata: Mock, +) -> None: + """Test that compile_program doesn't log build_info when build_info.json doesn't exist.""" + _setup_build_info_test(tmp_path, create_build_info=False) + + config: dict[str, Any] = {CONF_ESPHOME: {CONF_NAME: "test_device"}} + args = MockArgs() + + with caplog.at_level(logging.INFO): + result = compile_program(args, config) + + assert result == 0 + assert "Build Info:" not in caplog.text + + +def test_compile_program_no_build_info_when_json_invalid( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + mock_compile_build_info_run_compile: Mock, + mock_compile_build_info_get_idedata: Mock, +) -> None: + """Test that compile_program doesn't log build_info when build_info.json is invalid.""" + _setup_build_info_test(tmp_path, build_info_content="not valid json {{{") + + config: dict[str, Any] = {CONF_ESPHOME: {CONF_NAME: "test_device"}} + args = MockArgs() + + with caplog.at_level(logging.DEBUG): + result = compile_program(args, config) + + assert result == 0 + assert "Build Info:" not in caplog.text + + +def test_compile_program_no_build_info_when_json_missing_keys( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + mock_compile_build_info_run_compile: Mock, + mock_compile_build_info_get_idedata: Mock, +) -> None: + """Test that compile_program doesn't log build_info when build_info.json is missing required keys.""" + _setup_build_info_test( + tmp_path, build_info_content=json.dumps({"build_time": 1234567890}) + ) + + config: dict[str, Any] = {CONF_ESPHOME: {CONF_NAME: "test_device"}} + args = MockArgs() + + with caplog.at_level(logging.INFO): + result = compile_program(args, config) + + assert result == 0 + assert "Build Info:" not in caplog.text diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 9fa60c06ecd..06a7d5dbdf0 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -1,6 +1,10 @@ """Test writer module functionality.""" from collections.abc import Callable +from contextlib import contextmanager +from dataclasses import dataclass +from datetime import datetime +import json import os from pathlib import Path import stat @@ -20,6 +24,9 @@ from esphome.writer import ( clean_all, clean_build, clean_cmake_cache, + copy_src_tree, + generate_build_info_data_h, + get_build_info, storage_should_clean, update_storage_json, write_cpp, @@ -1165,3 +1172,721 @@ def test_clean_build_reraises_for_other_errors( finally: # Cleanup - restore write permission so tmp_path cleanup works os.chmod(subdir, stat.S_IRWXU) + + +# Tests for get_build_info() + + +@patch("esphome.writer.CORE") +def test_get_build_info_new_build( + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test get_build_info returns new build_time when no existing build_info.json.""" + build_info_path = tmp_path / "build_info.json" + mock_core.relative_build_path.return_value = build_info_path + mock_core.config_hash = 0x12345678 + + config_hash, build_time, build_time_str = get_build_info() + + assert config_hash == 0x12345678 + assert isinstance(build_time, int) + assert build_time > 0 + assert isinstance(build_time_str, str) + # Verify build_time_str format matches expected pattern + assert len(build_time_str) >= 19 # e.g., "2025-12-15 16:27:44 +0000" + + +@patch("esphome.writer.CORE") +def test_get_build_info_always_returns_current_time( + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test get_build_info always returns current build_time.""" + build_info_path = tmp_path / "build_info.json" + mock_core.relative_build_path.return_value = build_info_path + mock_core.config_hash = 0x12345678 + + # Create existing build_info.json with matching config_hash and version + existing_build_time = 1700000000 + existing_build_time_str = "2023-11-14 22:13:20 +0000" + build_info_path.write_text( + json.dumps( + { + "config_hash": 0x12345678, + "build_time": existing_build_time, + "build_time_str": existing_build_time_str, + "esphome_version": "2025.1.0-dev", + } + ) + ) + + with patch("esphome.writer.__version__", "2025.1.0-dev"): + config_hash, build_time, build_time_str = get_build_info() + + assert config_hash == 0x12345678 + # get_build_info now always returns current time + assert build_time != existing_build_time + assert build_time > existing_build_time + assert build_time_str != existing_build_time_str + + +@patch("esphome.writer.CORE") +def test_get_build_info_config_changed( + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test get_build_info returns new build_time when config hash changed.""" + build_info_path = tmp_path / "build_info.json" + mock_core.relative_build_path.return_value = build_info_path + mock_core.config_hash = 0xABCDEF00 # Different from existing + + # Create existing build_info.json with different config_hash + existing_build_time = 1700000000 + build_info_path.write_text( + json.dumps( + { + "config_hash": 0x12345678, # Different + "build_time": existing_build_time, + "build_time_str": "2023-11-14 22:13:20 +0000", + "esphome_version": "2025.1.0-dev", + } + ) + ) + + with patch("esphome.writer.__version__", "2025.1.0-dev"): + config_hash, build_time, build_time_str = get_build_info() + + assert config_hash == 0xABCDEF00 + assert build_time != existing_build_time # New time generated + assert build_time > existing_build_time + + +@patch("esphome.writer.CORE") +def test_get_build_info_version_changed( + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test get_build_info returns new build_time when ESPHome version changed.""" + build_info_path = tmp_path / "build_info.json" + mock_core.relative_build_path.return_value = build_info_path + mock_core.config_hash = 0x12345678 + + # Create existing build_info.json with different version + existing_build_time = 1700000000 + build_info_path.write_text( + json.dumps( + { + "config_hash": 0x12345678, + "build_time": existing_build_time, + "build_time_str": "2023-11-14 22:13:20 +0000", + "esphome_version": "2024.12.0", # Old version + } + ) + ) + + with patch("esphome.writer.__version__", "2025.1.0-dev"): # New version + config_hash, build_time, build_time_str = get_build_info() + + assert config_hash == 0x12345678 + assert build_time != existing_build_time # New time generated + assert build_time > existing_build_time + + +@patch("esphome.writer.CORE") +def test_get_build_info_invalid_json( + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test get_build_info handles invalid JSON gracefully.""" + build_info_path = tmp_path / "build_info.json" + mock_core.relative_build_path.return_value = build_info_path + mock_core.config_hash = 0x12345678 + + # Create invalid JSON file + build_info_path.write_text("not valid json {{{") + + config_hash, build_time, build_time_str = get_build_info() + + assert config_hash == 0x12345678 + assert isinstance(build_time, int) + assert build_time > 0 + + +@patch("esphome.writer.CORE") +def test_get_build_info_missing_keys( + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test get_build_info handles missing keys gracefully.""" + build_info_path = tmp_path / "build_info.json" + mock_core.relative_build_path.return_value = build_info_path + mock_core.config_hash = 0x12345678 + + # Create JSON with missing keys + build_info_path.write_text(json.dumps({"config_hash": 0x12345678})) + + with patch("esphome.writer.__version__", "2025.1.0-dev"): + config_hash, build_time, build_time_str = get_build_info() + + assert config_hash == 0x12345678 + assert isinstance(build_time, int) + assert build_time > 0 + + +@patch("esphome.writer.CORE") +def test_get_build_info_build_time_str_format( + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test get_build_info returns correctly formatted build_time_str.""" + build_info_path = tmp_path / "build_info.json" + mock_core.relative_build_path.return_value = build_info_path + mock_core.config_hash = 0x12345678 + + config_hash, build_time, build_time_str = get_build_info() + + # Verify the format matches "%Y-%m-%d %H:%M:%S %z" + # e.g., "2025-12-15 16:27:44 +0000" + parsed = datetime.strptime(build_time_str, "%Y-%m-%d %H:%M:%S %z") + assert parsed.year >= 2024 + + +def test_generate_build_info_data_h_format() -> None: + """Test generate_build_info_data_h produces correct header content.""" + config_hash = 0x12345678 + build_time = 1700000000 + build_time_str = "2023-11-14 22:13:20 +0000" + + result = generate_build_info_data_h(config_hash, build_time, build_time_str) + + assert "#pragma once" in result + assert "#define ESPHOME_CONFIG_HASH 0x12345678U" in result + assert "#define ESPHOME_BUILD_TIME 1700000000" in result + assert 'ESPHOME_BUILD_TIME_STR[] = "2023-11-14 22:13:20 +0000"' in result + + +def test_generate_build_info_data_h_esp8266_progmem() -> None: + """Test generate_build_info_data_h includes PROGMEM for ESP8266.""" + result = generate_build_info_data_h(0xABCDEF01, 1700000000, "test") + + # Should have ESP8266 PROGMEM conditional + assert "#ifdef USE_ESP8266" in result + assert "#include " in result + assert "PROGMEM" in result + + +def test_generate_build_info_data_h_hash_formatting() -> None: + """Test generate_build_info_data_h formats hash with leading zeros.""" + # Test with small hash value that needs leading zeros + result = generate_build_info_data_h(0x00000001, 0, "test") + assert "#define ESPHOME_CONFIG_HASH 0x00000001U" in result + + # Test with larger hash value + result = generate_build_info_data_h(0xFFFFFFFF, 0, "test") + assert "#define ESPHOME_CONFIG_HASH 0xffffffffU" in result + + +@patch("esphome.writer.CORE") +@patch("esphome.writer.iter_components") +@patch("esphome.writer.walk_files") +def test_copy_src_tree_writes_build_info_files( + mock_walk_files: MagicMock, + mock_iter_components: MagicMock, + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test copy_src_tree writes build_info_data.h and build_info.json.""" + # Setup directory structure + src_path = tmp_path / "src" + src_path.mkdir() + esphome_core_path = src_path / "esphome" / "core" + esphome_core_path.mkdir(parents=True) + build_path = tmp_path / "build" + build_path.mkdir() + + # Create mock source files for defines.h and version.h + mock_defines_h = esphome_core_path / "defines.h" + mock_defines_h.write_text("// mock defines.h") + mock_version_h = esphome_core_path / "version.h" + mock_version_h.write_text("// mock version.h") + + # Create mock FileResource that returns our temp files + @dataclass(frozen=True) + class MockFileResource: + package: str + resource: str + _path: Path + + @contextmanager + def path(self): + yield self._path + + # Create mock resources for defines.h and version.h (required by copy_src_tree) + mock_resources = [ + MockFileResource( + package="esphome.core", + resource="defines.h", + _path=mock_defines_h, + ), + MockFileResource( + package="esphome.core", + resource="version.h", + _path=mock_version_h, + ), + ] + + # Create mock component with resources + mock_component = MagicMock() + mock_component.resources = mock_resources + + # Setup mocks + mock_core.relative_src_path.side_effect = lambda *args: src_path.joinpath(*args) + mock_core.relative_build_path.side_effect = lambda *args: build_path.joinpath(*args) + mock_core.defines = [] + mock_core.config_hash = 0xDEADBEEF + mock_core.target_platform = "test_platform" + mock_core.config = {} + mock_iter_components.return_value = [("core", mock_component)] + mock_walk_files.return_value = [] + + # Create mock module without copy_files attribute (causes AttributeError which is caught) + mock_module = MagicMock(spec=[]) # Empty spec = no copy_files attribute + + with ( + patch("esphome.writer.__version__", "2025.1.0-dev"), + patch("esphome.writer.importlib.import_module", return_value=mock_module), + ): + copy_src_tree() + + # Verify build_info_data.h was written + build_info_h_path = esphome_core_path / "build_info_data.h" + assert build_info_h_path.exists() + build_info_h_content = build_info_h_path.read_text() + assert "#define ESPHOME_CONFIG_HASH 0xdeadbeefU" in build_info_h_content + assert "#define ESPHOME_BUILD_TIME" in build_info_h_content + assert "ESPHOME_BUILD_TIME_STR" in build_info_h_content + + # Verify build_info.json was written + build_info_json_path = build_path / "build_info.json" + assert build_info_json_path.exists() + build_info_json = json.loads(build_info_json_path.read_text()) + assert build_info_json["config_hash"] == 0xDEADBEEF + assert "build_time" in build_info_json + assert "build_time_str" in build_info_json + assert build_info_json["esphome_version"] == "2025.1.0-dev" + + +@patch("esphome.writer.CORE") +@patch("esphome.writer.iter_components") +@patch("esphome.writer.walk_files") +def test_copy_src_tree_detects_config_hash_change( + mock_walk_files: MagicMock, + mock_iter_components: MagicMock, + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test copy_src_tree detects when config_hash changes.""" + # Setup directory structure + src_path = tmp_path / "src" + src_path.mkdir() + esphome_core_path = src_path / "esphome" / "core" + esphome_core_path.mkdir(parents=True) + build_path = tmp_path / "build" + build_path.mkdir() + + # Create existing build_info.json with different config_hash + build_info_json_path = build_path / "build_info.json" + build_info_json_path.write_text( + json.dumps( + { + "config_hash": 0x12345678, # Different from current + "build_time": 1700000000, + "build_time_str": "2023-11-14 22:13:20 +0000", + "esphome_version": "2025.1.0-dev", + } + ) + ) + + # Create existing build_info_data.h + build_info_h_path = esphome_core_path / "build_info_data.h" + build_info_h_path.write_text("// old build_info_data.h") + + # Setup mocks + mock_core.relative_src_path.side_effect = lambda *args: src_path.joinpath(*args) + mock_core.relative_build_path.side_effect = lambda *args: build_path.joinpath(*args) + mock_core.defines = [] + mock_core.config_hash = 0xDEADBEEF # Different from existing + mock_core.target_platform = "test_platform" + mock_core.config = {} + mock_iter_components.return_value = [] + mock_walk_files.return_value = [] + + with ( + patch("esphome.writer.__version__", "2025.1.0-dev"), + patch("esphome.writer.importlib.import_module") as mock_import, + ): + mock_import.side_effect = AttributeError + copy_src_tree() + + # Verify build_info files were updated due to config_hash change + assert build_info_h_path.exists() + new_content = build_info_h_path.read_text() + assert "0xdeadbeef" in new_content.lower() + + new_json = json.loads(build_info_json_path.read_text()) + assert new_json["config_hash"] == 0xDEADBEEF + + +@patch("esphome.writer.CORE") +@patch("esphome.writer.iter_components") +@patch("esphome.writer.walk_files") +def test_copy_src_tree_detects_version_change( + mock_walk_files: MagicMock, + mock_iter_components: MagicMock, + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test copy_src_tree detects when esphome_version changes.""" + # Setup directory structure + src_path = tmp_path / "src" + src_path.mkdir() + esphome_core_path = src_path / "esphome" / "core" + esphome_core_path.mkdir(parents=True) + build_path = tmp_path / "build" + build_path.mkdir() + + # Create existing build_info.json with different version + build_info_json_path = build_path / "build_info.json" + build_info_json_path.write_text( + json.dumps( + { + "config_hash": 0xDEADBEEF, + "build_time": 1700000000, + "build_time_str": "2023-11-14 22:13:20 +0000", + "esphome_version": "2024.12.0", # Old version + } + ) + ) + + # Create existing build_info_data.h + build_info_h_path = esphome_core_path / "build_info_data.h" + build_info_h_path.write_text("// old build_info_data.h") + + # Setup mocks + mock_core.relative_src_path.side_effect = lambda *args: src_path.joinpath(*args) + mock_core.relative_build_path.side_effect = lambda *args: build_path.joinpath(*args) + mock_core.defines = [] + mock_core.config_hash = 0xDEADBEEF + mock_core.target_platform = "test_platform" + mock_core.config = {} + mock_iter_components.return_value = [] + mock_walk_files.return_value = [] + + with ( + patch("esphome.writer.__version__", "2025.1.0-dev"), # New version + patch("esphome.writer.importlib.import_module") as mock_import, + ): + mock_import.side_effect = AttributeError + copy_src_tree() + + # Verify build_info files were updated due to version change + assert build_info_h_path.exists() + new_json = json.loads(build_info_json_path.read_text()) + assert new_json["esphome_version"] == "2025.1.0-dev" + + +@patch("esphome.writer.CORE") +@patch("esphome.writer.iter_components") +@patch("esphome.writer.walk_files") +def test_copy_src_tree_handles_invalid_build_info_json( + mock_walk_files: MagicMock, + mock_iter_components: MagicMock, + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test copy_src_tree handles invalid build_info.json gracefully.""" + # Setup directory structure + src_path = tmp_path / "src" + src_path.mkdir() + esphome_core_path = src_path / "esphome" / "core" + esphome_core_path.mkdir(parents=True) + build_path = tmp_path / "build" + build_path.mkdir() + + # Create invalid build_info.json + build_info_json_path = build_path / "build_info.json" + build_info_json_path.write_text("invalid json {{{") + + # Create existing build_info_data.h + build_info_h_path = esphome_core_path / "build_info_data.h" + build_info_h_path.write_text("// old build_info_data.h") + + # Setup mocks + mock_core.relative_src_path.side_effect = lambda *args: src_path.joinpath(*args) + mock_core.relative_build_path.side_effect = lambda *args: build_path.joinpath(*args) + mock_core.defines = [] + mock_core.config_hash = 0xDEADBEEF + mock_core.target_platform = "test_platform" + mock_core.config = {} + mock_iter_components.return_value = [] + mock_walk_files.return_value = [] + + with ( + patch("esphome.writer.__version__", "2025.1.0-dev"), + patch("esphome.writer.importlib.import_module") as mock_import, + ): + mock_import.side_effect = AttributeError + copy_src_tree() + + # Verify build_info files were created despite invalid JSON + assert build_info_h_path.exists() + new_json = json.loads(build_info_json_path.read_text()) + assert new_json["config_hash"] == 0xDEADBEEF + + +@patch("esphome.writer.CORE") +@patch("esphome.writer.iter_components") +@patch("esphome.writer.walk_files") +def test_copy_src_tree_build_info_timestamp_behavior( + mock_walk_files: MagicMock, + mock_iter_components: MagicMock, + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test build_info behaviour: regenerated on change, preserved when unchanged.""" + # Setup directory structure + src_path = tmp_path / "src" + src_path.mkdir() + esphome_core_path = src_path / "esphome" / "core" + esphome_core_path.mkdir(parents=True) + esphome_components_path = src_path / "esphome" / "components" + esphome_components_path.mkdir(parents=True) + build_path = tmp_path / "build" + build_path.mkdir() + + # Create a source file + source_file = tmp_path / "source" / "test.cpp" + source_file.parent.mkdir() + source_file.write_text("// version 1") + + # Create destination file in build tree + dest_file = esphome_components_path / "test.cpp" + + # Create mock FileResource + @dataclass(frozen=True) + class MockFileResource: + package: str + resource: str + _path: Path + + @contextmanager + def path(self): + yield self._path + + mock_resources = [ + MockFileResource( + package="esphome.components", + resource="test.cpp", + _path=source_file, + ), + ] + + mock_component = MagicMock() + mock_component.resources = mock_resources + + # Setup mocks + mock_core.relative_src_path.side_effect = lambda *args: src_path.joinpath(*args) + mock_core.relative_build_path.side_effect = lambda *args: build_path.joinpath(*args) + mock_core.defines = [] + mock_core.config_hash = 0xDEADBEEF + mock_core.target_platform = "test_platform" + mock_core.config = {} + mock_iter_components.return_value = [("test", mock_component)] + + build_info_json_path = build_path / "build_info.json" + + # First run: initial setup, should create build_info + mock_walk_files.return_value = [] + with ( + patch("esphome.writer.__version__", "2025.1.0-dev"), + patch("esphome.writer.importlib.import_module") as mock_import, + ): + mock_import.side_effect = AttributeError + copy_src_tree() + + # Manually set an old timestamp for testing + old_timestamp = 1700000000 + old_timestamp_str = "2023-11-14 22:13:20 +0000" + build_info_json_path.write_text( + json.dumps( + { + "config_hash": 0xDEADBEEF, + "build_time": old_timestamp, + "build_time_str": old_timestamp_str, + "esphome_version": "2025.1.0-dev", + } + ) + ) + + # Second run: no changes, should NOT regenerate build_info + mock_walk_files.return_value = [str(dest_file)] + with ( + patch("esphome.writer.__version__", "2025.1.0-dev"), + patch("esphome.writer.importlib.import_module") as mock_import, + ): + mock_import.side_effect = AttributeError + copy_src_tree() + + second_json = json.loads(build_info_json_path.read_text()) + second_timestamp = second_json["build_time"] + + # Verify timestamp was NOT changed + assert second_timestamp == old_timestamp, ( + f"build_info should not be regenerated when no files change: " + f"{old_timestamp} != {second_timestamp}" + ) + + # Third run: change source file, should regenerate build_info with new timestamp + source_file.write_text("// version 2") + with ( + patch("esphome.writer.__version__", "2025.1.0-dev"), + patch("esphome.writer.importlib.import_module") as mock_import, + ): + mock_import.side_effect = AttributeError + copy_src_tree() + + third_json = json.loads(build_info_json_path.read_text()) + third_timestamp = third_json["build_time"] + + # Verify timestamp WAS changed + assert third_timestamp != old_timestamp, ( + f"build_info should be regenerated when source file changes: " + f"{old_timestamp} == {third_timestamp}" + ) + assert third_timestamp > old_timestamp + + +@patch("esphome.writer.CORE") +@patch("esphome.writer.iter_components") +@patch("esphome.writer.walk_files") +def test_copy_src_tree_detects_removed_source_file( + mock_walk_files: MagicMock, + mock_iter_components: MagicMock, + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test copy_src_tree detects when a non-generated source file is removed.""" + # Setup directory structure + src_path = tmp_path / "src" + src_path.mkdir() + esphome_components_path = src_path / "esphome" / "components" + esphome_components_path.mkdir(parents=True) + build_path = tmp_path / "build" + build_path.mkdir() + + # Create an existing source file in the build tree + existing_file = esphome_components_path / "test.cpp" + existing_file.write_text("// test file") + + # Setup mocks - no components, so the file should be removed + mock_core.relative_src_path.side_effect = lambda *args: src_path.joinpath(*args) + mock_core.relative_build_path.side_effect = lambda *args: build_path.joinpath(*args) + mock_core.defines = [] + mock_core.config_hash = 0xDEADBEEF + mock_core.target_platform = "test_platform" + mock_core.config = {} + mock_iter_components.return_value = [] # No components = file should be removed + mock_walk_files.return_value = [str(existing_file)] + + # Create existing build_info.json + build_info_json_path = build_path / "build_info.json" + old_timestamp = 1700000000 + build_info_json_path.write_text( + json.dumps( + { + "config_hash": 0xDEADBEEF, + "build_time": old_timestamp, + "build_time_str": "2023-11-14 22:13:20 +0000", + "esphome_version": "2025.1.0-dev", + } + ) + ) + + with ( + patch("esphome.writer.__version__", "2025.1.0-dev"), + patch("esphome.writer.importlib.import_module") as mock_import, + ): + mock_import.side_effect = AttributeError + copy_src_tree() + + # Verify file was removed + assert not existing_file.exists() + + # Verify build_info was regenerated due to source file removal + new_json = json.loads(build_info_json_path.read_text()) + assert new_json["build_time"] != old_timestamp + + +@patch("esphome.writer.CORE") +@patch("esphome.writer.iter_components") +@patch("esphome.writer.walk_files") +def test_copy_src_tree_ignores_removed_generated_file( + mock_walk_files: MagicMock, + mock_iter_components: MagicMock, + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test copy_src_tree doesn't mark sources_changed when only generated file removed.""" + # Setup directory structure + src_path = tmp_path / "src" + src_path.mkdir() + esphome_core_path = src_path / "esphome" / "core" + esphome_core_path.mkdir(parents=True) + build_path = tmp_path / "build" + build_path.mkdir() + + # Create existing build_info_data.h (a generated file) + build_info_h = esphome_core_path / "build_info_data.h" + build_info_h.write_text("// old generated file") + + # Setup mocks + mock_core.relative_src_path.side_effect = lambda *args: src_path.joinpath(*args) + mock_core.relative_build_path.side_effect = lambda *args: build_path.joinpath(*args) + mock_core.defines = [] + mock_core.config_hash = 0xDEADBEEF + mock_core.target_platform = "test_platform" + mock_core.config = {} + mock_iter_components.return_value = [] + # walk_files returns the generated file, but it's not in source_files_copy + mock_walk_files.return_value = [str(build_info_h)] + + # Create existing build_info.json with old timestamp + build_info_json_path = build_path / "build_info.json" + old_timestamp = 1700000000 + build_info_json_path.write_text( + json.dumps( + { + "config_hash": 0xDEADBEEF, + "build_time": old_timestamp, + "build_time_str": "2023-11-14 22:13:20 +0000", + "esphome_version": "2025.1.0-dev", + } + ) + ) + + with ( + patch("esphome.writer.__version__", "2025.1.0-dev"), + patch("esphome.writer.importlib.import_module") as mock_import, + ): + mock_import.side_effect = AttributeError + copy_src_tree() + + # Verify build_info_data.h was regenerated (not removed) + assert build_info_h.exists() + + # Note: build_info.json will have a new timestamp because get_build_info() + # always returns current time. The key test is that the old build_info_data.h + # file was removed and regenerated, not that it triggered sources_changed. + new_json = json.loads(build_info_json_path.read_text()) + assert new_json["config_hash"] == 0xDEADBEEF diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index eac0ceabb88..c8cb3e144f3 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -278,3 +278,31 @@ def test_secret_values_tracking(fixture_path: Path) -> None: assert yaml_util._SECRET_VALUES["super_secret_wifi"] == "wifi_password" assert "0123456789abcdef" in yaml_util._SECRET_VALUES assert yaml_util._SECRET_VALUES["0123456789abcdef"] == "api_key" + + +def test_dump_sort_keys() -> None: + """Test that dump with sort_keys=True produces sorted output.""" + # Create a dict with unsorted keys + data = { + "zebra": 1, + "alpha": 2, + "nested": { + "z_key": "z_value", + "a_key": "a_value", + }, + } + + # Without sort_keys, keys are in insertion order + unsorted = yaml_util.dump(data, sort_keys=False) + lines_unsorted = unsorted.strip().split("\n") + # First key should be "zebra" (insertion order) + assert lines_unsorted[0].startswith("zebra:") + + # With sort_keys, keys are alphabetically sorted + sorted_dump = yaml_util.dump(data, sort_keys=True) + lines_sorted = sorted_dump.strip().split("\n") + # First key should be "alpha" (alphabetical order) + assert lines_sorted[0].startswith("alpha:") + # nested keys should also be sorted + assert "a_key:" in sorted_dump + assert sorted_dump.index("a_key:") < sorted_dump.index("z_key:")