From 68628a85b15659c9bacf0059739774886bf6989f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 Sep 2025 01:08:57 -0500 Subject: [PATCH 1/8] [core] Use get_unit_of_measurement_ref() in entity logging to avoid string allocations (#10532) --- esphome/components/number/number.cpp | 4 ++-- esphome/components/sensor/sensor.cpp | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/number/number.cpp b/esphome/components/number/number.cpp index 8f5cd9bdeb..da08faf655 100644 --- a/esphome/components/number/number.cpp +++ b/esphome/components/number/number.cpp @@ -18,8 +18,8 @@ void log_number(const char *tag, const char *prefix, const char *type, Number *o ESP_LOGCONFIG(tag, "%s Icon: '%s'", prefix, obj->get_icon_ref().c_str()); } - if (!obj->traits.get_unit_of_measurement().empty()) { - ESP_LOGCONFIG(tag, "%s Unit of Measurement: '%s'", prefix, obj->traits.get_unit_of_measurement().c_str()); + if (!obj->traits.get_unit_of_measurement_ref().empty()) { + ESP_LOGCONFIG(tag, "%s Unit of Measurement: '%s'", prefix, obj->traits.get_unit_of_measurement_ref().c_str()); } if (!obj->traits.get_device_class_ref().empty()) { diff --git a/esphome/components/sensor/sensor.cpp b/esphome/components/sensor/sensor.cpp index ae45498949..e2e8302d8b 100644 --- a/esphome/components/sensor/sensor.cpp +++ b/esphome/components/sensor/sensor.cpp @@ -18,7 +18,7 @@ void log_sensor(const char *tag, const char *prefix, const char *type, Sensor *o "%s Unit of Measurement: '%s'\n" "%s Accuracy Decimals: %d", prefix, type, obj->get_name().c_str(), prefix, state_class_to_string(obj->get_state_class()), prefix, - obj->get_unit_of_measurement().c_str(), prefix, obj->get_accuracy_decimals()); + obj->get_unit_of_measurement_ref().c_str(), prefix, obj->get_accuracy_decimals()); if (!obj->get_device_class_ref().empty()) { ESP_LOGCONFIG(tag, "%s Device Class: '%s'", prefix, obj->get_device_class_ref().c_str()); @@ -128,7 +128,7 @@ void Sensor::internal_send_state_to_frontend(float state) { this->set_has_state(true); this->state = state; ESP_LOGD(TAG, "'%s': Sending state %.5f %s with %d decimals of accuracy", this->get_name().c_str(), state, - this->get_unit_of_measurement().c_str(), this->get_accuracy_decimals()); + this->get_unit_of_measurement_ref().c_str(), this->get_accuracy_decimals()); this->callback_.call(state); } From 4d681ffe3d1629cc5e07e1bf3af8c23caeded9a6 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 3 Sep 2025 11:47:51 -0400 Subject: [PATCH 2/8] [esp32] Rebuild when idf_component.yml changes (#10540) --- esphome/components/esp32/__init__.py | 7 ++++++- esphome/writer.py | 4 ++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 47832a08ae..54f83ab6e8 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -40,6 +40,7 @@ from esphome.cpp_generator import RawExpression import esphome.final_validate as fv from esphome.helpers import copy_file_if_changed, mkdir_p, write_file_if_changed from esphome.types import ConfigType +from esphome.writer import clean_cmake_cache from .boards import BOARDS, STANDARD_BOARDS from .const import ( # noqa @@ -1074,7 +1075,11 @@ def _write_idf_component_yml(): contents = yaml_util.dump({"dependencies": dependencies}) else: contents = "" - write_file_if_changed(yml_path, contents) + if write_file_if_changed(yml_path, contents): + dependencies_lock = CORE.relative_build_path("dependencies.lock") + if os.path.isfile(dependencies_lock): + os.remove(dependencies_lock) + clean_cmake_cache() # Called by writer.py diff --git a/esphome/writer.py b/esphome/writer.py index 4b25a25f7e..b8fe44abdd 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -310,6 +310,10 @@ def clean_build(): if os.path.isdir(piolibdeps): _LOGGER.info("Deleting %s", piolibdeps) shutil.rmtree(piolibdeps) + dependencies_lock = CORE.relative_build_path("dependencies.lock") + if os.path.isfile(dependencies_lock): + _LOGGER.info("Deleting %s", dependencies_lock) + os.remove(dependencies_lock) GITIGNORE_CONTENT = """# Gitignore settings for ESPHome From c3359edb33f6e403efa5b8970bd60ebcb1e80079 Mon Sep 17 00:00:00 2001 From: Anton Viktorov Date: Wed, 3 Sep 2025 19:18:26 +0200 Subject: [PATCH 3/8] [i2c] Fix bug write_register16 (#10547) --- esphome/components/i2c/i2c.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/i2c/i2c.cpp b/esphome/components/i2c/i2c.cpp index e66ab8ba73..48e1cf8aca 100644 --- a/esphome/components/i2c/i2c.cpp +++ b/esphome/components/i2c/i2c.cpp @@ -47,9 +47,9 @@ ErrorCode I2CDevice::write_register(uint8_t a_register, const uint8_t *data, siz ErrorCode I2CDevice::write_register16(uint16_t a_register, const uint8_t *data, size_t len) const { std::vector v(len + 2); - v.push_back(a_register >> 8); - v.push_back(a_register); - v.insert(v.end(), data, data + len); + v[0] = a_register >> 8; + v[1] = a_register; + std::copy(data, data + len, v.begin() + 2); return bus_->write_readv(this->address_, v.data(), v.size(), nullptr, 0); } From 8aeb6d3ba2e3eda1726ec1e2acaf8d5060518ca0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 Sep 2025 14:27:39 -0500 Subject: [PATCH 4/8] [bluetooth_proxy] Change default for active connections to true (#10546) --- esphome/components/bluetooth_proxy/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index 112faa27e5..f21b5028c7 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -80,7 +80,7 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(BluetoothProxy), - cv.Optional(CONF_ACTIVE, default=False): cv.boolean, + cv.Optional(CONF_ACTIVE, default=True): cv.boolean, cv.SplitDefault(CONF_CACHE_SERVICES, esp32_idf=True): cv.All( cv.only_with_esp_idf, cv.boolean ), From 0ab65c225e9152ad0930a885515803f3f6c14f64 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 3 Sep 2025 15:58:42 -0400 Subject: [PATCH 5/8] [wifi] Check for esp32_hosted on no wifi variants (#10528) --- esphome/components/wifi/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 7943911021..c63a12f879 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -51,7 +51,7 @@ from . import wpa2_eap AUTO_LOAD = ["network"] -NO_WIFI_VARIANTS = [const.VARIANT_ESP32H2] +NO_WIFI_VARIANTS = [const.VARIANT_ESP32H2, const.VARIANT_ESP32P4] CONF_SAVE = "save" wifi_ns = cg.esphome_ns.namespace("wifi") @@ -179,8 +179,8 @@ WIFI_NETWORK_STA = WIFI_NETWORK_BASE.extend( def validate_variant(_): if CORE.is_esp32: variant = get_esp32_variant() - if variant in NO_WIFI_VARIANTS: - raise cv.Invalid(f"{variant} does not support WiFi") + if variant in NO_WIFI_VARIANTS and "esp32_hosted" not in fv.full_config.get(): + raise cv.Invalid(f"WiFi requires component esp32_hosted on {variant}") def final_validate(config): From 5759692627d7679f5f2693585a779b14a6d1a3c0 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 3 Sep 2025 15:59:47 -0400 Subject: [PATCH 6/8] [esp32] Clear IDF environment variables (#10527) Co-authored-by: J. Nick Koston --- esphome/components/esp32/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 54f83ab6e8..12d84dd4b3 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -841,6 +841,9 @@ async def to_code(config): if conf[CONF_ADVANCED][CONF_IGNORE_EFUSE_CUSTOM_MAC]: cg.add_define("USE_ESP32_IGNORE_EFUSE_CUSTOM_MAC") + for clean_var in ("IDF_PATH", "IDF_TOOLS_PATH"): + os.environ.pop(clean_var, None) + add_extra_script( "post", "post_build.py", From 23c66509029dc8a32f7669901de69947f4ef778a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 Sep 2025 15:07:13 -0500 Subject: [PATCH 7/8] [api] Fix VERY_VERBOSE logging compilation error with bool arrays (#10539) --- esphome/components/api/api_pb2_dump.cpp | 2 +- script/api_protobuf/api_protobuf.py | 4 +++- tests/integration/fixtures/parallel_script_delays.yaml | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 3e7df9195b..1d7d315419 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1135,7 +1135,7 @@ void ExecuteServiceArgument::dump_to(std::string &out) const { dump_field(out, "string_", this->string_); dump_field(out, "int_", this->int_); for (const auto it : this->bool_array) { - dump_field(out, "bool_array", it, 4); + dump_field(out, "bool_array", static_cast(it), 4); } for (const auto &it : this->int_array) { dump_field(out, "int_array", it, 4); diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 511d70d3ec..205bac4937 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -1059,7 +1059,9 @@ def _generate_array_dump_content( # Check if underlying type can use dump_field if ti.can_use_dump_field(): # For types that have dump_field overloads, use them with extra indent - o += f' dump_field(out, "{name}", {ti.dump_field_value("it")}, 4);\n' + # std::vector iterators return proxy objects, need explicit cast + value_expr = "static_cast(it)" if is_bool else ti.dump_field_value("it") + o += f' dump_field(out, "{name}", {value_expr}, 4);\n' else: # For complex types (messages, bytes), use the old pattern o += f' out.append(" {name}: ");\n' diff --git a/tests/integration/fixtures/parallel_script_delays.yaml b/tests/integration/fixtures/parallel_script_delays.yaml index 6887045913..71d5b904e9 100644 --- a/tests/integration/fixtures/parallel_script_delays.yaml +++ b/tests/integration/fixtures/parallel_script_delays.yaml @@ -4,7 +4,7 @@ esphome: host: logger: - level: DEBUG + level: VERY_VERBOSE api: actions: From c03d978b46e96bccd08d0e2d595e9f66594b09ad Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Thu, 4 Sep 2025 04:02:49 +0200 Subject: [PATCH 8/8] [wizard] extend the wizard dashboard API to allow upload and empty config options (#10203) --- esphome/dashboard/web_server.py | 77 ++++++++++++++++++++++++++++----- esphome/wizard.py | 59 +++++++++++++++---------- tests/unit_tests/test_wizard.py | 61 ++++++++++++++++++++++++++ 3 files changed, 164 insertions(+), 33 deletions(-) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index fd16667d8a..294a180794 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio import base64 +import binascii from collections.abc import Callable, Iterable import datetime import functools @@ -490,7 +491,17 @@ class WizardRequestHandler(BaseHandler): kwargs = { k: v for k, v in json.loads(self.request.body.decode()).items() - if k in ("name", "platform", "board", "ssid", "psk", "password") + if k + in ( + "type", + "name", + "platform", + "board", + "ssid", + "psk", + "password", + "file_content", + ) } if not kwargs["name"]: self.set_status(422) @@ -498,19 +509,65 @@ class WizardRequestHandler(BaseHandler): self.write(json.dumps({"error": "Name is required"})) return + if "type" not in kwargs: + # Default to basic wizard type for backwards compatibility + kwargs["type"] = "basic" + kwargs["friendly_name"] = kwargs["name"] kwargs["name"] = friendly_name_slugify(kwargs["friendly_name"]) - - kwargs["ota_password"] = secrets.token_hex(16) - noise_psk = secrets.token_bytes(32) - kwargs["api_encryption_key"] = base64.b64encode(noise_psk).decode() + if kwargs["type"] == "basic": + kwargs["ota_password"] = secrets.token_hex(16) + noise_psk = secrets.token_bytes(32) + kwargs["api_encryption_key"] = base64.b64encode(noise_psk).decode() + elif kwargs["type"] == "upload": + try: + kwargs["file_text"] = base64.b64decode(kwargs["file_content"]).decode( + "utf-8" + ) + except (binascii.Error, UnicodeDecodeError): + self.set_status(422) + self.set_header("content-type", "application/json") + self.write( + json.dumps({"error": "The uploaded file is not correctly encoded."}) + ) + return + elif kwargs["type"] != "empty": + self.set_status(422) + self.set_header("content-type", "application/json") + self.write( + json.dumps( + {"error": f"Invalid wizard type specified: {kwargs['type']}"} + ) + ) + return filename = f"{kwargs['name']}.yaml" destination = settings.rel_path(filename) - wizard.wizard_write(path=destination, **kwargs) - self.set_status(200) - self.set_header("content-type", "application/json") - self.write(json.dumps({"configuration": filename})) - self.finish() + + # Check if destination file already exists + if os.path.exists(destination): + self.set_status(409) # Conflict status code + self.set_header("content-type", "application/json") + self.write( + json.dumps({"error": f"Configuration file '{filename}' already exists"}) + ) + self.finish() + return + + success = wizard.wizard_write(path=destination, **kwargs) + if success: + self.set_status(200) + self.set_header("content-type", "application/json") + self.write(json.dumps({"configuration": filename})) + self.finish() + else: + self.set_status(500) + self.set_header("content-type", "application/json") + self.write( + json.dumps( + {"error": "Failed to write configuration, see logs for details"} + ) + ) + self.finish() class ImportRequestHandler(BaseHandler): diff --git a/esphome/wizard.py b/esphome/wizard.py index 8602e90222..cb599df59a 100644 --- a/esphome/wizard.py +++ b/esphome/wizard.py @@ -189,32 +189,45 @@ def wizard_write(path, **kwargs): from esphome.components.rtl87xx import boards as rtl87xx_boards name = kwargs["name"] - board = kwargs["board"] + if kwargs["type"] == "empty": + file_text = "" + # Will be updated later after editing the file + hardware = "UNKNOWN" + elif kwargs["type"] == "upload": + file_text = kwargs["file_text"] + hardware = "UNKNOWN" + else: # "basic" + board = kwargs["board"] - for key in ("ssid", "psk", "password", "ota_password"): - if key in kwargs: - kwargs[key] = sanitize_double_quotes(kwargs[key]) + for key in ("ssid", "psk", "password", "ota_password"): + if key in kwargs: + kwargs[key] = sanitize_double_quotes(kwargs[key]) + if "platform" not in kwargs: + if board in esp8266_boards.BOARDS: + platform = "ESP8266" + elif board in esp32_boards.BOARDS: + platform = "ESP32" + elif board in rp2040_boards.BOARDS: + platform = "RP2040" + elif board in bk72xx_boards.BOARDS: + platform = "BK72XX" + elif board in ln882x_boards.BOARDS: + platform = "LN882X" + elif board in rtl87xx_boards.BOARDS: + platform = "RTL87XX" + else: + safe_print(color(AnsiFore.RED, f'The board "{board}" is unknown.')) + return False + kwargs["platform"] = platform + hardware = kwargs["platform"] + file_text = wizard_file(**kwargs) - if "platform" not in kwargs: - if board in esp8266_boards.BOARDS: - platform = "ESP8266" - elif board in esp32_boards.BOARDS: - platform = "ESP32" - elif board in rp2040_boards.BOARDS: - platform = "RP2040" - elif board in bk72xx_boards.BOARDS: - platform = "BK72XX" - elif board in ln882x_boards.BOARDS: - platform = "LN882X" - elif board in rtl87xx_boards.BOARDS: - platform = "RTL87XX" - else: - safe_print(color(AnsiFore.RED, f'The board "{board}" is unknown.')) - return False - kwargs["platform"] = platform - hardware = kwargs["platform"] + # Check if file already exists to prevent overwriting + if os.path.exists(path) and os.path.isfile(path): + safe_print(color(AnsiFore.RED, f'The file "{path}" already exists.')) + return False - write_file(path, wizard_file(**kwargs)) + write_file(path, file_text) storage = StorageJSON.from_wizard(name, name, f"{name}.local", hardware) storage_path = ext_storage_path(os.path.basename(path)) storage.save(storage_path) diff --git a/tests/unit_tests/test_wizard.py b/tests/unit_tests/test_wizard.py index ab20b2abb5..fea2fb5558 100644 --- a/tests/unit_tests/test_wizard.py +++ b/tests/unit_tests/test_wizard.py @@ -17,6 +17,7 @@ import esphome.wizard as wz @pytest.fixture def default_config(): return { + "type": "basic", "name": "test-name", "platform": "ESP8266", "board": "esp01_1m", @@ -125,6 +126,47 @@ def test_wizard_write_sets_platform(default_config, tmp_path, monkeypatch): assert "esp8266:" in generated_config +def test_wizard_empty_config(tmp_path, monkeypatch): + """ + The wizard should be able to create an empty configuration + """ + # Given + empty_config = { + "type": "empty", + "name": "test-empty", + } + monkeypatch.setattr(wz, "write_file", MagicMock()) + monkeypatch.setattr(CORE, "config_path", os.path.dirname(tmp_path)) + + # When + wz.wizard_write(tmp_path, **empty_config) + + # Then + generated_config = wz.write_file.call_args.args[1] + assert generated_config == "" + + +def test_wizard_upload_config(tmp_path, monkeypatch): + """ + The wizard should be able to import an base64 encoded configuration + """ + # Given + empty_config = { + "type": "upload", + "name": "test-upload", + "file_text": "# imported file 📁\n\n", + } + monkeypatch.setattr(wz, "write_file", MagicMock()) + monkeypatch.setattr(CORE, "config_path", os.path.dirname(tmp_path)) + + # When + wz.wizard_write(tmp_path, **empty_config) + + # Then + generated_config = wz.write_file.call_args.args[1] + assert generated_config == "# imported file 📁\n\n" + + def test_wizard_write_defaults_platform_from_board_esp8266( default_config, tmp_path, monkeypatch ): @@ -471,3 +513,22 @@ def test_wizard_requires_valid_ssid(tmpdir, monkeypatch, wizard_answers): # Then assert retval == 0 + + +def test_wizard_write_protects_existing_config(tmpdir, default_config, monkeypatch): + """ + The wizard_write function should not overwrite existing config files and return False + """ + # Given + config_file = tmpdir.join("test.yaml") + original_content = "# Original config content\n" + config_file.write(original_content) + + monkeypatch.setattr(CORE, "config_path", str(tmpdir)) + + # When + result = wz.wizard_write(str(config_file), **default_config) + + # Then + assert result is False # Should return False when file exists + assert config_file.read() == original_content