diff --git a/esphome/__main__.py b/esphome/__main__.py index 327741bb75..b9a4cc861a 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -738,6 +738,16 @@ def command_clean_mqtt(args: ArgsProtocol, config: ConfigType) -> int | None: return clean_mqtt(config, args) +def command_clean_platform(args: ArgsProtocol, config: ConfigType) -> int | None: + try: + writer.clean_platform() + except OSError as err: + _LOGGER.error("Error deleting platform files: %s", err) + return 1 + _LOGGER.info("Done!") + return 0 + + def command_mqtt_fingerprint(args: ArgsProtocol, config: ConfigType) -> int | None: from esphome import mqtt @@ -936,9 +946,10 @@ POST_CONFIG_ACTIONS = { "upload": command_upload, "logs": command_logs, "run": command_run, - "clean-mqtt": command_clean_mqtt, - "mqtt-fingerprint": command_mqtt_fingerprint, "clean": command_clean, + "clean-mqtt": command_clean_mqtt, + "clean-platform": command_clean_platform, + "mqtt-fingerprint": command_mqtt_fingerprint, "idedata": command_idedata, "rename": command_rename, "discover": command_discover, @@ -947,6 +958,7 @@ POST_CONFIG_ACTIONS = { SIMPLE_CONFIG_ACTIONS = [ "clean", "clean-mqtt", + "clean-platform", "config", ] @@ -1162,6 +1174,13 @@ def parse_args(argv): "configuration", help="Your YAML configuration file(s).", nargs="+" ) + parser_clean = subparsers.add_parser( + "clean-platform", help="Delete all platform files." + ) + parser_clean.add_argument( + "configuration", help="Your YAML configuration file(s).", nargs="+" + ) + parser_dashboard = subparsers.add_parser( "dashboard", help="Create a simple web server for a dashboard." ) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 796fd4a4d9..daaac63821 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1571,7 +1571,7 @@ message BluetoothGATTWriteRequest { uint32 handle = 2; bool response = 3; - bytes data = 4; + bytes data = 4 [(pointer_to_buffer) = true]; } message BluetoothGATTReadDescriptorRequest { @@ -1591,7 +1591,7 @@ message BluetoothGATTWriteDescriptorRequest { uint64 address = 1; uint32 handle = 2; - bytes data = 3; + bytes data = 3 [(pointer_to_buffer) = true]; } message BluetoothGATTNotifyRequest { diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index d2c62bff05..4c9ac6ca04 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -2037,9 +2037,12 @@ bool BluetoothGATTWriteRequest::decode_varint(uint32_t field_id, ProtoVarInt val } bool BluetoothGATTWriteRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 4: - this->data = value.as_string(); + case 4: { + // Use raw data directly to avoid allocation + this->data = value.data(); + this->data_len = value.size(); break; + } default: return false; } @@ -2073,9 +2076,12 @@ bool BluetoothGATTWriteDescriptorRequest::decode_varint(uint32_t field_id, Proto } bool BluetoothGATTWriteDescriptorRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 3: - this->data = value.as_string(); + case 3: { + // Use raw data directly to avoid allocation + this->data = value.data(); + this->data_len = value.size(); break; + } default: return false; } diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 75894f3ffd..5d43de4440 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1988,14 +1988,15 @@ class BluetoothGATTReadResponse final : public ProtoMessage { class BluetoothGATTWriteRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 75; - static constexpr uint8_t ESTIMATED_SIZE = 19; + static constexpr uint8_t ESTIMATED_SIZE = 29; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_gatt_write_request"; } #endif uint64_t address{0}; uint32_t handle{0}; bool response{false}; - std::string data{}; + const uint8_t *data{nullptr}; + uint16_t data_len{0}; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2023,13 +2024,14 @@ class BluetoothGATTReadDescriptorRequest final : public ProtoDecodableMessage { class BluetoothGATTWriteDescriptorRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 77; - static constexpr uint8_t ESTIMATED_SIZE = 17; + static constexpr uint8_t ESTIMATED_SIZE = 27; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_gatt_write_descriptor_request"; } #endif uint64_t address{0}; uint32_t handle{0}; - std::string data{}; + const uint8_t *data{nullptr}; + uint16_t data_len{0}; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 020da7b3eb..131f6e361a 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1658,7 +1658,7 @@ void BluetoothGATTWriteRequest::dump_to(std::string &out) const { dump_field(out, "handle", this->handle); dump_field(out, "response", this->response); out.append(" data: "); - out.append(format_hex_pretty(reinterpret_cast(this->data.data()), this->data.size())); + out.append(format_hex_pretty(this->data, this->data_len)); out.append("\n"); } void BluetoothGATTReadDescriptorRequest::dump_to(std::string &out) const { @@ -1671,7 +1671,7 @@ void BluetoothGATTWriteDescriptorRequest::dump_to(std::string &out) const { dump_field(out, "address", this->address); dump_field(out, "handle", this->handle); out.append(" data: "); - out.append(format_hex_pretty(reinterpret_cast(this->data.data()), this->data.size())); + out.append(format_hex_pretty(this->data, this->data_len)); out.append("\n"); } void BluetoothGATTNotifyRequest::dump_to(std::string &out) const { diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 540492f8c5..cde82fbfb0 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -514,7 +514,8 @@ esp_err_t BluetoothConnection::read_characteristic(uint16_t handle) { return this->check_and_log_error_("esp_ble_gattc_read_char", err); } -esp_err_t BluetoothConnection::write_characteristic(uint16_t handle, const std::string &data, bool response) { +esp_err_t BluetoothConnection::write_characteristic(uint16_t handle, const uint8_t *data, size_t length, + bool response) { if (!this->connected()) { this->log_gatt_not_connected_("write", "characteristic"); return ESP_GATT_NOT_CONNECTED; @@ -522,8 +523,11 @@ esp_err_t BluetoothConnection::write_characteristic(uint16_t handle, const std:: ESP_LOGV(TAG, "[%d] [%s] Writing GATT characteristic handle %d", this->connection_index_, this->address_str_.c_str(), handle); + // ESP-IDF's API requires a non-const uint8_t* but it doesn't modify the data + // The BTC layer immediately copies the data to its own buffer (see btc_gattc.c) + // const_cast is safe here and was previously hidden by a C-style cast esp_err_t err = - esp_ble_gattc_write_char(this->gattc_if_, this->conn_id_, handle, data.size(), (uint8_t *) data.data(), + esp_ble_gattc_write_char(this->gattc_if_, this->conn_id_, handle, length, const_cast(data), response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); return this->check_and_log_error_("esp_ble_gattc_write_char", err); } @@ -540,7 +544,7 @@ esp_err_t BluetoothConnection::read_descriptor(uint16_t handle) { return this->check_and_log_error_("esp_ble_gattc_read_char_descr", err); } -esp_err_t BluetoothConnection::write_descriptor(uint16_t handle, const std::string &data, bool response) { +esp_err_t BluetoothConnection::write_descriptor(uint16_t handle, const uint8_t *data, size_t length, bool response) { if (!this->connected()) { this->log_gatt_not_connected_("write", "descriptor"); return ESP_GATT_NOT_CONNECTED; @@ -548,8 +552,11 @@ esp_err_t BluetoothConnection::write_descriptor(uint16_t handle, const std::stri ESP_LOGV(TAG, "[%d] [%s] Writing GATT descriptor handle %d", this->connection_index_, this->address_str_.c_str(), handle); + // ESP-IDF's API requires a non-const uint8_t* but it doesn't modify the data + // The BTC layer immediately copies the data to its own buffer (see btc_gattc.c) + // const_cast is safe here and was previously hidden by a C-style cast esp_err_t err = esp_ble_gattc_write_char_descr( - this->gattc_if_, this->conn_id_, handle, data.size(), (uint8_t *) data.data(), + this->gattc_if_, this->conn_id_, handle, length, const_cast(data), response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); return this->check_and_log_error_("esp_ble_gattc_write_char_descr", err); } diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.h b/esphome/components/bluetooth_proxy/bluetooth_connection.h index e5d5ff2dd6..60bbc93e8b 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.h +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.h @@ -18,9 +18,9 @@ class BluetoothConnection final : public esp32_ble_client::BLEClientBase { esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override; esp_err_t read_characteristic(uint16_t handle); - esp_err_t write_characteristic(uint16_t handle, const std::string &data, bool response); + esp_err_t write_characteristic(uint16_t handle, const uint8_t *data, size_t length, bool response); esp_err_t read_descriptor(uint16_t handle); - esp_err_t write_descriptor(uint16_t handle, const std::string &data, bool response); + esp_err_t write_descriptor(uint16_t handle, const uint8_t *data, size_t length, bool response); esp_err_t notify_characteristic(uint16_t handle, bool enable); diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 532aff550e..cd7261d5e5 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -305,7 +305,7 @@ void BluetoothProxy::bluetooth_gatt_write(const api::BluetoothGATTWriteRequest & return; } - auto err = connection->write_characteristic(msg.handle, msg.data, msg.response); + auto err = connection->write_characteristic(msg.handle, msg.data, msg.data_len, msg.response); if (err != ESP_OK) { this->send_gatt_error(msg.address, msg.handle, err); } @@ -331,7 +331,7 @@ void BluetoothProxy::bluetooth_gatt_write_descriptor(const api::BluetoothGATTWri return; } - auto err = connection->write_descriptor(msg.handle, msg.data, true); + auto err = connection->write_descriptor(msg.handle, msg.data, msg.data_len, true); if (err != ESP_OK) { this->send_gatt_error(msg.address, msg.handle, err); } diff --git a/esphome/components/zwave_proxy/zwave_proxy.cpp b/esphome/components/zwave_proxy/zwave_proxy.cpp index 12c4ee0c0d..a894899dc4 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.cpp +++ b/esphome/components/zwave_proxy/zwave_proxy.cpp @@ -1,4 +1,5 @@ #include "zwave_proxy.h" +#include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/util.h" @@ -12,6 +13,7 @@ static constexpr uint8_t ZWAVE_COMMAND_GET_NETWORK_IDS = 0x20; // GET_NETWORK_IDS response: [SOF][LENGTH][TYPE][CMD][HOME_ID(4)][NODE_ID][...] static constexpr uint8_t ZWAVE_COMMAND_TYPE_RESPONSE = 0x01; // Response type field value static constexpr uint8_t ZWAVE_MIN_GET_NETWORK_IDS_LENGTH = 9; // TYPE + CMD + HOME_ID(4) + NODE_ID + checksum +static constexpr uint32_t HOME_ID_TIMEOUT_MS = 100; // Timeout for waiting for home ID during setup static uint8_t calculate_frame_checksum(const uint8_t *data, uint8_t length) { // Calculate Z-Wave frame checksum @@ -26,7 +28,44 @@ static uint8_t calculate_frame_checksum(const uint8_t *data, uint8_t length) { ZWaveProxy::ZWaveProxy() { global_zwave_proxy = this; } -void ZWaveProxy::setup() { this->send_simple_command_(ZWAVE_COMMAND_GET_NETWORK_IDS); } +void ZWaveProxy::setup() { + this->setup_time_ = App.get_loop_component_start_time(); + this->send_simple_command_(ZWAVE_COMMAND_GET_NETWORK_IDS); +} + +float ZWaveProxy::get_setup_priority() const { + // Set up before API so home ID is ready when API starts + return setup_priority::BEFORE_CONNECTION; +} + +bool ZWaveProxy::can_proceed() { + // If we already have the home ID, we can proceed + if (this->home_id_ready_) { + return true; + } + + // Handle any pending responses + if (this->response_handler_()) { + ESP_LOGV(TAG, "Handled response during setup"); + } + + // Process UART data to check for home ID + this->process_uart_(); + + // Check if we got the home ID after processing + if (this->home_id_ready_) { + return true; + } + + // Wait up to HOME_ID_TIMEOUT_MS for home ID response + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->setup_time_ > HOME_ID_TIMEOUT_MS) { + ESP_LOGW(TAG, "Timeout reading Home ID during setup"); + return true; // Proceed anyway after timeout + } + + return false; // Keep waiting +} void ZWaveProxy::loop() { if (this->response_handler_()) { @@ -37,6 +76,11 @@ void ZWaveProxy::loop() { this->api_connection_ = nullptr; // Unsubscribe if disconnected } + this->process_uart_(); + this->status_clear_warning(); +} + +void ZWaveProxy::process_uart_() { while (this->available()) { uint8_t byte; if (!this->read_byte(&byte)) { @@ -56,6 +100,7 @@ void ZWaveProxy::loop() { // Extract the 4-byte Home ID starting at offset 4 // The frame parser has already validated the checksum and ensured all bytes are present std::memcpy(this->home_id_.data(), this->buffer_.data() + 4, this->home_id_.size()); + this->home_id_ready_ = true; ESP_LOGI(TAG, "Home ID: %s", format_hex_pretty(this->home_id_.data(), this->home_id_.size(), ':', false).c_str()); } @@ -73,7 +118,6 @@ void ZWaveProxy::loop() { } } } - this->status_clear_warning(); } void ZWaveProxy::dump_config() { ESP_LOGCONFIG(TAG, "Z-Wave Proxy"); } diff --git a/esphome/components/zwave_proxy/zwave_proxy.h b/esphome/components/zwave_proxy/zwave_proxy.h index 5d908b328c..68bec4e7ce 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.h +++ b/esphome/components/zwave_proxy/zwave_proxy.h @@ -44,6 +44,8 @@ class ZWaveProxy : public uart::UARTDevice, public Component { void setup() override; void loop() override; void dump_config() override; + float get_setup_priority() const override; + bool can_proceed() override; void zwave_proxy_request(api::APIConnection *api_connection, api::enums::ZWaveProxyRequestType type); api::APIConnection *get_api_connection() { return this->api_connection_; } @@ -60,19 +62,24 @@ class ZWaveProxy : public uart::UARTDevice, public Component { bool parse_byte_(uint8_t byte); // Returns true if frame parsing was completed (a frame is ready in the buffer) void parse_start_(uint8_t byte); bool response_handler_(); - - api::APIConnection *api_connection_{nullptr}; // Current subscribed client - - std::array home_id_{0, 0, 0, 0}; // Fixed buffer for home ID - std::array buffer_; // Fixed buffer for incoming data - uint8_t buffer_index_{0}; // Index for populating the data buffer - uint8_t end_frame_after_{0}; // Payload reception ends after this index - uint8_t last_response_{0}; // Last response type sent - ZWaveParsingState parsing_state_{ZWAVE_PARSING_STATE_WAIT_START}; - bool in_bootloader_{false}; // True if the device is detected to be in bootloader mode + void process_uart_(); // Process all available UART data // Pre-allocated message - always ready to send api::ZWaveProxyFrame outgoing_proto_msg_; + std::array buffer_; // Fixed buffer for incoming data + std::array home_id_{0, 0, 0, 0}; // Fixed buffer for home ID + + // Pointers and 32-bit values (aligned together) + api::APIConnection *api_connection_{nullptr}; // Current subscribed client + uint32_t setup_time_{0}; // Time when setup() was called + + // 8-bit values (grouped together to minimize padding) + uint8_t buffer_index_{0}; // Index for populating the data buffer + uint8_t end_frame_after_{0}; // Payload reception ends after this index + uint8_t last_response_{0}; // Last response type sent + ZWaveParsingState parsing_state_{ZWAVE_PARSING_STATE_WAIT_START}; + bool in_bootloader_{false}; // True if the device is detected to be in bootloader mode + bool home_id_ready_{false}; // True when home ID has been received from Z-Wave module }; extern ZWaveProxy *global_zwave_proxy; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 7b6e6b4507..a4c24369a3 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -479,6 +479,12 @@ class EsphomeCleanMqttHandler(EsphomeCommandWebSocket): return [*DASHBOARD_COMMAND, "clean-mqtt", config_file] +class EsphomeCleanPlatformHandler(EsphomeCommandWebSocket): + async def build_command(self, json_message: dict[str, Any]) -> list[str]: + config_file = settings.rel_path(json_message["configuration"]) + return [*DASHBOARD_COMMAND, "clean-platform", config_file] + + class EsphomeCleanHandler(EsphomeCommandWebSocket): async def build_command(self, json_message: dict[str, Any]) -> list[str]: config_file = settings.rel_path(json_message["configuration"]) @@ -1313,6 +1319,7 @@ def make_app(debug=get_bool_env(ENV_DEV)) -> tornado.web.Application: (f"{rel}compile", EsphomeCompileHandler), (f"{rel}validate", EsphomeValidateHandler), (f"{rel}clean-mqtt", EsphomeCleanMqttHandler), + (f"{rel}clean-platform", EsphomeCleanPlatformHandler), (f"{rel}clean", EsphomeCleanHandler), (f"{rel}vscode", EsphomeVscodeHandler), (f"{rel}ace", EsphomeAceEditorHandler), diff --git a/esphome/writer.py b/esphome/writer.py index 6d34d8f751..718041876a 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -323,17 +323,39 @@ def clean_build(): # Clean PlatformIO cache to resolve CMake compiler detection issues # This helps when toolchain paths change or get corrupted try: - from platformio.project.helpers import get_project_cache_dir + from platformio.project.config import ProjectConfig except ImportError: # PlatformIO is not available, skip cache cleaning pass else: - cache_dir = get_project_cache_dir() - if cache_dir and cache_dir.strip(): - cache_path = Path(cache_dir) - if cache_path.is_dir(): - _LOGGER.info("Deleting PlatformIO cache %s", cache_dir) - shutil.rmtree(cache_dir) + config = ProjectConfig.get_instance() + cache_dir = Path(config.get("platformio", "cache_dir")) + if cache_dir.is_dir(): + _LOGGER.info("Deleting PlatformIO cache %s", cache_dir) + shutil.rmtree(cache_dir) + + +def clean_platform(): + import shutil + + # Clean entire build dir + if CORE.build_path.is_dir(): + _LOGGER.info("Deleting %s", CORE.build_path) + shutil.rmtree(CORE.build_path) + + # Clean PlatformIO project files + try: + from platformio.project.config import ProjectConfig + except ImportError: + # PlatformIO is not available, skip cleaning + pass + else: + config = ProjectConfig.get_instance() + for pio_dir in ["cache_dir", "packages_dir", "platforms_dir", "core_dir"]: + path = Path(config.get("platformio", pio_dir)) + if path.is_dir(): + _LOGGER.info("Deleting PlatformIO %s %s", pio_dir, path) + shutil.rmtree(path) GITIGNORE_CONTENT = """# Gitignore settings for ESPHome diff --git a/requirements.txt b/requirements.txt index 67f6e89f93..ca3db9821e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.1.0 click==8.1.7 esphome-dashboard==20250904.0 -aioesphomeapi==41.7.0 +aioesphomeapi==41.8.0 zeroconf==0.147.2 puremagic==1.30 ruamel.yaml==0.18.15 # dashboard_import diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index bb047d063c..8799ac56ff 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Generator from dataclasses import dataclass +import logging from pathlib import Path import re from typing import Any @@ -16,6 +17,7 @@ from esphome import platformio_api from esphome.__main__ import ( Purpose, choose_upload_log_host, + command_clean_platform, command_rename, command_update_all, command_wizard, @@ -1853,3 +1855,101 @@ esp32: # Should not have any Python error messages assert "TypeError" not in clean_output assert "can only concatenate str" not in clean_output + + +def test_command_clean_platform_success( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test command_clean_platform when writer.clean_platform() succeeds.""" + args = MockArgs() + config = {} + + # Set logger level to capture INFO messages + with ( + caplog.at_level(logging.INFO), + patch("esphome.writer.clean_platform") as mock_clean_platform, + ): + result = command_clean_platform(args, config) + + assert result == 0 + mock_clean_platform.assert_called_once() + + # Check that success message was logged + assert "Done!" in caplog.text + + +def test_command_clean_platform_oserror( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test command_clean_platform when writer.clean_platform() raises OSError.""" + args = MockArgs() + config = {} + + # Create a mock OSError with a specific message + mock_error = OSError("Permission denied: cannot delete directory") + + # Set logger level to capture ERROR and INFO messages + with ( + caplog.at_level(logging.INFO), + patch( + "esphome.writer.clean_platform", side_effect=mock_error + ) as mock_clean_platform, + ): + result = command_clean_platform(args, config) + + assert result == 1 + mock_clean_platform.assert_called_once() + + # Check that error message was logged + assert ( + "Error deleting platform files: Permission denied: cannot delete directory" + in caplog.text + ) + # Should not have success message + assert "Done!" not in caplog.text + + +def test_command_clean_platform_oserror_no_message( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test command_clean_platform when writer.clean_platform() raises OSError without message.""" + args = MockArgs() + config = {} + + # Create a mock OSError without a message + mock_error = OSError() + + # Set logger level to capture ERROR and INFO messages + with ( + caplog.at_level(logging.INFO), + patch( + "esphome.writer.clean_platform", side_effect=mock_error + ) as mock_clean_platform, + ): + result = command_clean_platform(args, config) + + assert result == 1 + mock_clean_platform.assert_called_once() + + # Check that error message was logged (should show empty string for OSError without message) + assert "Error deleting platform files:" in caplog.text + # Should not have success message + assert "Done!" not in caplog.text + + +def test_command_clean_platform_args_and_config_ignored() -> None: + """Test that command_clean_platform ignores args and config parameters.""" + # Test with various args and config to ensure they don't affect the function + args1 = MockArgs(name="test1", file="test.bin") + config1 = {"wifi": {"ssid": "test"}} + + args2 = MockArgs(name="test2", dashboard=True) + config2 = {"api": {}, "ota": {}} + + with patch("esphome.writer.clean_platform") as mock_clean_platform: + result1 = command_clean_platform(args1, config1) + result2 = command_clean_platform(args2, config2) + + assert result1 == 0 + assert result2 == 0 + assert mock_clean_platform.call_count == 2 diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 68da466a53..c9993c93cc 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -363,11 +363,17 @@ def test_clean_build( assert dependencies_lock.exists() assert platformio_cache_dir.exists() - # Mock PlatformIO's get_project_cache_dir + # Mock PlatformIO's ProjectConfig cache_dir with patch( - "platformio.project.helpers.get_project_cache_dir" - ) as mock_get_cache_dir: - mock_get_cache_dir.return_value = str(platformio_cache_dir) + "platformio.project.config.ProjectConfig.get_instance" + ) as mock_get_instance: + mock_config = MagicMock() + mock_get_instance.return_value = mock_config + mock_config.get.side_effect = ( + lambda section, option: str(platformio_cache_dir) + if (section, option) == ("platformio", "cache_dir") + else "" + ) # Call the function with caplog.at_level("INFO"): @@ -487,7 +493,7 @@ def test_clean_build_platformio_not_available( # Mock import error for platformio with ( - patch.dict("sys.modules", {"platformio.project.helpers": None}), + patch.dict("sys.modules", {"platformio.project.config": None}), caplog.at_level("INFO"), ): # Call the function @@ -521,11 +527,17 @@ def test_clean_build_empty_cache_dir( # Verify pioenvs exists before assert pioenvs_dir.exists() - # Mock PlatformIO's get_project_cache_dir to return whitespace + # Mock PlatformIO's ProjectConfig cache_dir to return whitespace with patch( - "platformio.project.helpers.get_project_cache_dir" - ) as mock_get_cache_dir: - mock_get_cache_dir.return_value = " " # Whitespace only + "platformio.project.config.ProjectConfig.get_instance" + ) as mock_get_instance: + mock_config = MagicMock() + mock_get_instance.return_value = mock_config + mock_config.get.side_effect = ( + lambda section, option: " " # Whitespace only + if (section, option) == ("platformio", "cache_dir") + else "" + ) # Call the function with caplog.at_level("INFO"): @@ -724,3 +736,126 @@ def test_write_cpp_with_duplicate_markers( # Call should raise an error with pytest.raises(EsphomeError, match="Found multiple auto generate code begins"): write_cpp("// New code") + + +@patch("esphome.writer.CORE") +def test_clean_platform( + mock_core: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test clean_platform removes build and PlatformIO dirs.""" + # Create build directory + build_dir = tmp_path / "build" + build_dir.mkdir() + (build_dir / "dummy.txt").write_text("x") + + # Create PlatformIO directories + pio_cache = tmp_path / "pio_cache" + pio_packages = tmp_path / "pio_packages" + pio_platforms = tmp_path / "pio_platforms" + pio_core = tmp_path / "pio_core" + for d in (pio_cache, pio_packages, pio_platforms, pio_core): + d.mkdir() + (d / "keep").write_text("x") + + # Setup CORE + mock_core.build_path = build_dir + + # Mock ProjectConfig + with patch( + "platformio.project.config.ProjectConfig.get_instance" + ) as mock_get_instance: + mock_config = MagicMock() + mock_get_instance.return_value = mock_config + + def cfg_get(section: str, option: str) -> str: + mapping = { + ("platformio", "cache_dir"): str(pio_cache), + ("platformio", "packages_dir"): str(pio_packages), + ("platformio", "platforms_dir"): str(pio_platforms), + ("platformio", "core_dir"): str(pio_core), + } + return mapping.get((section, option), "") + + mock_config.get.side_effect = cfg_get + + # Call + from esphome.writer import clean_platform + + with caplog.at_level("INFO"): + clean_platform() + + # Verify deletions + assert not build_dir.exists() + assert not pio_cache.exists() + assert not pio_packages.exists() + assert not pio_platforms.exists() + assert not pio_core.exists() + + # Verify logging mentions each + assert "Deleting" in caplog.text + assert str(build_dir) in caplog.text + assert "PlatformIO cache" in caplog.text + assert "PlatformIO packages" in caplog.text + assert "PlatformIO platforms" in caplog.text + assert "PlatformIO core" in caplog.text + + +@patch("esphome.writer.CORE") +def test_clean_platform_platformio_not_available( + mock_core: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test clean_platform when PlatformIO is not available.""" + # Build dir + build_dir = tmp_path / "build" + build_dir.mkdir() + mock_core.build_path = build_dir + + # PlatformIO dirs that should remain untouched + pio_cache = tmp_path / "pio_cache" + pio_cache.mkdir() + + from esphome.writer import clean_platform + + with ( + patch.dict("sys.modules", {"platformio.project.config": None}), + caplog.at_level("INFO"), + ): + clean_platform() + + # Build dir removed, PlatformIO dirs remain + assert not build_dir.exists() + assert pio_cache.exists() + + # No PlatformIO-specific logs + assert "PlatformIO" not in caplog.text + + +@patch("esphome.writer.CORE") +def test_clean_platform_partial_exists( + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test clean_platform when only build dir exists.""" + build_dir = tmp_path / "build" + build_dir.mkdir() + mock_core.build_path = build_dir + + with patch( + "platformio.project.config.ProjectConfig.get_instance" + ) as mock_get_instance: + mock_config = MagicMock() + mock_get_instance.return_value = mock_config + # Return non-existent dirs + mock_config.get.side_effect = lambda *_args, **_kw: str( + tmp_path / "does_not_exist" + ) + + from esphome.writer import clean_platform + + clean_platform() + + assert not build_dir.exists()