diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07fd91b1c8..a89a12e2e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,6 +105,7 @@ jobs: script/ci-custom.py script/build_codeowners.py --check script/build_language_schema.py --check + script/generate-esp32-boards.py --check pytest: name: Run pytest diff --git a/esphome/components/ade7880/ade7880.cpp b/esphome/components/ade7880/ade7880.cpp index 55f834bf86..fd560e0676 100644 --- a/esphome/components/ade7880/ade7880.cpp +++ b/esphome/components/ade7880/ade7880.cpp @@ -113,7 +113,7 @@ void ADE7880::update() { if (this->channel_a_ != nullptr) { auto *chan = this->channel_a_; this->update_sensor_from_s24zp_register16_(chan->current, AIRMS, [](float val) { return val / 100000.0f; }); - this->update_sensor_from_s24zp_register16_(chan->voltage, BVRMS, [](float val) { return val / 10000.0f; }); + this->update_sensor_from_s24zp_register16_(chan->voltage, AVRMS, [](float val) { return val / 10000.0f; }); this->update_sensor_from_s24zp_register16_(chan->active_power, AWATT, [](float val) { return val / 100.0f; }); this->update_sensor_from_s24zp_register16_(chan->apparent_power, AVA, [](float val) { return val / 100.0f; }); this->update_sensor_from_s16_register16_(chan->power_factor, APF, diff --git a/esphome/components/as7341/sensor.py b/esphome/components/as7341/sensor.py index 2832b7c3df..fa51a1cdfa 100644 --- a/esphome/components/as7341/sensor.py +++ b/esphome/components/as7341/sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( + CONF_CLEAR, CONF_GAIN, CONF_ID, DEVICE_CLASS_ILLUMINANCE, @@ -29,7 +30,6 @@ CONF_F5 = "f5" CONF_F6 = "f6" CONF_F7 = "f7" CONF_F8 = "f8" -CONF_CLEAR = "clear" CONF_NIR = "nir" UNIT_COUNTS = "#" diff --git a/esphome/components/esp32/boards.py b/esphome/components/esp32/boards.py index cf6cf8cbe5..5f039492c8 100644 --- a/esphome/components/esp32/boards.py +++ b/esphome/components/esp32/boards.py @@ -1504,6 +1504,10 @@ BOARDS = { "name": "BPI-Bit", "variant": VARIANT_ESP32, }, + "bpi-centi-s3": { + "name": "BPI-Centi-S3", + "variant": VARIANT_ESP32S3, + }, "bpi_leaf_s3": { "name": "BPI-Leaf-S3", "variant": VARIANT_ESP32S3, @@ -1664,10 +1668,46 @@ BOARDS = { "name": "Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)", "variant": VARIANT_ESP32S3, }, + "esp32-s3-devkitc-1-n32r8v": { + "name": "Espressif ESP32-S3-DevKitC-1-N32R8V (32 MB Flash Octal, 8 MB PSRAM Octal)", + "variant": VARIANT_ESP32S3, + }, + "esp32-s3-devkitc1-n16r16": { + "name": "Espressif ESP32-S3-DevKitC-1-N16R16V (16 MB Flash Quad, 16 MB PSRAM Octal)", + "variant": VARIANT_ESP32S3, + }, + "esp32-s3-devkitc1-n16r2": { + "name": "Espressif ESP32-S3-DevKitC-1-N16R2 (16 MB Flash Quad, 2 MB PSRAM Quad)", + "variant": VARIANT_ESP32S3, + }, + "esp32-s3-devkitc1-n16r8": { + "name": "Espressif ESP32-S3-DevKitC-1-N16R8V (16 MB Flash Quad, 8 MB PSRAM Octal)", + "variant": VARIANT_ESP32S3, + }, + "esp32-s3-devkitc1-n4r2": { + "name": "Espressif ESP32-S3-DevKitC-1-N4R2 (4 MB Flash Quad, 2 MB PSRAM Quad)", + "variant": VARIANT_ESP32S3, + }, + "esp32-s3-devkitc1-n4r8": { + "name": "Espressif ESP32-S3-DevKitC-1-N4R8 (4 MB Flash Quad, 8 MB PSRAM Octal)", + "variant": VARIANT_ESP32S3, + }, + "esp32-s3-devkitc1-n8r2": { + "name": "Espressif ESP32-S3-DevKitC-1-N8R2 (8 MB Flash Quad, 2 MB PSRAM quad)", + "variant": VARIANT_ESP32S3, + }, + "esp32-s3-devkitc1-n8r8": { + "name": "Espressif ESP32-S3-DevKitC-1-N8R8 (8 MB Flash Quad, 8 MB PSRAM Octal)", + "variant": VARIANT_ESP32S3, + }, "esp32-s3-devkitm-1": { "name": "Espressif ESP32-S3-DevKitM-1", "variant": VARIANT_ESP32S3, }, + "esp32-s3-fh4r2": { + "name": "Espressif ESP32-S3-FH4R2 (4 MB QD, 2MB PSRAM)", + "variant": VARIANT_ESP32S3, + }, "esp32-solo1": { "name": "Espressif Generic ESP32-solo1 4M Flash", "variant": VARIANT_ESP32, @@ -1764,6 +1804,10 @@ BOARDS = { "name": "Franzininho WiFi MSC", "variant": VARIANT_ESP32S2, }, + "freenove-esp32-s3-n8r8": { + "name": "Freenove ESP32-S3 WROOM N8R8 (8MB Flash / 8MB PSRAM)", + "variant": VARIANT_ESP32S3, + }, "freenove_esp32_s3_wroom": { "name": "Freenove ESP32-S3 WROOM N8R8 (8MB Flash / 8MB PSRAM)", "variant": VARIANT_ESP32S3, @@ -1964,6 +2008,10 @@ BOARDS = { "name": "M5Stack AtomS3", "variant": VARIANT_ESP32S3, }, + "m5stack-atoms3u": { + "name": "M5Stack AtomS3U", + "variant": VARIANT_ESP32S3, + }, "m5stack-core-esp32": { "name": "M5Stack Core ESP32", "variant": VARIANT_ESP32, @@ -2084,6 +2132,10 @@ BOARDS = { "name": "Ai-Thinker NodeMCU-32S2 (ESP-12K)", "variant": VARIANT_ESP32S2, }, + "nologo_esp32c3_super_mini": { + "name": "Nologo ESP32C3 SuperMini", + "variant": VARIANT_ESP32C3, + }, "nscreen-32": { "name": "YeaCreate NSCREEN-32", "variant": VARIANT_ESP32, @@ -2192,6 +2244,10 @@ BOARDS = { "name": "SparkFun LoRa Gateway 1-Channel", "variant": VARIANT_ESP32, }, + "sparkfun_pro_micro_esp32c3": { + "name": "SparkFun Pro Micro ESP32-C3", + "variant": VARIANT_ESP32C3, + }, "sparkfun_qwiic_pocket_esp32c6": { "name": "SparkFun ESP32-C6 Qwiic Pocket", "variant": VARIANT_ESP32C6, @@ -2256,6 +2312,14 @@ BOARDS = { "name": "Turta IoT Node", "variant": VARIANT_ESP32, }, + "um_bling": { + "name": "Unexpected Maker BLING!", + "variant": VARIANT_ESP32S3, + }, + "um_edges3_d": { + "name": "Unexpected Maker EDGES3[D]", + "variant": VARIANT_ESP32S3, + }, "um_feathers2": { "name": "Unexpected Maker FeatherS2", "variant": VARIANT_ESP32S2, @@ -2268,10 +2332,18 @@ BOARDS = { "name": "Unexpected Maker FeatherS3", "variant": VARIANT_ESP32S3, }, + "um_feathers3_neo": { + "name": "Unexpected Maker FeatherS3 Neo", + "variant": VARIANT_ESP32S3, + }, "um_nanos3": { "name": "Unexpected Maker NanoS3", "variant": VARIANT_ESP32S3, }, + "um_omgs3": { + "name": "Unexpected Maker OMGS3", + "variant": VARIANT_ESP32S3, + }, "um_pros3": { "name": "Unexpected Maker PROS3", "variant": VARIANT_ESP32S3, @@ -2280,6 +2352,14 @@ BOARDS = { "name": "Unexpected Maker RMP", "variant": VARIANT_ESP32S2, }, + "um_squixl": { + "name": "Unexpected Maker SQUiXL", + "variant": VARIANT_ESP32S3, + }, + "um_tinyc6": { + "name": "Unexpected Maker TinyC6", + "variant": VARIANT_ESP32C6, + }, "um_tinys2": { "name": "Unexpected Maker TinyS2", "variant": VARIANT_ESP32S2, @@ -2401,3 +2481,4 @@ BOARDS = { "variant": VARIANT_ESP32S3, }, } +# DO NOT ADD ANYTHING BELOW THIS LINE diff --git a/esphome/components/mqtt/mqtt_client.cpp b/esphome/components/mqtt/mqtt_client.cpp index 7675280f1a..7ab6efd1a1 100644 --- a/esphome/components/mqtt/mqtt_client.cpp +++ b/esphome/components/mqtt/mqtt_client.cpp @@ -491,7 +491,7 @@ bool MQTTClientComponent::publish(const std::string &topic, const std::string &p bool MQTTClientComponent::publish(const std::string &topic, const char *payload, size_t payload_length, uint8_t qos, bool retain) { - return publish({.topic = topic, .payload = payload, .qos = qos, .retain = retain}); + return publish({.topic = topic, .payload = std::string(payload, payload_length), .qos = qos, .retain = retain}); } bool MQTTClientComponent::publish(const MQTTMessage &message) { diff --git a/esphome/const.py b/esphome/const.py index 677b9173ec..c91c81f484 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -186,6 +186,7 @@ CONF_CHARACTERISTIC_UUID = "characteristic_uuid" CONF_CHECK = "check" CONF_CHIPSET = "chipset" CONF_CLEAN_SESSION = "clean_session" +CONF_CLEAR = "clear" CONF_CLEAR_IMPEDANCE = "clear_impedance" CONF_CLIENT_CERTIFICATE = "client_certificate" CONF_CLIENT_CERTIFICATE_KEY = "client_certificate_key" diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 242a6854df..983b844ced 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -699,6 +699,15 @@ class EsphomeCore: def relative_piolibdeps_path(self, *path): return self.relative_build_path(".piolibdeps", *path) + @property + def platformio_cache_dir(self) -> str: + """Get the PlatformIO cache directory path.""" + # Check if running in Docker/HA addon with custom cache dir + if cache_dir := os.environ.get("PLATFORMIO_CACHE_DIR"): + return cache_dir + # Default PlatformIO cache location + return os.path.expanduser("~/.platformio/.cache") + @property def firmware_bin(self): if self.is_libretiny: diff --git a/esphome/writer.py b/esphome/writer.py index b8fe44abdd..adddc85c80 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -315,6 +315,13 @@ def clean_build(): _LOGGER.info("Deleting %s", dependencies_lock) os.remove(dependencies_lock) + # Clean PlatformIO cache to resolve CMake compiler detection issues + # This helps when toolchain paths change or get corrupted + cache_dir = CORE.platformio_cache_dir + if os.path.isdir(cache_dir): + _LOGGER.info("Deleting PlatformIO cache %s", cache_dir) + shutil.rmtree(cache_dir) + GITIGNORE_CONTENT = """# Gitignore settings for ESPHome # This is an example and may include too much for your use-case. diff --git a/script/generate-esp32-boards.py b/script/generate-esp32-boards.py index 3f444ed455..152a480d23 100755 --- a/script/generate-esp32-boards.py +++ b/script/generate-esp32-boards.py @@ -1,14 +1,18 @@ #!/usr/bin/env python3 +import argparse import json -import os +from pathlib import Path import subprocess +import sys import tempfile from esphome.components.esp32 import ESP_IDF_PLATFORM_VERSION as ver +from esphome.helpers import write_file_if_changed version_str = f"{ver.major}.{ver.minor:02d}.{ver.patch:02d}" -print(f"ESP32 Platform Version: {version_str}") +root = Path(__file__).parent.parent +boards_file_path = root / "esphome" / "components" / "esp32" / "boards.py" def get_boards(): @@ -17,6 +21,9 @@ def get_boards(): [ "git", "clone", + "-q", + "-c", + "advice.detachedHead=false", "--depth", "1", "--branch", @@ -26,16 +33,14 @@ def get_boards(): ], check=True, ) - boards_file = os.path.join(tempdir, "boards") + boards_directory = Path(tempdir) / "boards" boards = {} - for fname in os.listdir(boards_file): - if not fname.endswith(".json"): - continue - with open(os.path.join(boards_file, fname), encoding="utf-8") as f: + for fname in boards_directory.glob("*.json"): + with fname.open(encoding="utf-8") as f: board_info = json.load(f) mcu = board_info["build"]["mcu"] name = board_info["name"] - board = fname[:-5] + board = fname.stem variant = mcu.upper() boards[board] = { "name": name, @@ -47,33 +52,47 @@ def get_boards(): TEMPLATE = """ "%s": { "name": "%s", "variant": %s, - }, -""" + },""" -def main(): +def main(check: bool): boards = get_boards() # open boards.py, delete existing BOARDS variable and write the new boards dict - boards_file_path = os.path.join( - os.path.dirname(__file__), "..", "esphome", "components", "esp32", "boards.py" - ) - with open(boards_file_path, encoding="UTF-8") as f: - lines = f.readlines() + existing_content = boards_file_path.read_text(encoding="UTF-8") - with open(boards_file_path, "w", encoding="UTF-8") as f: - for line in lines: - if line.startswith("BOARDS = {"): - f.write("BOARDS = {\n") - f.writelines( - TEMPLATE % (board, info["name"], info["variant"]) - for board, info in sorted(boards.items()) - ) - f.write("}\n") - break + parts: list[str] = [] + for line in existing_content.splitlines(): + if line == "BOARDS = {": + parts.append(line) + parts.extend( + TEMPLATE % (board, info["name"], info["variant"]) + for board, info in sorted(boards.items()) + ) + parts.append("}") + parts.append("# DO NOT ADD ANYTHING BELOW THIS LINE") + break - f.write(line) + parts.append(line) + + parts.append("") + content = "\n".join(parts) + + if check: + if existing_content != content: + print("boards.py file is not up to date.") + print("Please run `script/generate-esp32-boards.py`") + sys.exit(1) + print("boards.py file is up to date") + elif write_file_if_changed(boards_file_path, content): + print("ESP32 boards updated successfully.") if __name__ == "__main__": - main() - print("ESP32 boards updated successfully.") + parser = argparse.ArgumentParser() + parser.add_argument( + "--check", + help="Check if the boards.py file is up to date.", + action="store_true", + ) + args = parser.parse_args() + main(args.check) diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 9a69329e80..b36bc8f4c0 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -660,3 +660,37 @@ class TestEsphomeCore: os.environ.pop("ESPHOME_IS_HA_ADDON", None) os.environ.pop("ESPHOME_DATA_DIR", None) assert target.data_dir == expected_default + + def test_platformio_cache_dir_with_env_var(self): + """Test platformio_cache_dir when PLATFORMIO_CACHE_DIR env var is set.""" + target = core.EsphomeCore() + test_cache_dir = "/custom/cache/dir" + + with patch.dict(os.environ, {"PLATFORMIO_CACHE_DIR": test_cache_dir}): + assert target.platformio_cache_dir == test_cache_dir + + def test_platformio_cache_dir_without_env_var(self): + """Test platformio_cache_dir defaults to ~/.platformio/.cache.""" + target = core.EsphomeCore() + + with patch.dict(os.environ, {}, clear=True): + # Ensure env var is not set + os.environ.pop("PLATFORMIO_CACHE_DIR", None) + expected = os.path.expanduser("~/.platformio/.cache") + assert target.platformio_cache_dir == expected + + def test_platformio_cache_dir_empty_env_var(self): + """Test platformio_cache_dir with empty env var falls back to default.""" + target = core.EsphomeCore() + + with patch.dict(os.environ, {"PLATFORMIO_CACHE_DIR": ""}): + expected = os.path.expanduser("~/.platformio/.cache") + assert target.platformio_cache_dir == expected + + def test_platformio_cache_dir_docker_addon_path(self): + """Test platformio_cache_dir in Docker/HA addon environment.""" + target = core.EsphomeCore() + addon_cache = "/data/cache/platformio" + + with patch.dict(os.environ, {"PLATFORMIO_CACHE_DIR": addon_cache}): + assert target.platformio_cache_dir == addon_cache diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index f1f86a322e..593e3d2eae 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -349,15 +349,25 @@ def test_clean_build( dependencies_lock = tmp_path / "dependencies.lock" dependencies_lock.write_text("lock file") + # Create PlatformIO cache directory + platformio_cache_dir = tmp_path / ".platformio" / ".cache" + platformio_cache_dir.mkdir(parents=True) + (platformio_cache_dir / "downloads").mkdir() + (platformio_cache_dir / "http").mkdir() + (platformio_cache_dir / "tmp").mkdir() + (platformio_cache_dir / "downloads" / "package.tar.gz").write_text("package") + # Setup mocks mock_core.relative_pioenvs_path.return_value = str(pioenvs_dir) mock_core.relative_piolibdeps_path.return_value = str(piolibdeps_dir) mock_core.relative_build_path.return_value = str(dependencies_lock) + mock_core.platformio_cache_dir = str(platformio_cache_dir) # Verify all exist before assert pioenvs_dir.exists() assert piolibdeps_dir.exists() assert dependencies_lock.exists() + assert platformio_cache_dir.exists() # Call the function with caplog.at_level("INFO"): @@ -367,12 +377,14 @@ def test_clean_build( assert not pioenvs_dir.exists() assert not piolibdeps_dir.exists() assert not dependencies_lock.exists() + assert not platformio_cache_dir.exists() # Verify logging assert "Deleting" in caplog.text assert ".pioenvs" in caplog.text assert ".piolibdeps" in caplog.text assert "dependencies.lock" in caplog.text + assert "PlatformIO cache" in caplog.text @patch("esphome.writer.CORE")