Compare commits

...

20 Commits

Author SHA1 Message Date
Jesse Hills 58780cd765 Merge branch 'release' into dev 2026-07-01 16:26:24 +12:00
Jesse Hills 1fc73731a8 Merge pull request #17317 from esphome/bump-2026.6.4
2026.6.4
2026-07-01 16:25:39 +12:00
Jesse Hills e47feace11 Bump version to 2026.6.4 2026-07-01 13:18:54 +12:00
esphome[bot] 4472d3b61b Bump bundled esphome-device-builder to 1.0.23 (#17316) 2026-07-01 13:18:54 +12:00
Jonathan Swoboda 06c5bcbc66 [espnow] Drop oversized received frames to prevent buffer overflow (#17271) 2026-07-01 13:18:54 +12:00
Jonathan Swoboda 6c44775bf5 [bluetooth_proxy] Fix -Wtype-limits warning with active: false (#17273) 2026-07-01 13:18:54 +12:00
Clyde Stubbs b127363fa0 [mipi_spi] Bug fixes (#17247) 2026-07-01 13:18:54 +12:00
esphome[bot] 782b58bbeb Bump bundled esphome-device-builder to 1.0.22 2026-07-01 13:18:50 +12:00
Franck Nijhof 5de508ad8c [es8388] Fix DAC unable to unmute once muted (#17221)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-07-01 13:18:40 +12:00
Jesse Hills 054c8ba485 [config_validation] Fix multicast typo in error message (#17206) 2026-07-01 13:18:35 +12:00
Julian Lunz 3b2be021b2 [adc] Only call cyw43_thread_enter/exit for VSYS when WiFi is active on RP2040 (#17203) 2026-07-01 13:11:21 +12:00
esphome[bot] 848defedd8 Bump bundled esphome-device-builder to 1.0.23 (#17316) 2026-07-01 13:09:48 +12:00
dependabot[bot] 4c9ed129cf Bump awalsh128/cache-apt-pkgs-action from 1.6.0 to 1.6.3 (#17310)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 18:28:04 -04:00
dependabot[bot] c8b37fb1c8 Bump platformdirs from 4.9.4 to 4.10.0 (#17309)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 17:18:33 -04:00
Jonathan Swoboda 1b556f5d0c [ethernet] Fix ETH_SPEED_1000M build on IDF 6.0 (enum added in 6.1) (#17311) 2026-06-30 17:17:47 -04:00
Jonathan Swoboda 8a3d0aeafb [tests] Add esp32-c61-idf base file for grouped component tests (#17293) 2026-06-30 19:33:17 +00:00
Jonathan Swoboda 9468ad628c [espnow] Drop oversized received frames to prevent buffer overflow (#17271) 2026-06-30 14:57:34 -04:00
Jonathan Swoboda 990431aa5b [bluetooth_proxy] Fix -Wtype-limits warning with active: false (#17273) 2026-06-30 14:56:30 -04:00
Jonathan Swoboda b79cbcbde7 [espidf] Install native ESP-IDF into a machine-global cache dir (#17306) 2026-06-30 14:56:18 -04:00
Bonne Eggleston afb5922f37 [modbus] Update client components to use ModbusClientDevice (#11987) 2026-06-30 13:26:51 -05:00
32 changed files with 260 additions and 51 deletions
+1 -1
View File
@@ -820,7 +820,7 @@ jobs:
run: echo ${{ matrix.components }}
- name: Cache apt packages
uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: libsdl2-dev ccache
version: 1.1
+1 -1
View File
@@ -22,7 +22,7 @@ RUN \
-r /requirements.txt
# Install the ESPHome Device Builder dashboard.
RUN uv pip install --no-cache-dir esphome-device-builder==1.0.22
RUN uv pip install --no-cache-dir esphome-device-builder==1.0.23
RUN \
platformio settings set enable_telemetry No \
+4
View File
@@ -21,6 +21,10 @@ export PLATFORMIO_PLATFORMS_DIR="${pio_cache_base}/platforms"
export PLATFORMIO_PACKAGES_DIR="${pio_cache_base}/packages"
export PLATFORMIO_CACHE_DIR="${pio_cache_base}/cache"
# Keep the native ESP-IDF install on the persistent cache root, not the
# container's ephemeral user cache dir (re-downloaded on every restart).
export ESPHOME_ESP_IDF_PREFIX="$(dirname "${pio_cache_base}")/idf"
# If /build is mounted, use that as the build path
# otherwise use path in /config (so that builds aren't lost on container restart)
if [[ -d /build ]]; then
@@ -15,6 +15,10 @@ export PLATFORMIO_PLATFORMS_DIR="${pio_cache_base}/platforms"
export PLATFORMIO_PACKAGES_DIR="${pio_cache_base}/packages"
export PLATFORMIO_CACHE_DIR="${pio_cache_base}/cache"
# Keep the native ESP-IDF install on the persistent /data volume, not the
# container's ephemeral user cache dir (wiped on every add-on update/restart).
export ESPHOME_ESP_IDF_PREFIX=/data/cache/idf
if bashio::config.true 'leave_front_door_open'; then
export DISABLE_HA_AUTHENTICATION=true
fi
+4 -1
View File
@@ -2386,7 +2386,10 @@ def parse_args(argv):
)
parser_clean_all = subparsers.add_parser(
"clean-all", help="Clean all build and platform files."
"clean-all",
help="Clean all build and platform files, including machine-global "
"toolchain caches shared by all configurations, so other projects will "
"re-download them on next build.",
)
parser_clean_all.add_argument(
"configuration", help="Your YAML file or configuration directory.", nargs="*"
@@ -68,11 +68,15 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
void loop() override;
esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override;
void register_connection(BluetoothConnection *connection) {
// maybe_unused: in a passive proxy (active: false) MAX is 0, the body below is removed, and connection is unused.
void register_connection([[maybe_unused]] BluetoothConnection *connection) {
// Guard the always-false comparison (-Wtype-limits) in a passive proxy (active: false), where MAX is 0.
#if BLUETOOTH_PROXY_MAX_CONNECTIONS > 0
if (this->connection_count_ < BLUETOOTH_PROXY_MAX_CONNECTIONS) {
this->connections_[this->connection_count_++] = connection;
connection->proxy_ = this;
}
#endif
}
void bluetooth_device_request(const api::BluetoothDeviceRequest &msg);
+11 -2
View File
@@ -94,6 +94,15 @@ void on_send_report(const uint8_t *mac_addr, esp_now_send_status_t status)
}
void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int size) {
// Drop oversized frames before copying. ESP-NOW v2 peers (IDF >= 5.4 builds a
// v2 stack with no opt-out) can send up to ESP_NOW_MAX_DATA_LEN_V2 (1470 B),
// but our receive buffer is ESP_NOW_MAX_DATA_LEN (250 B); copying a larger
// frame would overflow packet_.receive.data.
if (size < 0 || size > ESP_NOW_MAX_DATA_LEN) {
global_esp_now->receive_packet_queue_.increment_dropped_count();
return;
}
// Allocate an event from the pool
ESPNowPacket *packet = global_esp_now->receive_packet_pool_.allocate();
if (packet == nullptr) {
@@ -327,13 +336,13 @@ void ESPNowComponent::loop() {
// Log dropped received packets periodically
uint16_t received_dropped = this->receive_packet_queue_.get_and_reset_dropped_count();
if (received_dropped > 0) {
ESP_LOGW(TAG, "Dropped %u received packets due to buffer overflow", received_dropped);
ESP_LOGW(TAG, "Dropped %u received packets (queue full or oversized frame)", received_dropped);
}
// Log dropped send packets periodically
uint16_t send_dropped = this->send_packet_queue_.get_and_reset_dropped_count();
if (send_dropped > 0) {
ESP_LOGW(TAG, "Dropped %u send packets due to buffer overflow", send_dropped);
ESP_LOGW(TAG, "Dropped %u send packets (queue full)", send_dropped);
}
}
@@ -839,7 +839,7 @@ void EthernetComponent::dump_connect_params_() {
case ETH_SPEED_100M:
link_speed = 100;
break;
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 1, 0)
case ETH_SPEED_1000M:
link_speed = 1000;
break;
@@ -65,7 +65,7 @@ constexpr size_t RTU2_TODAY_PRODUCTION = 53; // length = 2
constexpr size_t RTU2_TOTAL_ENERGY_PRODUCTION = 55; // length = 2
constexpr size_t RTU2_INVERTER_MODULE_TEMP = 93; // length = 1
class GrowattSolar final : public PollingComponent, public modbus::ModbusDevice {
class GrowattSolar final : public PollingComponent, public modbus::ModbusClientDevice {
public:
void loop() override;
void update() override;
+10 -2
View File
@@ -25,6 +25,7 @@ from esphome.const import (
UNIT_VOLT,
UNIT_WATT,
)
from esphome.types import ConfigType
CONF_ENERGY_PRODUCTION_DAY = "energy_production_day"
CONF_TOTAL_ENERGY_PRODUCTION = "total_energy_production"
@@ -47,7 +48,7 @@ CODEOWNERS = ["@leeuwte"]
growatt_solar_ns = cg.esphome_ns.namespace("growatt_solar")
GrowattSolar = growatt_solar_ns.class_(
"GrowattSolar", cg.PollingComponent, modbus.ModbusDevice
"GrowattSolar", cg.PollingComponent, modbus.ModbusClientDevice
)
PHASE_SENSORS = {
@@ -162,10 +163,17 @@ CONFIG_SCHEMA = (
)
def _final_validate(config: ConfigType) -> ConfigType:
return modbus.final_validate_modbus_device("growatt_solar", role="client")(config)
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await modbus.register_modbus_device(var, config)
await modbus.register_modbus_client_device(var, config)
cg.add(var.set_protocol_version(config[CONF_PROTOCOL_VERSION]))
@@ -8,7 +8,7 @@
namespace esphome::havells_solar {
class HavellsSolar final : public PollingComponent, public modbus::ModbusDevice {
class HavellsSolar final : public PollingComponent, public modbus::ModbusClientDevice {
public:
void set_voltage_sensor(uint8_t phase, sensor::Sensor *voltage_sensor) {
this->phases_[phase].setup = true;
+10 -2
View File
@@ -28,6 +28,7 @@ from esphome.const import (
UNIT_VOLT_AMPS_REACTIVE,
UNIT_WATT,
)
from esphome.types import ConfigType
CONF_ENERGY_PRODUCTION_DAY = "energy_production_day"
CONF_TOTAL_ENERGY_PRODUCTION = "total_energy_production"
@@ -58,7 +59,7 @@ CODEOWNERS = ["@sourabhjaiswal"]
havells_solar_ns = cg.esphome_ns.namespace("havells_solar")
HavellsSolar = havells_solar_ns.class_(
"HavellsSolar", cg.PollingComponent, modbus.ModbusDevice
"HavellsSolar", cg.PollingComponent, modbus.ModbusClientDevice
)
PHASE_SENSORS = {
@@ -216,10 +217,17 @@ CONFIG_SCHEMA = (
)
def _final_validate(config: ConfigType) -> ConfigType:
return modbus.final_validate_modbus_device("havells_solar", role="client")(config)
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await modbus.register_modbus_device(var, config)
await modbus.register_modbus_client_device(var, config)
if CONF_FREQUENCY in config:
sens = await sensor.new_sensor(config[CONF_FREQUENCY])
+1 -1
View File
@@ -6,7 +6,7 @@
namespace esphome::kuntze {
class Kuntze final : public PollingComponent, public modbus::ModbusDevice {
class Kuntze final : public PollingComponent, public modbus::ModbusClientDevice {
public:
void set_ph_sensor(sensor::Sensor *ph_sensor) { ph_sensor_ = ph_sensor; }
void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; }
+10 -2
View File
@@ -15,13 +15,14 @@ from esphome.const import (
UNIT_EMPTY,
UNIT_PH,
)
from esphome.types import ConfigType
CODEOWNERS = ["@ssieb"]
AUTO_LOAD = ["modbus"]
kuntze_ns = cg.esphome_ns.namespace("kuntze")
Kuntze = kuntze_ns.class_("Kuntze", cg.PollingComponent, modbus.ModbusDevice)
Kuntze = kuntze_ns.class_("Kuntze", cg.PollingComponent, modbus.ModbusClientDevice)
CONF_DIS1 = "dis1"
CONF_DIS2 = "dis2"
@@ -88,10 +89,17 @@ CONFIG_SCHEMA = (
)
def _final_validate(config: ConfigType) -> ConfigType:
return modbus.final_validate_modbus_device("kuntze", role="client")(config)
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await modbus.register_modbus_device(var, config)
await modbus.register_modbus_client_device(var, config)
if CONF_PH in config:
conf = config[CONF_PH]
+8
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import logging
from typing import Literal
from esphome import pins
@@ -10,6 +11,8 @@ from esphome.const import CONF_ADDRESS, CONF_DISABLE_CRC, CONF_FLOW_CONTROL_PIN,
from esphome.cpp_helpers import gpio_pin_expression
import esphome.final_validate as fv
_LOGGER = logging.getLogger(__name__)
DEPENDENCIES = ["uart"]
modbus_ns = cg.esphome_ns.namespace("modbus")
@@ -129,4 +132,9 @@ async def register_modbus_server_device(var, config):
async def register_modbus_device(var, config):
# Remove before 2026.12.0
_LOGGER.warning(
"'register_modbus_device' is deprecated, use 'register_modbus_client_device' "
"instead. Will be removed in 2026.12.0"
)
return await register_modbus_client_device(var, config)
+3 -1
View File
@@ -197,7 +197,9 @@ class ModbusClientDevice {
};
// This is for compatibility with external components using the former class name
using ModbusDevice = ModbusClientDevice;
// Remove before 2026.12.0
using ModbusDevice ESPDEPRECATED("Use ModbusClientDevice instead. Removed in 2026.12.0",
"2026.6.0") = ModbusClientDevice;
// Result of a server register handler: std::nullopt means success, otherwise the Modbus exception code to return.
using ServerResponseStatus = std::optional<ModbusExceptionCode>;
@@ -11,6 +11,7 @@ from esphome.components.modbus.helpers import (
import esphome.config_validation as cv
from esphome.const import CONF_ADDRESS, CONF_ID, CONF_LAMBDA, CONF_NAME, CONF_OFFSET
from esphome.cpp_helpers import logging
from esphome.types import ConfigType
from .const import (
CONF_ALLOW_DUPLICATE_COMMANDS,
@@ -42,7 +43,7 @@ MULTI_CONF = True
modbus_controller_ns = cg.esphome_ns.namespace("modbus_controller")
ModbusController = modbus_controller_ns.class_(
"ModbusController", cg.PollingComponent, modbus.ModbusDevice
"ModbusController", cg.PollingComponent, modbus.ModbusClientDevice
)
SensorItem = modbus_controller_ns.struct("SensorItem")
@@ -117,7 +118,7 @@ def validate_modbus_register(config):
return config
def _final_validate(config):
def _final_validate(config: ConfigType) -> ConfigType:
return modbus.final_validate_modbus_device("modbus_controller", role="client")(
config
)
@@ -211,7 +212,7 @@ async def to_code(config):
async def register_modbus_device(var, config):
cg.add(var.set_address(config[CONF_ADDRESS]))
await cg.register_component(var, config)
return await modbus.register_modbus_device(var, config)
return await modbus.register_modbus_client_device(var, config)
def function_code_to_register(function_code):
+1 -1
View File
@@ -11,7 +11,7 @@ namespace esphome::pzemac {
template<typename... Ts> class ResetEnergyAction;
class PZEMAC final : public PollingComponent, public modbus::ModbusDevice {
class PZEMAC final : public PollingComponent, public modbus::ModbusClientDevice {
public:
void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; }
void set_current_sensor(sensor::Sensor *current_sensor) { current_sensor_ = current_sensor; }
+10 -2
View File
@@ -26,11 +26,12 @@ from esphome.const import (
UNIT_WATT,
UNIT_WATT_HOURS,
)
from esphome.types import ConfigType
AUTO_LOAD = ["modbus"]
pzemac_ns = cg.esphome_ns.namespace("pzemac")
PZEMAC = pzemac_ns.class_("PZEMAC", cg.PollingComponent, modbus.ModbusDevice)
PZEMAC = pzemac_ns.class_("PZEMAC", cg.PollingComponent, modbus.ModbusClientDevice)
# Actions
ResetEnergyAction = pzemac_ns.class_("ResetEnergyAction", automation.Action)
@@ -97,10 +98,17 @@ async def reset_energy_to_code(config, action_id, template_arg, args):
return cg.new_Pvariable(action_id, template_arg, paren)
def _final_validate(config: ConfigType) -> ConfigType:
return modbus.final_validate_modbus_device("pzemac", role="client")(config)
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await modbus.register_modbus_device(var, config)
await modbus.register_modbus_client_device(var, config)
if CONF_VOLTAGE in config:
conf = config[CONF_VOLTAGE]
+1 -1
View File
@@ -9,7 +9,7 @@
namespace esphome::pzemdc {
class PZEMDC final : public PollingComponent, public modbus::ModbusDevice {
class PZEMDC final : public PollingComponent, public modbus::ModbusClientDevice {
public:
void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; }
void set_current_sensor(sensor::Sensor *current_sensor) { current_sensor_ = current_sensor; }
+10 -2
View File
@@ -20,11 +20,12 @@ from esphome.const import (
UNIT_VOLT,
UNIT_WATT,
)
from esphome.types import ConfigType
AUTO_LOAD = ["modbus"]
pzemdc_ns = cg.esphome_ns.namespace("pzemdc")
PZEMDC = pzemdc_ns.class_("PZEMDC", cg.PollingComponent, modbus.ModbusDevice)
PZEMDC = pzemdc_ns.class_("PZEMDC", cg.PollingComponent, modbus.ModbusClientDevice)
# Actions
ResetEnergyAction = pzemdc_ns.class_("ResetEnergyAction", automation.Action)
@@ -79,10 +80,17 @@ async def reset_energy_to_code(config, action_id, template_arg, args):
return cg.new_Pvariable(action_id, template_arg, paren)
def _final_validate(config: ConfigType) -> ConfigType:
return modbus.final_validate_modbus_device("pzemdc", role="client")(config)
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await modbus.register_modbus_device(var, config)
await modbus.register_modbus_client_device(var, config)
if CONF_VOLTAGE in config:
conf = config[CONF_VOLTAGE]
+1 -1
View File
@@ -8,7 +8,7 @@
namespace esphome::sdm_meter {
class SDMMeter final : public PollingComponent, public modbus::ModbusDevice {
class SDMMeter final : public PollingComponent, public modbus::ModbusClientDevice {
public:
void set_voltage_sensor(uint8_t phase, sensor::Sensor *voltage_sensor) {
this->phases_[phase].setup = true;
+12 -2
View File
@@ -41,12 +41,15 @@ from esphome.const import (
UNIT_VOLT_AMPS_REACTIVE,
UNIT_WATT,
)
from esphome.types import ConfigType
AUTO_LOAD = ["modbus"]
CODEOWNERS = ["@polyfaces", "@jesserockz"]
sdm_meter_ns = cg.esphome_ns.namespace("sdm_meter")
SDMMeter = sdm_meter_ns.class_("SDMMeter", cg.PollingComponent, modbus.ModbusDevice)
SDMMeter = sdm_meter_ns.class_(
"SDMMeter", cg.PollingComponent, modbus.ModbusClientDevice
)
PHASE_SENSORS = {
CONF_VOLTAGE: sensor.sensor_schema(
@@ -145,10 +148,17 @@ CONFIG_SCHEMA = (
)
def _final_validate(config: ConfigType) -> ConfigType:
return modbus.final_validate_modbus_device("sdm_meter", role="client")(config)
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await modbus.register_modbus_device(var, config)
await modbus.register_modbus_client_device(var, config)
if CONF_TOTAL_POWER in config:
sens = await sensor.new_sensor(config[CONF_TOTAL_POWER])
+1 -1
View File
@@ -15,7 +15,7 @@ namespace esphome::selec_meter {
public: \
void set_##name##_sensor(sensor::Sensor *(name)) { this->name##_sensor_ = name; }
class SelecMeter final : public PollingComponent, public modbus::ModbusDevice {
class SelecMeter final : public PollingComponent, public modbus::ModbusClientDevice {
public:
SELEC_METER_SENSOR(total_active_energy)
SELEC_METER_SENSOR(import_active_energy)
+10 -2
View File
@@ -32,6 +32,7 @@ from esphome.const import (
UNIT_VOLT_AMPS_REACTIVE,
UNIT_WATT,
)
from esphome.types import ConfigType
AUTO_LOAD = ["modbus"]
CODEOWNERS = ["@sourabhjaiswal"]
@@ -49,7 +50,7 @@ UNIT_KILOVOLT_AMPS_REACTIVE_HOURS = "kVARh"
selec_meter_ns = cg.esphome_ns.namespace("selec_meter")
SelecMeter = selec_meter_ns.class_(
"SelecMeter", cg.PollingComponent, modbus.ModbusDevice
"SelecMeter", cg.PollingComponent, modbus.ModbusClientDevice
)
SENSORS = {
@@ -163,10 +164,17 @@ CONFIG_SCHEMA = (
)
def _final_validate(config: ConfigType) -> ConfigType:
return modbus.final_validate_modbus_device("selec_meter", role="client")(config)
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await modbus.register_modbus_device(var, config)
await modbus.register_modbus_client_device(var, config)
for name in SENSORS:
if name in config:
sens = await sensor.new_sensor(config[name])
+3 -3
View File
@@ -147,9 +147,9 @@ def _setup_core(work_dir: Path, settings: _Settings) -> None:
from esphome.core import CORE
CORE.name = TIDY_PROJECT_NAME
# config_path's parent is the data dir root: the IDF install lives at
# ``<parent>/.esphome/idf`` -- keep it beside (not inside) the per-run
# project dir so clearing the project doesn't force an IDF re-download.
# config_path's parent is the data dir root for per-run artifacts (idedata,
# converted pio_components). The IDF install is in the global cache dir,
# independent of this path.
CORE.config_path = work_dir.parent / "tidy.yaml"
CORE.build_path = work_dir
esp32 = CORE.data.setdefault(KEY_ESP32, {})
+22 -13
View File
@@ -9,6 +9,8 @@ import re
import shutil
import tempfile
import platformdirs
from esphome.config_validation import Version
from esphome.core import CORE
from esphome.framework_helpers import (
@@ -80,10 +82,18 @@ def _get_idf_tools_path() -> Path:
Returns:
Path object pointing to the ESP-IDF tools directory
"""
if "ESPHOME_ESP_IDF_PREFIX" in os.environ:
path = Path(get_str_env("ESPHOME_ESP_IDF_PREFIX", None)).expanduser()
# Treat an empty/whitespace ESPHOME_ESP_IDF_PREFIX as unset: Path("")
# resolves to the CWD, which would install into (and let clean-all delete)
# the working directory by accident.
if prefix := get_str_env("ESPHOME_ESP_IDF_PREFIX", "").strip():
path = Path(prefix).expanduser()
else:
path = CORE.data_dir / "idf"
# Machine-global so all projects share the multi-GB install instead of
# a per-config-directory copy. The user cache dir (not ~/.esphome)
# avoids colliding with data_dir when configs live in the home dir.
# appauthor=False drops the redundant <author>\ segment on Windows
# (which otherwise repeats "esphome\esphome\") to keep the path short.
path = Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "idf"
# Resolve so an unnormalized config path (e.g. compiling ``../config/x.yaml``)
# doesn't leave ``..`` segments in the IDF_TOOLS_PATH handed to idf.py, which
# otherwise warns that the venv interpreter path doesn't match the install.
@@ -145,10 +155,11 @@ def _check_windows_path_length() -> None:
" fatal error: bits/c++config.h: No such file or directory\n"
" cannot execute 'as': CreateProcess: No such file or directory\n"
"To fix, either:\n"
" - Enable Windows long path support: set\n"
" HKLM\\SYSTEM\\CurrentControlSet\\Control\\FileSystem\\LongPathsEnabled\n"
" to 1 and reboot, or\n"
" - Move your ESPHome project to a shorter path\n"
" - Enable Windows long path support, then reboot. In an elevated\n"
" PowerShell run:\n"
" Set-ItemProperty 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\FileSystem' LongPathsEnabled 1\n"
" Details: https://learn.microsoft.com/windows/win32/fileio/maximum-file-path-limitation\n"
" - Or set ESPHOME_ESP_IDF_PREFIX to a shorter path (e.g. C:\\ESPHome\\idf)\n"
"Then delete the ESP-IDF tools directory above so the toolchain "
"reinstalls cleanly.",
tools_path,
@@ -553,7 +564,7 @@ def _check_esphome_idf_framework_install(
# Logged every invocation (not just on install) so the user can verify the
# override. A changed URL needs ``esphome clean-all`` to force a re-download
# (``esphome clean`` only wipes the build dir, not the extracted framework
# under <data_dir>/idf/frameworks/<version>).
# under the global install dir's ``frameworks/<version>``).
if source_url:
_LOGGER.info("Using framework source override: %s", source_url)
@@ -822,11 +833,9 @@ def _ccache_env() -> dict[str, str]:
Enabled by default whenever the ``ccache`` binary is on PATH; set
``IDF_CCACHE_ENABLE=0`` in the environment to opt out. The cache lives under
the IDF tools path. How widely it is shared depends on where that resolves:
across projects (and surviving ``clean-all``) when it is a common location
(``ESPHOME_ESP_IDF_PREFIX`` or the add-on ``/data``), but per-project under
``.esphome/idf`` for a default pip install, where ``clean-all`` clears it
along with the framework.
the IDF tools path (the machine-global cache dir, or
``ESPHOME_ESP_IDF_PREFIX``), so it is shared across all projects and removed
by ``esphome clean-all`` along with the framework.
Depend mode keeps cache-miss overhead low (hashes the compiler's depfiles
instead of preprocessing). ``CCACHE_BASEDIR`` rewrites the per-build
+9
View File
@@ -653,6 +653,15 @@ def clean_all(configuration: list[str]):
elif item.is_dir() and item.name != "storage":
rmtree(item)
# The native ESP-IDF install lives in a machine-global cache dir, outside
# any .esphome data dir, so the per-config loop above won't reach it.
from esphome.espidf.framework import _get_idf_tools_path
idf_install_path = _get_idf_tools_path()
if idf_install_path.is_dir():
_LOGGER.info("Deleting %s", idf_install_path)
rmtree(idf_install_path)
# Clean PlatformIO project files
try:
from platformio.project.config import ProjectConfig
+1
View File
@@ -23,6 +23,7 @@ bleak==2.1.1
smpclient==6.0.0
requests==2.34.2
py7zr==1.1.3
platformdirs==4.10.0 # native esp-idf toolchain global cache dir
# esp-idf >= 5.0 requires this
pyparsing >= 3.3.2
@@ -0,0 +1,18 @@
esphome:
name: componenttestesp32c61idf
friendly_name: $component_name
esp32:
variant: ESP32C61
flash_size: 8MB
framework:
type: esp-idf
logger:
level: VERY_VERBOSE
packages:
component_under_test: !include
file: $component_test_file
vars:
component_test_file: $component_test_file
+47
View File
@@ -36,6 +36,19 @@ from esphome.espidf.framework import (
from esphome.framework_helpers import _tar_extract_all, get_python_env_executable_path
@pytest.fixture(autouse=True)
def _isolate_idf_install_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Pin the ESP-IDF install root to a tmp dir for every test.
The default location is the OS user cache dir, so without this any test
that builds framework paths or pre-creates the framework dir would touch
the real ``~/.cache/esphome`` on the developer's machine. Tests that need
to exercise the override or default-resolution logic clear/override the env
themselves.
"""
monkeypatch.setenv("ESPHOME_ESP_IDF_PREFIX", str(tmp_path / "idf_install"))
@pytest.mark.parametrize(
("source", "expected"),
[
@@ -791,6 +804,38 @@ def test_get_idf_tools_path_env_override(tmp_path: Path) -> None:
assert _get_idf_tools_path() == Path(override)
@pytest.mark.parametrize("value", ["", " "])
def test_get_idf_tools_path_blank_env_falls_back_to_default(
value: str, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A blank ESPHOME_ESP_IDF_PREFIX is treated as unset, not as CWD.
Path("") would resolve to the working directory, which clean-all could then
delete by accident.
"""
import platformdirs
monkeypatch.setenv("ESPHOME_ESP_IDF_PREFIX", value)
expected = (
Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "idf"
).resolve()
assert _get_idf_tools_path() == expected
def test_get_idf_tools_path_default_uses_user_cache(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Without the env override the install root is the machine-global OS user
cache dir, not the per-config ``<data_dir>/idf``."""
import platformdirs
monkeypatch.delenv("ESPHOME_ESP_IDF_PREFIX", raising=False)
expected = (
Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "idf"
).resolve()
assert _get_idf_tools_path() == expected
def test_write_idf_version_txt_warns_on_write_error(tmp_path: Path) -> None:
with patch("pathlib.Path.write_text", side_effect=OSError("denied")):
# write failure is caught and warned, not raised
@@ -908,3 +953,5 @@ def test_check_windows_path_length_long_path_warns(
message = caplog.records[0].getMessage()
assert _LONG_IDF_PATH in message
assert "long path support" in message
# The install is global now; the remedy is the prefix env, not moving the project.
assert "ESPHOME_ESP_IDF_PREFIX" in message
+35 -3
View File
@@ -67,15 +67,23 @@ def _isolate_platformio_paths(tmp_path_factory: pytest.TempPathFactory) -> Any:
want to verify the PIO-cleanup branch (e.g. test_clean_all,
test_clean_all_partial_exists) install their own inner patch which
stacks on top of this one and wins for the duration of their block.
Also pin ``ESPHOME_ESP_IDF_PREFIX`` to a nonexistent tmp dir for the
same reason: ``clean_all`` removes the now machine-global ESP-IDF
install, which otherwise defaults to the real ``~/.cache/esphome``.
"""
pio_root = tmp_path_factory.mktemp("isolated_pio") / "nonexistent"
idf_root = tmp_path_factory.mktemp("isolated_idf") / "nonexistent"
mock_cfg = MagicMock()
mock_cfg.get.side_effect = lambda section, option: (
str(pio_root / option) if section == "platformio" else ""
)
with patch(
"platformio.project.config.ProjectConfig.get_instance",
return_value=mock_cfg,
with (
patch(
"platformio.project.config.ProjectConfig.get_instance",
return_value=mock_cfg,
),
patch.dict("os.environ", {"ESPHOME_ESP_IDF_PREFIX": str(idf_root)}),
):
yield
@@ -990,6 +998,30 @@ def test_clean_all_with_yaml_file(
assert str(build_dir) in caplog.text
@patch("esphome.writer.CORE")
def test_clean_all_removes_global_idf_install(
mock_core: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""clean_all removes the machine-global native ESP-IDF install dir."""
idf_install = tmp_path / "idf_install"
(idf_install / "frameworks").mkdir(parents=True)
monkeypatch.setenv("ESPHOME_ESP_IDF_PREFIX", str(idf_install))
config_dir = tmp_path / "config"
config_dir.mkdir()
from esphome.writer import clean_all
with caplog.at_level("INFO"):
clean_all([str(config_dir)])
assert not idf_install.exists()
assert str(idf_install.resolve()) in caplog.text
@patch("esphome.writer.CORE")
def test_clean_all_with_yaml_build_path(
mock_core: MagicMock,