Compare commits

...

66 Commits

Author SHA1 Message Date
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
Jesse Hills 0cbbd64577 Merge pull request #17223 from esphome/bump-2026.6.3
2026.6.3
2026-06-29 22:31:44 +12:00
Jesse Hills a618ee11b4 Bump version to 2026.6.3 2026-06-29 20:30:24 +12:00
Tom 6251c26cc6 [espnow] Fix espnow crash when send() is called without a callback (#17266) 2026-06-29 20:30:24 +12:00
Jonathan Swoboda 4fbe0d87ec [wifi] Fix crash when WiFi is enabled late alongside ESP-NOW (#17239) 2026-06-29 20:30:24 +12:00
esphome[bot] 24d8e99c50 Bump bundled esphome-device-builder to 1.0.21 (#17257)
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
2026-06-29 20:30:24 +12:00
Jonathan Swoboda 14b6a0ede1 [espnow] Don't throttle ESP-NOW RX when deep_sleep is present (#17240) 2026-06-29 20:30:24 +12:00
Jonathan Swoboda 1793ca5eac [core] Suppress unactionable legacy-redaction warning for substitutions (#17242) 2026-06-29 20:30:24 +12:00
esphome[bot] 62e19bcb27 Bump bundled esphome-device-builder to 1.0.20 (#17244) 2026-06-29 20:30:24 +12:00
Franck Nijhof 84d1c34c28 [core] Fix area saved as null in storage.json (#17219)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-06-29 20:30:24 +12:00
esphome[bot] f78cbf9200 Bump bundled esphome-device-builder to 1.0.19 (#17217)
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
2026-06-29 20:30:23 +12:00
esphome[bot] eb711381d3 Bump bundled esphome-device-builder to 1.0.18 (#17212)
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
2026-06-29 20:30:23 +12:00
Jonathan Swoboda 9a1daa5247 [hbridge] Fix light stuck on one polarity (#17162) 2026-06-29 20:30:18 +12:00
Clyde Stubbs f3d61ca3e1 [mipi][mipi_spi] Swap native dimensions for swap_xy hardware transform (#17201)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 20:29:44 +12:00
Jonathan Swoboda 29dfd820c6 [wifi] Report STA IP, not SoftAP IP, in wifi_info on ESP8266 (#17185) 2026-06-29 20:29:44 +12:00
Jonathan Swoboda 8bc5b97298 [network] Set IPv4 type tag on all lwIP platforms, not just esp32 (#17200) 2026-06-29 20:29:44 +12:00
Jonathan Swoboda 7a64163c4f [esp32] Accept '#' as ESP-IDF source ref separator (#17193) 2026-06-29 20:29:44 +12:00
esphome[bot] dfe14f9c3a Bump bundled esphome-device-builder to 1.0.17 (#17199) 2026-06-29 20:29:44 +12:00
esphome[bot] 26cf373ae7 Bump bundled esphome-device-builder to 1.0.16 (#17182) 2026-06-29 20:29:44 +12:00
Geoffrey Frogeye 94ccddf176 [opentherm] Support power scaling disabled (#17183)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-06-29 20:29:44 +12:00
Clyde Stubbs 2ec24505d0 [mipi_spi] Suppress sequence errors when page selection used (#17176) 2026-06-29 20:29:44 +12:00
esphome[bot] 4f7faa7712 Bump bundled esphome-device-builder to 1.0.15 (#17170) 2026-06-29 20:29:44 +12:00
Clyde Stubbs b3dcaac262 [mipi_spi] Warn on MODE3 default for display without CS pin (#17153) 2026-06-29 20:29:44 +12:00
mnewton25 ee118d384a [esp32] Use POSIX path for secure-boot signing/verification keys Fixes #17164 (#17166)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-06-29 20:29:44 +12:00
Jonathan Swoboda 8d36167e11 [esp32_ble_server] Fix set_value action with by-reference triggers (#17156) 2026-06-29 20:29:44 +12:00
esphome[bot] 6d559a32df Bump bundled esphome-device-builder to 1.0.14 (#17139) 2026-06-29 20:29:37 +12:00
Jonathan Swoboda bf0d31b3ab [espidf] Don't fail framework check on broken unrelated PATH tools (#17053) 2026-06-29 20:23:23 +12:00
dependabot[bot] d8ffb732b7 Bump zeroconf from 0.149.16 to 0.150.0 (#17137)
Signed-off-by: dependabot[bot] <support@github.com>
2026-06-29 20:23:23 +12:00
Jonathan Swoboda 9ab2a573ab Merge pull request #17093 from esphome/bump-2026.6.2
2026.6.2
2026-06-20 14:17:55 -04:00
Jonathan Swoboda 99d1c4eb69 Bump version to 2026.6.2 2026-06-20 13:33:41 -04:00
esphome[bot] b079be756f Bump bundled esphome-device-builder to 1.0.12 (#17091) 2026-06-20 13:33:41 -04:00
J. Nick Koston 039a1f063e [ha-addon] Expose the device-builder public port only when port 6052 is mapped (#17076) 2026-06-20 13:33:41 -04:00
esphome[bot] 2354165e41 Bump bundled esphome-device-builder to 1.0.11 (#17081) 2026-06-20 13:33:41 -04:00
Jonathan Swoboda f5697b0ae5 [packet_transport] Mark encryption key as cv.sensitive (#17066) 2026-06-20 13:33:41 -04:00
Jonathan Swoboda fe794a26e8 [fastled_base] Fix RMT5 intr_priority conflict (#17072) 2026-06-20 13:33:41 -04:00
Jonathan Swoboda 8d77051b9a [espidf] Resolve IDF tools path to avoid unnormalized path warning (#17055) 2026-06-20 13:33:41 -04:00
Jesse Hills 9534ab2a19 Merge pull request #17052 from esphome/bump-2026.6.1
2026.6.1
2026-06-19 11:35:03 +12:00
Jesse Hills 1b1c8d767d Bump version to 2026.6.1 2026-06-19 10:06:13 +12:00
esphome[bot] e3d68deef9 Bump bundled esphome-device-builder to 1.0.10 (#17051) 2026-06-19 10:06:13 +12:00
J. Nick Koston 20cd6a1771 [logger] Hold recursion guard while draining the task log buffer (#17044) 2026-06-19 10:06:13 +12:00
Jonathan Swoboda d27229a1c7 [esp32] Don't overwrite PlatformIO's factory.bin (#17042) 2026-06-19 10:06:13 +12:00
Jonathan Swoboda 129aebe8f4 [esp32] Support esphome idedata with the native ESP-IDF toolchain (#17040) 2026-06-19 10:06:13 +12:00
Jonathan Swoboda a84ad7b1f8 [uptime] Revert timestamp sensor device_class to timestamp (#17037) 2026-06-19 10:06:13 +12:00
Jonathan Swoboda 86096b96f5 [build] Skip target-platform deps when populating host unit-test config (#17039) 2026-06-19 10:06:13 +12:00
J. Nick Koston ac5a28301a [core] Honor transferred address cache in has_resolvable_address (#17025)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-19 10:06:13 +12:00
Jesse Hills e2157a3d26 Merge pull request #17022 from esphome/bump-2026.6.0
2026.6.0
2026-06-18 12:59:50 +12:00
esphome[bot] d934fb3910 Bump bundled esphome-device-builder to 1.0.9 (#17021)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-06-18 10:46:22 +12:00
esphome[bot] c4076ec8a9 Bump bundled esphome-device-builder to 1.0.8 (#17020) 2026-06-18 10:46:12 +12:00
esphome[bot] 9ac22f9244 Bump bundled esphome-device-builder to 1.0.7 (#17018) 2026-06-18 10:46:06 +12:00
Jesse Hills 9e7b3e0330 Bump version to 2026.6.0 2026-06-18 10:18:37 +12:00
Jesse Hills 2abe272867 Merge pull request #17017 from esphome/bump-2026.6.0b4
2026.6.0b4
2026-06-18 10:08:15 +12:00
Jesse Hills db6b9166f4 Bump version to 2026.6.0b4 2026-06-18 08:20:15 +12:00
esphome[bot] 7ab95ddcb1 Bump bundled esphome-device-builder to 1.0.6 (#17016) 2026-06-18 08:20:02 +12:00
esphome[bot] cdd2bfbc60 Bump bundled esphome-device-builder to 1.0.4 (#17013) 2026-06-18 08:19:05 +12:00
esphome[bot] 41f7f8cccb Bump bundled esphome-device-builder to 1.0.3 (#17005) 2026-06-18 08:19:05 +12:00
Jonathan Swoboda 045de436ba [ota] Scale ESP-IDF OTA erase watchdog to image size (#16998) 2026-06-18 08:19:05 +12:00
Jonathan Swoboda 24e276c3f9 [esp32_hosted] Bump esp_hosted to 2.12.9 (#16999) 2026-06-18 08:18:25 +12:00
49 changed files with 1103 additions and 146 deletions
+1 -1
View File
@@ -1 +1 @@
34f6ce4a4775acf8c7201778f114b191f78269f232b67f01fed920f0cdf73686
72f02816e288b68ff4ef4b3d6fb66432c893b187a80ad3ebaa29afa443ff9ea6
+1 -1
View File
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
# could be handy for archiving the generated documentation or if some version
# control system is used.
PROJECT_NUMBER = 2026.6.0b3
PROJECT_NUMBER = 2026.6.4
# Using the PROJECT_BRIEF tag one can provide an optional one line description
# for a project that appears at the top of each page and should give viewer a
+1 -1
View File
@@ -32,7 +32,7 @@ RUN \
-r /requirements.txt
# Install the ESPHome Device Builder dashboard.
RUN uv pip install --no-cache-dir esphome-device-builder==1.0.1
RUN uv pip install --no-cache-dir esphome-device-builder==1.0.23
RUN \
platformio settings set enable_telemetry No \
@@ -49,7 +49,21 @@ if bashio::fs.directory_exists '/config/esphome/.esphome'; then
rm -rf /config/esphome/.esphome
fi
# Only signal device-builder to expose the public LAN port when the operator
# mapped port 6052, matching the legacy dashboard where nginx listened on the
# fixed port 6052 only when it was configured. We use the mapping purely as a
# presence check and don't forward the published value; device-builder binds
# its default port 6052 (the fixed container port, as the legacy
# "listen 6052" did). --ha-addon-allow-public is inert on its own: the no-auth
# gate is the DISABLE_HA_AUTHENTICATION env var set above, so both opt-ins are
# required to bind 6052 unauthenticated; either alone stays ingress-only.
set --
if bashio::var.has_value "$(bashio::addon.port 6052)"; then
set -- --ha-addon-allow-public
fi
bashio::log.info "Starting ESPHome Device Builder..."
exec esphome-device-builder /config/esphome \
--ha-addon \
--ingress-port "$(bashio::addon.ingress_port)"
--ingress-port "$(bashio::addon.ingress_port)" \
"$@"
+43 -5
View File
@@ -504,6 +504,12 @@ def has_resolvable_address() -> bool:
if has_ip_address():
return True
# The dashboard pre-resolves the device and passes the IPs via
# --mdns-address-cache/--dns-address-cache; honor a cached address even when the
# device has mDNS disabled (e.g. a .local host found via ping).
if CORE.address_cache and CORE.address_cache.get_addresses(CORE.address):
return True
if has_mdns():
return True
@@ -1464,12 +1470,29 @@ _LEGACY_REDACTION_REMOVAL = "2026.12.0"
def _redact_with_legacy_fallback(output: str) -> str:
unmarked: set[str] = set()
# Track the top-level ``substitutions:`` block. Its keys are arbitrary
# user-chosen names with no schema validator, so the ``cv.sensitive(...)``
# migration named in the warning can't be applied to them. Their values are
# still redacted, but emitting the (unactionable) deprecation warning would
# only confuse users.
in_substitutions = False
def _replace(m: re.Match[str]) -> str:
unmarked.add(m.group("key"))
return f"{m.group('key')}: \\033[8m{m.group('val')}\\033[28m"
output = _LEGACY_REDACTION_RE.sub(_replace, output)
lines = output.split("\n")
for i, line in enumerate(lines):
# A non-indented, non-blank line is a top-level key that opens or
# closes the substitutions block.
if line and not line[0].isspace():
in_substitutions = line.startswith(f"{CONF_SUBSTITUTIONS}:")
m = _LEGACY_REDACTION_RE.search(line)
if m is None:
continue
if not in_substitutions:
unmarked.add(m.group("key"))
lines[i] = (
f"{line[: m.start()]}{m.group('key')}: "
f"\\033[8m{m.group('val')}\\033[28m{line[m.end() :]}"
)
output = "\n".join(lines)
for key in sorted(unmarked):
_LOGGER.warning(
"Field '%s' is being redacted by a legacy substring heuristic. "
@@ -1765,6 +1788,21 @@ def command_update_all(args: ArgsProtocol) -> int | None:
def command_idedata(args: ArgsProtocol, config: ConfigType) -> int:
import json
if CORE.using_toolchain_esp_idf:
# Native ESP-IDF derives idedata from the build's compile_commands.json,
# so the configuration must already be compiled.
from esphome.espidf import toolchain as espidf_toolchain
idedata = espidf_toolchain.get_idedata()
if idedata is None:
_LOGGER.error(
"No idedata available; compile the configuration first",
)
return 1
print(json.dumps(idedata, indent=2) + "\n")
return 0
if not CORE.using_toolchain_platformio:
_LOGGER.error(
"The idedata command is not compatible with %s toolchain",
+8 -5
View File
@@ -66,15 +66,18 @@ float ADCSensor::sample() {
}
uint8_t pin = this->pin_->get_pin();
#ifdef CYW43_USES_VSYS_PIN
#if defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI)
if (pin == PICO_VSYS_PIN) {
// Measuring VSYS on Raspberry Pico W needs to be wrapped with
// `cyw43_thread_enter()`/`cyw43_thread_exit()` as discussed in
// https://github.com/raspberrypi/pico-sdk/issues/1222, since Wifi chip and
// VSYS ADC both share GPIO29
// VSYS ADC both share GPIO29.
// The USE_WIFI guard is required because CYW43_USES_VSYS_PIN can be defined
// transitively (e.g. via lwip_wrap.h) even on non-WiFi boards where the CYW43
// driver is never initialized; calling cyw43_thread_enter() there hard-faults.
cyw43_thread_enter();
}
#endif // CYW43_USES_VSYS_PIN
#endif // defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI)
adc_gpio_init(pin);
adc_select_input(pin - 26);
@@ -84,11 +87,11 @@ float ADCSensor::sample() {
aggr.add_sample(raw);
}
#ifdef CYW43_USES_VSYS_PIN
#if defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI)
if (pin == PICO_VSYS_PIN) {
cyw43_thread_exit();
}
#endif // CYW43_USES_VSYS_PIN
#endif // defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI)
if (this->output_raw_) {
return aggr.aggregate();
@@ -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);
+7 -1
View File
@@ -173,8 +173,14 @@ bool ES8388::set_mute_state_(bool mute_state) {
ES8388_ERROR_CHECK(this->read_byte(ES8388_DACCONTROL3, &value));
ESP_LOGV(TAG, "Read ES8388_DACCONTROL3: 0x%02X", value);
// Only toggle the DACMute bit; the other bits of this register hold unrelated
// DAC settings that must be preserved. Previously muting overwrote the whole
// register with 0x3C and unmuting never cleared the bit, so once muted the DAC
// could not be unmuted again.
if (mute_state) {
value = 0x3C;
value |= ES8388_DACCONTROL3_DAC_MUTE;
} else {
value &= ~ES8388_DACCONTROL3_DAC_MUTE;
}
ESP_LOGV(TAG, "Setting ES8388_DACCONTROL3 to 0x%02X (muted: %s)", value, YESNO(mute_state));
+1
View File
@@ -38,6 +38,7 @@ static const uint8_t ES8388_ADCCONTROL14 = 0x16;
static const uint8_t ES8388_DACCONTROL1 = 0x17;
static const uint8_t ES8388_DACCONTROL2 = 0x18;
static const uint8_t ES8388_DACCONTROL3 = 0x19;
static const uint8_t ES8388_DACCONTROL3_DAC_MUTE = 0x04; // DACMute, bit 2 of DACCONTROL3
static const uint8_t ES8388_DACCONTROL4 = 0x1a;
static const uint8_t ES8388_DACCONTROL5 = 0x1b;
static const uint8_t ES8388_DACCONTROL6 = 0x1c;
+2 -2
View File
@@ -2316,14 +2316,14 @@ async def to_code(config):
add_idf_sdkconfig_option("CONFIG_SECURE_BOOT_BUILD_SIGNED_BINARIES", True)
add_idf_sdkconfig_option(
"CONFIG_SECURE_BOOT_SIGNING_KEY",
str(signed_ota[CONF_SIGNING_KEY].resolve()),
signed_ota[CONF_SIGNING_KEY].resolve().as_posix(),
)
else:
# Public key mode — verification only, external signing required
add_idf_sdkconfig_option("CONFIG_SECURE_BOOT_BUILD_SIGNED_BINARIES", False)
add_idf_sdkconfig_option(
"CONFIG_SECURE_BOOT_VERIFICATION_KEY",
str(signed_ota[CONF_VERIFICATION_KEY].resolve()),
signed_ota[CONF_VERIFICATION_KEY].resolve().as_posix(),
)
cg.add_define("USE_OTA_SIGNED_VERIFICATION")
+11 -1
View File
@@ -224,6 +224,17 @@ def merge_factory_bin(source, target, env):
flash_size = env.BoardConfig().get("upload.flash_size", "4MB")
chip = env.BoardConfig().get("build.mcu", "esp32")
# PlatformIO's esp-idf builder already creates a correct firmware.factory.bin (right
# artifact names and partition offsets, including custom partition tables). The merge
# below is only a fallback and cannot honor custom layouts, so don't overwrite an image
# PlatformIO already produced. Post-build actions only run when firmware.bin is rebuilt,
# and PlatformIO's combined-image builder runs before us in that batch, so an existing
# file here is current.
output_path = firmware_path.with_suffix(".factory.bin")
if output_path.exists():
print(f"{output_path.name} already created by PlatformIO - skipping merge")
return
sections = []
flasher_args_path = build_dir / "flasher_args.json"
@@ -291,7 +302,6 @@ def merge_factory_bin(source, target, env):
print("No valid flash sections found — skipping .factory.bin creation.")
return
output_path = firmware_path.with_suffix(".factory.bin")
python_exe = f'"{env.subst("$PYTHONEXE")}"'
cmd = [
python_exe,
@@ -77,13 +77,15 @@ template<typename... Ts> class BLECharacteristicSetValueAction : public Action<T
// Set initial value
this->parent_->set_value(this->buffer_.value(x...));
// Set the listener for read events
this->parent_->on_read([this, x...](uint16_t id) {
// ``mutable`` keeps by-copy captures non-const for triggers passing args by reference
// (e.g. climate on_control's ClimateCall&). See #17142.
this->parent_->on_read([this, x...](uint16_t id) mutable {
// Set the value of the characteristic every time it is read
this->parent_->set_value(this->buffer_.value(x...));
});
// Set the listener in the global manager so only one BLECharacteristicSetValueAction is set for each characteristic
BLECharacteristicSetValueActionManager::get_instance()->set_listener(
this->parent_, [this, x...]() { this->parent_->set_value(this->buffer_.value(x...)); });
this->parent_, [this, x...]() mutable { this->parent_->set_value(this->buffer_.value(x...)); });
}
protected:
+1 -1
View File
@@ -257,7 +257,7 @@ async def to_code(config):
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.5.1")
esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.2")
esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5")
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.8")
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.9")
else:
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0")
esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0")
+14 -11
View File
@@ -28,9 +28,6 @@ namespace esphome::espnow {
static constexpr const char *TAG = "espnow";
static const esp_err_t CONFIG_ESPNOW_WAKE_WINDOW = 50;
static const esp_err_t CONFIG_ESPNOW_WAKE_INTERVAL = 100;
ESPNowComponent *global_esp_now = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static const LogString *espnow_error_to_str(esp_err_t error) {
@@ -97,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) {
@@ -204,11 +210,6 @@ void ESPNowComponent::enable_() {
esp_wifi_get_mac(WIFI_IF_STA, this->own_address_);
#ifdef USE_DEEP_SLEEP
esp_now_set_wake_window(CONFIG_ESPNOW_WAKE_WINDOW);
esp_wifi_connectionless_module_set_wake_interval(CONFIG_ESPNOW_WAKE_INTERVAL);
#endif
this->state_ = ESPNOW_STATE_ENABLED;
for (auto peer : this->peers_) {
@@ -311,7 +312,9 @@ void ESPNowComponent::loop() {
ESP_LOGV(TAG, ">>> [%s] %s", addr_buf, LOG_STR_ARG(espnow_error_to_str(packet->packet_.sent.status)));
#endif
if (this->current_send_packet_ != nullptr) {
this->current_send_packet_->callback_(packet->packet_.sent.status);
if (this->current_send_packet_->callback_ != nullptr) {
this->current_send_packet_->callback_(packet->packet_.sent.status);
}
this->send_packet_pool_.release(this->current_send_packet_);
this->current_send_packet_ = nullptr; // Reset current packet after sending
}
@@ -333,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);
}
}
@@ -50,6 +50,11 @@ async def new_fastled_light(config):
ref="d44c800a9e876a8394caefc2ce4915dd96dac77b",
)
cg.add_library("SPI", None)
# FastLED's RMT5 driver hard-codes intr_priority=3, which conflicts with
# esphome's RMT channels (remote_transmitter etc., priority 0): the IDF
# driver rejects FastLED's channel and show() then hangs ~3s with no
# output. Override to 0 so it shares the interrupt. See #17063.
cg.add_build_flag("-DFL_RMT5_INTERRUPT_LEVEL=0")
else:
cg.add_library("fastled/FastLED", "3.9.16")
await light.register_light(var, config)
+4 -2
View File
@@ -1,14 +1,14 @@
import esphome.codegen as cg
from esphome.components import light, output
import esphome.config_validation as cv
from esphome.const import CONF_OUTPUT_ID, CONF_PIN_A, CONF_PIN_B
from esphome.const import CONF_OUTPUT_ID, CONF_PIN_A, CONF_PIN_B, CONF_UPDATE_INTERVAL
from .. import hbridge_ns
CODEOWNERS = ["@DotNetDann"]
HBridgeLightOutput = hbridge_ns.class_(
"HBridgeLightOutput", cg.Component, light.LightOutput
"HBridgeLightOutput", cg.PollingComponent, light.LightOutput
)
CONFIG_SCHEMA = light.RGB_LIGHT_SCHEMA.extend(
@@ -16,12 +16,14 @@ CONFIG_SCHEMA = light.RGB_LIGHT_SCHEMA.extend(
cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(HBridgeLightOutput),
cv.Required(CONF_PIN_A): cv.use_id(output.FloatOutput),
cv.Required(CONF_PIN_B): cv.use_id(output.FloatOutput),
cv.Optional(CONF_UPDATE_INTERVAL, default="8ms"): cv.update_interval,
}
)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_OUTPUT_ID])
cg.add(var.set_update_interval(config.pop(CONF_UPDATE_INTERVAL)))
await cg.register_component(var, config)
await light.register_light(var, config)
@@ -3,11 +3,10 @@
#include "esphome/components/light/light_output.h"
#include "esphome/components/output/float_output.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
namespace esphome::hbridge {
class HBridgeLightOutput : public Component, public light::LightOutput {
class HBridgeLightOutput final : public PollingComponent, public light::LightOutput {
public:
void set_pina_pin(output::FloatOutput *pina_pin) { this->pina_pin_ = pina_pin; }
void set_pinb_pin(output::FloatOutput *pinb_pin) { this->pinb_pin_ = pinb_pin; }
@@ -20,11 +19,12 @@ class HBridgeLightOutput : public Component, public light::LightOutput {
return traits;
}
void setup() override { this->disable_loop(); }
void setup() override { this->stop_poller(); }
void loop() override {
// Only called when both channels are active — alternate H-bridge direction
// each iteration to multiplex cold and warm white.
void update() override {
// Flip the H-bridge direction to multiplex cold/warm white. update_interval must stay
// slower than the output's PWM period (flipping faster collapses the output onto one
// channel) but fast enough to avoid flicker (issue #17030).
if (!this->forward_direction_) {
this->pina_pin_->set_level(this->pina_duty_);
this->pinb_pin_->set_level(0);
@@ -46,13 +46,17 @@ class HBridgeLightOutput : public Component, public light::LightOutput {
this->pinb_duty_ = new_pinb;
if (new_pina != 0.0f && new_pinb != 0.0f) {
// Both channels active — need loop to alternate H-bridge direction
this->high_freq_.start();
this->enable_loop();
// Both channels active — multiplex the H-bridge direction via the poller.
if (!this->multiplexing_) {
this->multiplexing_ = true;
this->start_poller();
}
} else {
// Zero or one channel active — drive pins directly, no multiplexing needed
this->high_freq_.stop();
this->disable_loop();
// Zero or one channel active — drive pins directly, no multiplexing needed.
if (this->multiplexing_) {
this->multiplexing_ = false;
this->stop_poller();
}
this->pina_pin_->set_level(new_pina);
this->pinb_pin_->set_level(new_pinb);
}
@@ -64,7 +68,7 @@ class HBridgeLightOutput : public Component, public light::LightOutput {
float pina_duty_{0};
float pinb_duty_{0};
bool forward_direction_{false};
HighFrequencyLoopRequester high_freq_;
bool multiplexing_{false};
};
} // namespace esphome::hbridge
+4
View File
@@ -175,6 +175,10 @@ void Logger::process_messages_() {
#ifdef USE_ESPHOME_TASK_LOG_BUFFER
// Process any buffered messages when available
if (this->log_buffer_.has_messages()) {
// Prevent main-task logs emitted by listener callbacks (e.g. the API send path) from re-entering
// and corrupting the shared tx_buffer_ / API shared_write_buffer_ while we are draining here.
// Mirrors the guard held by log_message_to_buffer_and_send_ on the synchronous logging path.
RecursionGuard guard(this->main_task_recursion_guard_);
logger::TaskLogBuffer::LogMessage *message;
uint16_t text_length;
while (this->log_buffer_.borrow_message_main_loop(message, text_length)) {
+24 -13
View File
@@ -120,6 +120,7 @@ CSCON = 0xF0
PWCTR6 = 0xF6
ADJCTL3 = 0xF7
PAGESEL = 0xFE
PAGESEL1 = 0xFF
MADCTL_MY = 0x80 # Bit 7 Bottom to top
MADCTL_MX = 0x40 # Bit 6 Right to left
@@ -387,6 +388,16 @@ class DriverChip:
return {CONF_MIRROR_X, CONF_MIRROR_Y, CONF_SWAP_XY}
return {CONF_MIRROR_X, CONF_MIRROR_Y}
def has_hardware_transform(self, config) -> bool:
"""
Check if the model supports hardware transforms for the given configuration.
"""
return config.get(CONF_TRANSFORM) != CONF_DISABLED and self.transforms == {
CONF_MIRROR_X,
CONF_MIRROR_Y,
CONF_SWAP_XY,
}
def option(self, name, fallback=False) -> cv.Optional:
return cv.Optional(name, default=self.get_default(name, fallback))
@@ -417,10 +428,15 @@ class DriverChip:
:return: A tuple (width, height, offset_width, offset_height, pad_width, pad_height).
"""
transform = self.get_transform(config)
if CONF_DIMENSIONS in config:
# Explicit dimensions, just use as is
dimensions = config[CONF_DIMENSIONS]
if isinstance(dimensions, dict):
native_width = self.get_default(CONF_NATIVE_WIDTH, 0)
native_height = self.get_default(CONF_NATIVE_HEIGHT, 0)
if transform.get(CONF_SWAP_XY) is True:
native_width, native_height = native_height, native_width
width = dimensions[CONF_WIDTH]
height = dimensions[CONF_HEIGHT]
offset_width = dimensions[CONF_OFFSET_WIDTH]
@@ -428,23 +444,19 @@ class DriverChip:
if CONF_PAD_WIDTH in dimensions:
pad_width = dimensions[CONF_PAD_WIDTH]
native_width = width + offset_width + pad_width
elif native_width == 0:
pad_width = 0
native_width = width + offset_width
else:
native_width = self.get_default(CONF_NATIVE_WIDTH, 0)
if native_width == 0:
pad_width = 0
native_width = width + offset_width
else:
pad_width = native_width - width - offset_width
pad_width = native_width - width - offset_width
if CONF_PAD_HEIGHT in dimensions:
pad_height = dimensions[CONF_PAD_HEIGHT]
native_height = height + offset_height + pad_height
elif native_height == 0:
pad_height = 0
native_height = height + offset_height
else:
native_height = self.get_default(CONF_NATIVE_HEIGHT, 0)
if native_height == 0:
pad_height = 0
native_height = height + offset_height
else:
pad_height = native_height - height - offset_height
pad_height = native_height - height - offset_height
if (
pad_width + offset_width >= native_width
or pad_height + offset_height >= native_height
@@ -460,7 +472,6 @@ class DriverChip:
return width, height, 0, 0, 0, 0
# Default dimensions, use model defaults
transform = self.get_transform(config)
width = self.get_default(CONF_WIDTH)
height = self.get_default(CONF_HEIGHT)
+24 -24
View File
@@ -17,6 +17,8 @@ from esphome.components.mipi import (
MADCTL,
MODE_BGR,
MODE_RGB,
PAGESEL,
PAGESEL1,
PIXFMT,
DriverChip,
dimension_schema,
@@ -172,13 +174,19 @@ def model_schema(config):
if bus_mode == TYPE_SINGLE:
other_options.append(CONF_SPI_16)
# Calculate default SPI mode. Mode3 for octal bus or single bus with no cs pin, mode0 otherwise.
spi_mode = model.get_default(CONF_SPI_MODE)
spi_mode = (
cv.UNDEFINED if CONF_SPI_MODE in config else model.get_default(CONF_SPI_MODE)
)
if not spi_mode:
if bus_mode == TYPE_OCTAL or (
bus_mode == TYPE_SINGLE
and not config.get(CONF_CS_PIN, model.get_default(CONF_CS_PIN))
and config.get(CONF_CS_PIN, model.get_default(CONF_CS_PIN)) is False
):
spi_mode = "MODE3"
if bus_mode == TYPE_SINGLE:
LOGGER.warning(
"No SPI mode specified, defaulting to MODE3 due to lack of CS pin. If you experience issues, try setting SPI mode explicitly to MODE0 or MODE3."
)
else:
spi_mode = "MODE0"
@@ -270,14 +278,16 @@ def customise_schema(config):
# Check for invalid combinations of MADCTL config
if init_sequence := config.get(CONF_INIT_SEQUENCE):
commands = [x[0] for x in init_sequence]
if MADCTL in commands and CONF_TRANSFORM in config:
raise cv.Invalid(
f"transform is not supported when MADCTL ({MADCTL:#X}) is in the init sequence"
)
if PIXFMT in commands:
raise cv.Invalid(
f"PIXFMT ({PIXFMT:#X}) should not be in the init sequence, it will be set automatically"
)
# If there is page swapping, we can't rely on recognising common commands
if PAGESEL not in commands and PAGESEL1 not in commands:
if MADCTL in commands and CONF_TRANSFORM in config:
raise cv.Invalid(
f"transform is not supported when MADCTL ({MADCTL:#X}) is in the init sequence"
)
if PIXFMT in commands:
raise cv.Invalid(
f"PIXFMT ({PIXFMT:#X}) should not be in the init sequence, it will be set automatically"
)
if bus_mode == TYPE_QUAD and CONF_DC_PIN in config:
raise cv.Invalid("DC pin is not supported in quad mode")
@@ -285,13 +295,7 @@ def customise_schema(config):
raise cv.Invalid(f"DC pin is required in {bus_mode} mode")
denominator(config)
model = MODELS[config[CONF_MODEL]]
has_hardware_transform = config.get(
CONF_TRANSFORM
) != CONF_DISABLED and model.transforms == {
CONF_MIRROR_X,
CONF_MIRROR_Y,
CONF_SWAP_XY,
}
has_hardware_transform = model.has_hardware_transform(config)
width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
model.get_dimensions(config, not has_hardware_transform)
)
@@ -356,13 +360,7 @@ def get_instance(config):
:return: type, template arguments
"""
model = MODELS[config[CONF_MODEL]]
has_hardware_transform = config.get(
CONF_TRANSFORM
) != CONF_DISABLED and model.transforms == {
CONF_MIRROR_X,
CONF_MIRROR_Y,
CONF_SWAP_XY,
}
has_hardware_transform = model.has_hardware_transform(config)
width, height, offset_width, offset_height, pad_width, pad_height = (
model.get_dimensions(config, not has_hardware_transform)
)
@@ -427,6 +425,8 @@ async def to_code(config):
dc_pin = await cg.gpio_pin_expression(dc_pin)
cg.add(var.set_dc_pin(dc_pin))
if config.get(CONF_INVERT_COLORS):
cg.add(var.set_invert_colors(True))
if lamb := config.get(CONF_LAMBDA):
lambda_ = await cg.process_lambda(
lamb, [(display.DisplayRef, "it")], return_type=cg.void
+3 -8
View File
@@ -151,6 +151,9 @@ class MipiSpi : public display::Display,
this->reset_pin_->digital_write(false);
delay(5);
this->reset_pin_->digital_write(true);
} else {
// no reset pin, send software reset command
this->write_command_(SW_RESET_CMD);
}
// need to know when the display is ready for SLPOUT command - will be 120ms after reset
@@ -176,7 +179,6 @@ class MipiSpi : public display::Display,
this->mark_failed();
return;
}
auto arg_byte = vec[index];
switch (cmd) {
case SLEEP_OUT: {
// are we ready, boots?
@@ -187,13 +189,6 @@ class MipiSpi : public display::Display,
}
} break;
case INVERT_ON:
this->invert_colors_ = true;
break;
case BRIGHTNESS:
this->brightness_ = arg_byte;
break;
default:
break;
}
+1 -6
View File
@@ -24,13 +24,11 @@ from esphome.components.mipi import (
PWSET,
PWSETN,
SETEXTC,
SWRESET,
VMCTR,
VMCTR1,
VMCTR2,
VSCRSADD,
DriverChip,
delay,
)
from esphome.components.spi import TYPE_OCTAL
@@ -367,7 +365,6 @@ ST7796 = DriverChip(
width=320,
height=480,
initsequence=(
(SWRESET,),
(CSCON, 0xC3),
(CSCON, 0x96),
(VMCTR1, 0x1C),
@@ -728,8 +725,6 @@ DriverChip(
width=128,
height=160,
initsequence=(
SWRESET,
delay(10),
(FRMCTR1, 0x01, 0x2C, 0x2D),
(FRMCTR2, 0x01, 0x2C, 0x2D),
(FRMCTR3, 0x01, 0x2C, 0x2D, 0x01, 0x2C, 0x2D),
@@ -786,7 +781,7 @@ ST7796.extend(
bus_mode=TYPE_OCTAL,
mirror_x=True,
reset_pin=4,
dc_pin=0,
dc_pin={"number": 0, "ignore_strapping_warning": True},
invert_colors=True,
)
+1 -1
View File
@@ -119,7 +119,7 @@ struct IPAddress {
IPAddress(const std::string &in_address) { ipaddr_aton(in_address.c_str(), &ip_addr_); }
IPAddress(ip4_addr_t *other_ip) {
memcpy((void *) &ip_addr_, (void *) other_ip, sizeof(ip4_addr_t));
#if USE_ESP32 && LWIP_IPV6
#if LWIP_IPV6
ip_addr_.type = IPADDR_TYPE_V4;
#endif
}
@@ -7,9 +7,13 @@ static const char *const TAG = "opentherm.output";
void opentherm::OpenthermOutput::write_state(float state) {
ESP_LOGD(TAG, "Received state: %.2f. Min value: %.2f, max value: %.2f", state, min_value_, max_value_);
this->state = state < 0.003 && this->zero_means_zero_
? 0.0
: clamp(std::lerp(min_value_, max_value_, state), min_value_, max_value_);
#ifdef USE_OUTPUT_FLOAT_POWER_SCALING
bool zero_means_zero = this->zero_means_zero_;
#else
bool zero_means_zero = false;
#endif
this->state =
state < 0.003 && zero_means_zero ? 0.0 : clamp(std::lerp(min_value_, max_value_, state), min_value_, max_value_);
this->has_state_ = true;
ESP_LOGD(TAG, "Output %s set to %.2f", this->id_, this->state);
}
+12 -1
View File
@@ -57,7 +57,18 @@ OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type)
return OTA_RESPONSE_ERROR_NO_UPDATE_PARTITION;
}
watchdog::WatchdogManager watchdog(15000);
// esp_ota_begin() erases the destination region, which blocks loopTask and
// scales with the erase size -- a fixed watchdog overruns on large OTA slots.
// An unknown size (0, e.g. web_server uploads) erases the whole partition, so
// budget against the bytes actually erased. ~10ms/KiB (conservative
// ~100 KiB/s erase) over a 15s floor; panic stays on so a stuck erase still
// resets rather than hanging forever.
size_t erase_size = image_size;
if (erase_size == 0 || erase_size > this->partition_->size) {
erase_size = this->partition_->size;
}
const uint32_t erase_budget_ms = 15000 + (erase_size >> 10) * 10;
watchdog::WatchdogManager watchdog(erase_budget_ms);
esp_err_t err = esp_ota_begin(this->partition_, image_size, &this->update_handle_);
if (err != ESP_OK) {
@@ -69,7 +69,7 @@ ENCRYPTION_SCHEMA = {
cv.Optional(CONF_ENCRYPTION): cv.maybe_simple_value(
cv.Schema(
{
cv.Required(CONF_KEY): cv.string,
cv.Required(CONF_KEY): cv.sensitive(cv.string),
}
),
key=CONF_KEY,
+3 -2
View File
@@ -4,7 +4,7 @@ import esphome.config_validation as cv
from esphome.const import (
CONF_TIME_ID,
DEVICE_CLASS_DURATION,
DEVICE_CLASS_UPTIME,
DEVICE_CLASS_TIMESTAMP,
ENTITY_CATEGORY_DIAGNOSTIC,
ICON_TIMER,
STATE_CLASS_TOTAL_INCREASING,
@@ -33,8 +33,9 @@ CONFIG_SCHEMA = cv.typed_schema(
).extend(cv.polling_component_schema("60s")),
"timestamp": sensor.sensor_schema(
UptimeTimestampSensor,
icon=ICON_TIMER,
accuracy_decimals=0,
device_class=DEVICE_CLASS_UPTIME,
device_class=DEVICE_CLASS_TIMESTAMP,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
)
.extend(
@@ -218,9 +218,18 @@ network::IPAddresses WiFiComponent::wifi_sta_ip_addresses() {
return {};
network::IPAddresses addresses;
uint8_t index = 0;
// addrList enumerates all lwIP netifs, including the SoftAP / fallback hotspot. Filter out
// the AP address so the STA address is reported as the device IP (see issue #17181).
struct ip_info ap_ip {};
wifi_get_ip_info(SOFTAP_IF, &ap_ip);
network::IPAddress ap_address(&ap_ip.ip);
bool filter_ap = ap_address.is_set();
for (auto &addr : addrList) {
network::IPAddress ip(addr.ipFromNetifNum());
if (filter_ap && ip == ap_address)
continue;
assert(index < addresses.size());
addresses[index++] = addr.ipFromNetifNum();
addresses[index++] = ip;
}
return addresses;
}
@@ -179,12 +179,53 @@ void WiFiComponent::wifi_lazy_init_() {
// nor re-register the default WiFi handlers.
if (s_sta_netif == nullptr)
s_sta_netif = esp_netif_create_default_wifi_sta();
if (s_sta_netif == nullptr) {
// Allocation failed; leave wifi_initialized_ false so a later enable() retries.
ESP_LOGE(TAG, "esp_netif_create_default_wifi_sta failed");
return;
}
#ifdef USE_WIFI_AP
if (s_ap_netif == nullptr)
s_ap_netif = esp_netif_create_default_wifi_ap();
#endif // USE_WIFI_AP
// The WiFi driver was started (e.g. by ESP-NOW with the wifi component disabled at
// boot) before our STA netif existed. The default WIFI_EVENT_STA_START handler
// therefore ran with no netif and never called esp_wifi_register_if_rxcb() -- the
// only thing that points the driver's RX path at a netif (it sets
// s_wifi_netifs[WIFI_IF_STA]). A bare esp_netif_action_start() would stop the
// immediate crash (#17232) but leaves RX unbound, so the first association
// associates at L2 yet never receives DHCP replies and times out (#17239). Restart
// the driver now that the netif exists so STA_START re-runs the default handler and
// wires RX correctly. ESP-NOW survives the stop/start (its peer state persists).
// This also matches a self-retry: if esp_wifi_set_storage() below failed on a
// previous wifi_lazy_init_() it returned without setting wifi_initialized_, and
// esp_wifi_init() has since run, so esp_wifi_get_mode() now succeeds here too.
wifi_mode_t mode;
if (esp_wifi_get_mode(&mode) == ESP_OK) {
ESP_LOGD(TAG, "WiFi driver already started without STA netif; restarting to bind it");
esp_err_t err = esp_wifi_stop();
if (err != ESP_OK) {
ESP_LOGW(TAG, "esp_wifi_stop failed: %s", esp_err_to_name(err));
}
// Re-apply RAM storage; the normal init path does this, but it is skipped on
// the self-retry case above, which would otherwise let the driver persist
// credentials to NVS for the rest of the boot.
err = esp_wifi_set_storage(WIFI_STORAGE_RAM);
if (err != ESP_OK) {
ESP_LOGW(TAG, "esp_wifi_set_storage failed: %s", esp_err_to_name(err));
}
err = esp_wifi_start();
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_wifi_start failed: %s", esp_err_to_name(err));
return;
}
s_wifi_started = true;
this->wifi_initialized_ = true;
return;
}
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
if (global_preferences->nvs_handle == 0) {
ESP_LOGW(TAG, "starting wifi without nvs");
+1 -3
View File
@@ -1455,9 +1455,7 @@ def ipv6address(value):
def ipv4address_multi_broadcast(value):
address = ipv4address(value)
if not (address.is_multicast or (address == IPv4Address("255.255.255.255"))):
raise Invalid(
f"{value} is not a multicasst address nor local broadcast address"
)
raise Invalid(f"{value} is not a multicast address nor local broadcast address")
return address
+1 -1
View File
@@ -4,7 +4,7 @@ from enum import Enum
from esphome.enum import StrEnum
__version__ = "2026.6.0b3"
__version__ = "2026.6.4"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = (
+11 -1
View File
@@ -407,6 +407,17 @@ def preload_core_config(config, result) -> str:
CORE.name = conf[CONF_NAME]
CORE.friendly_name = conf.get(CONF_FRIENDLY_NAME)
# Record the node's area name now (substitutions are already resolved at this
# point). storage.json is written before to_code() runs, so deferring this to
# to_code() left the area as null in storage.json. The value here is the raw
# post-substitution form (a plain string or a {name: ...} mapping). Assign
# unconditionally (like friendly_name) so a config without an area never
# inherits a stale value from a previous load in a long-running process, and
# use .get() so a malformed mapping surfaces later as a proper validation
# error rather than a KeyError here. to_code() sets it again from the
# validated config, which yields the same name.
area = conf.get(CONF_AREA)
CORE.area = area.get(CONF_NAME) if isinstance(area, dict) else area
CORE.data[KEY_CORE] = {}
if CONF_BUILD_PATH not in conf:
@@ -760,7 +771,6 @@ async def to_code(config: ConfigType) -> None:
# Process areas
all_areas: list[dict[str, str | core.ID]] = []
if CONF_AREA in config:
CORE.area = config[CONF_AREA][CONF_NAME]
all_areas.append(config[CONF_AREA])
all_areas.extend(config[CONF_AREAS])
+22 -12
View File
@@ -81,8 +81,13 @@ def _get_idf_tools_path() -> Path:
Path object pointing to the ESP-IDF tools directory
"""
if "ESPHOME_ESP_IDF_PREFIX" in os.environ:
return Path(get_str_env("ESPHOME_ESP_IDF_PREFIX", None)).expanduser()
return CORE.data_dir / "idf"
path = Path(get_str_env("ESPHOME_ESP_IDF_PREFIX", None)).expanduser()
else:
path = CORE.data_dir / "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.
return path.resolve()
# Windows' default MAX_PATH is 260 characters. ESP-IDF toolchains nest deeply
@@ -332,16 +337,19 @@ print(".".join([str(x) for x in sys.version_info]))
_GITHUB_SHORTHAND_RE = re.compile(
r"^github://([a-zA-Z0-9\-]+)/([a-zA-Z0-9\-\._]+?)(?:@([a-zA-Z0-9\-_.\./]+))?$"
r"^github://([a-zA-Z0-9\-]+)/([a-zA-Z0-9\-\._]+?)(?:[@#]([a-zA-Z0-9\-_.\./]+))?$"
)
_GITHUB_HTTPS_RE = re.compile(
r"^(https://github\.com/[a-zA-Z0-9\-]+/[a-zA-Z0-9\-\._]+?\.git)(?:@([a-zA-Z0-9\-_.\./]+))?$"
r"^(https://github\.com/[a-zA-Z0-9\-]+/[a-zA-Z0-9\-\._]+?\.git)(?:[@#]([a-zA-Z0-9\-_.\./]+))?$"
)
def _parse_git_source(source_url: str) -> tuple[str, str | None] | None:
"""Return ``(url, ref)`` for ``github://owner/repo[@ref]`` or
``https://github.com/owner/repo.git[@ref]``, else ``None``."""
``https://github.com/owner/repo.git[@ref]``, else ``None``.
The ref may be separated with ``@`` or ``#``; ``#`` matches the PlatformIO
convention used for ``platform_version`` URLs."""
if m := _GITHUB_SHORTHAND_RE.match(source_url):
owner, repo, ref = m.group(1), m.group(2), m.group(3)
# Tolerate a trailing ".git" on the shorthand repo so the
@@ -604,14 +612,16 @@ def _check_esphome_idf_framework_install(
install = True
if _check_stamp(env_stamp_file, stamp_info):
_LOGGER.info("Checking ESP-IDF %s framework installation ...", version)
cmd = [
get_system_python_path(),
str(idf_tools_path),
"--non-interactive",
"check",
]
if run_command_ok(cmd, msg=f"ESP-IDF {version} check", env=env):
# Validate via the managed tool-path resolution, not ``idf_tools.py check``:
# ``check`` probes tools on the system PATH and aborts if any fail to run (e.g. a
# broken Homebrew openocd), which forced a toolchain reinstall on every build.
try:
_get_idf_tool_paths(framework_path, env)
install = False
except RuntimeError as err:
_LOGGER.debug(
"ESP-IDF %s tool resolution failed, reinstalling: %s", version, err
)
# 4. Install framework tools if not installed or needs update
if install:
+1
View File
@@ -472,6 +472,7 @@ def get_idedata() -> dict | None:
pass
data = idedata_from_build(compile_commands)
data["prog_path"] = str(get_elf_path())
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
return data
+1 -1
View File
@@ -38,7 +38,7 @@ dependencies:
rules:
- if: "target in [esp32h2, esp32p4]"
espressif/esp_hosted:
version: 2.12.8
version: 2.12.9
rules:
- if: "target in [esp32h2, esp32p4]"
zorxx/multipart-parser:
+1 -1
View File
@@ -13,7 +13,7 @@ esptool==5.3.0
click==8.3.3
esphome-dashboard==20260425.0
aioesphomeapi==45.3.1
zeroconf==0.149.16
zeroconf==0.150.0
puremagic==1.30
ruamel.yaml==0.19.1 # dashboard_import
ruamel.yaml.clib==0.2.15 # dashboard_import
+15 -6
View File
@@ -70,12 +70,15 @@ def populate_dependency_config(
* ``domain.platform`` form (e.g. ``sensor.gpio``) appends
``{platform: <name>}`` to ``config[domain]``, creating the list if needed.
* Bare components are looked up via ``get_component_fn``. Platform
components (``IS_PLATFORM_COMPONENT``) and ``MULTI_CONF`` components are
initialised as ``[]`` so the sibling ``domain.platform`` branch can
``append`` into them. Everything else is populated by running the
component's schema with ``{}`` so defaults exist; if the schema requires
explicit input, an empty ``{}`` is used as a fallback.
* Bare components are looked up via ``get_component_fn``. Target-platform
components (``is_target_platform``, e.g. ``esp32``) are skipped entirely:
a host build targets ``host``, so a foreign target platform's sources are
guarded out and its schema must not run here (it would mutate global CORE
state as a side effect). Platform components (``IS_PLATFORM_COMPONENT``)
and ``MULTI_CONF`` components are initialised as ``[]`` so the sibling
``domain.platform`` branch can ``append`` into them. Everything else is
populated by running the component's schema with ``{}`` so defaults exist;
if the schema requires explicit input, an empty ``{}`` is used as a fallback.
Platform components must always be a list here even when no
``domain.platform`` entry follows, because the ``domain.platform`` branch
@@ -96,6 +99,12 @@ def populate_dependency_config(
component = get_component_fn(component_name)
if component is None:
continue
# Skip target platforms (e.g. esp32): a host build targets `host`, so a
# foreign target's sources are guarded out, and running its schema with
# {} leaks global CORE state (esp32 pins CORE.toolchain to ESP-IDF),
# crashing the host compile. See #17035.
if component.is_target_platform:
continue
if component.multi_conf or component.is_platform_component:
config.setdefault(component_name, [])
elif component_name not in config:
+45 -1
View File
@@ -306,6 +306,50 @@ def test_all_predefined_models(
run_schema_validation(config)
def test_single_bus_no_cs_no_mode_warns(
set_core_config: SetCoreConfigCallable,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A single-bus display with no CS pin and no explicit SPI mode warns about MODE3 default."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
run_schema_validation({"model": "ili9488", "dc_pin": 14})
assert "defaulting to MODE3 due to lack of CS pin" in caplog.text
@pytest.mark.parametrize(
"config",
[
pytest.param(
{"model": "ili9488", "dc_pin": 14, "cs_pin": 0},
id="cs_pin_provided",
),
pytest.param(
{"model": "ili9488", "dc_pin": 14, "spi_mode": "mode0"},
id="spi_mode_provided",
),
],
)
def test_single_bus_no_mode_warning_suppressed(
config: ConfigType,
set_core_config: SetCoreConfigCallable,
caplog: pytest.LogCaptureFixture,
) -> None:
"""No MODE3 warning when a CS pin or an explicit SPI mode is provided."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
run_schema_validation(config)
assert "defaulting to MODE3 due to lack of CS pin" not in caplog.text
def test_native_generation(
generate_main: Callable[[str | Path], str],
component_fixture_path: Callable[[str], Path],
@@ -333,6 +377,6 @@ def test_lvgl_generation(
"mipi_spi::MipiSpi<uint16_t, mipi_spi::PIXEL_MODE_16, true, mipi_spi::PIXEL_MODE_16, mipi_spi::BUS_TYPE_SINGLE, 128, 160, 0, 0, 0, 0, 0, true>();"
in main_cpp
)
assert "set_init_sequence({1, 0, 10, 255, 177" in main_cpp
assert "set_init_sequence({177, 3, 1, 44, 45, 178" in main_cpp
assert "show_test_card();" not in main_cpp
assert "set_auto_clear(false);" in main_cpp
@@ -13,6 +13,16 @@ from esphome.components.esp32 import (
VARIANT_ESP32,
VARIANT_ESP32S3,
)
from esphome.components.mipi import (
CONF_DIMENSIONS,
CONF_HEIGHT,
CONF_MIRROR_X,
CONF_MIRROR_Y,
CONF_OFFSET_HEIGHT,
CONF_OFFSET_WIDTH,
CONF_SWAP_XY,
CONF_WIDTH,
)
from esphome.components.mipi_spi.display import (
CONFIG_SCHEMA,
FINAL_VALIDATE_SCHEMA,
@@ -20,7 +30,13 @@ from esphome.components.mipi_spi.display import (
get_instance,
)
from esphome.components.spi import CONF_SPI_MODE, TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE
from esphome.const import CONF_CS_PIN, CONF_DC_PIN, PlatformFramework
from esphome.const import (
CONF_CS_PIN,
CONF_DC_PIN,
CONF_DISABLED,
CONF_TRANSFORM,
PlatformFramework,
)
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
@@ -432,3 +448,152 @@ class TestUserConfiguredPadding:
assert config["dimensions"]["width"] == 240
assert config["dimensions"]["height"] == 240
assert config["dimensions"]["pad_height"] == 16
class TestHasHardwareTransform:
"""Test DriverChip.has_hardware_transform()."""
def test_full_transform_model_without_transform_key(self) -> None:
"""A model supporting swap_xy uses a hardware transform by default."""
model = MODELS["ST7789V"]
assert model.has_hardware_transform({}) is True
def test_full_transform_model_with_transform_dict(self) -> None:
"""A configured (non-disabled) transform still uses the hardware path."""
model = MODELS["ST7789V"]
assert (
model.has_hardware_transform({CONF_TRANSFORM: {CONF_SWAP_XY: True}}) is True
)
def test_full_transform_model_with_transform_disabled(self) -> None:
"""Disabling the transform falls back to software transforms."""
model = MODELS["ST7789V"]
assert model.has_hardware_transform({CONF_TRANSFORM: CONF_DISABLED}) is False
def test_model_without_swap_xy_support(self) -> None:
"""Models that cannot swap axes never use a hardware transform."""
# AXS15231 only supports mirror_x/mirror_y, not swap_xy.
model = MODELS["AXS15231"]
assert model.transforms == {CONF_MIRROR_X, CONF_MIRROR_Y}
assert model.has_hardware_transform({}) is False
class TestSwapXYNativeDimensions:
"""Test that native dimensions are swapped when a swap_xy transform is active.
When explicit dimensions are given in the swapped (rotated) orientation and the
model applies a hardware swap_xy transform, the model's native_width/native_height
defaults must be swapped to match, otherwise padding is computed against the wrong
axis and validation fails.
"""
def test_explicit_swapped_dimensions_with_swap_xy_transform(
self,
set_core_config: SetCoreConfigCallable,
) -> None:
"""Explicit landscape dimensions on a portrait-native model with swap_xy."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
# ST7789V is natively 240x320 (portrait). Provide landscape dimensions
# together with a swap_xy transform.
model = MODELS["ST7789V"]
assert model.get_default("native_width") == 240
assert model.get_default("native_height") == 320
config = {
"model": "ST7789V",
CONF_DIMENSIONS: {
CONF_WIDTH: 320,
CONF_HEIGHT: 240,
CONF_OFFSET_WIDTH: 0,
CONF_OFFSET_HEIGHT: 0,
},
CONF_TRANSFORM: {
CONF_SWAP_XY: True,
CONF_MIRROR_X: False,
CONF_MIRROR_Y: False,
},
}
# swap=False because the buffer is laid out in the requested orientation.
width, height, offset_w, offset_h, pad_w, pad_h = model.get_dimensions(
config, swap=False
)
# Native dims are swapped to 320x240, so padding works out to zero rather
# than going negative (which previously raised "Invalid offsets").
assert (width, height) == (320, 240)
assert (offset_w, offset_h) == (0, 0)
assert (pad_w, pad_h) == (0, 0)
def test_explicit_dimensions_without_swap_keeps_native_orientation(
self,
set_core_config: SetCoreConfigCallable,
) -> None:
"""Without swap_xy the native dimensions keep their original orientation."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
model = MODELS["ST7789V"]
config = {
"model": "ST7789V",
CONF_DIMENSIONS: {
CONF_WIDTH: 240,
CONF_HEIGHT: 320,
CONF_OFFSET_WIDTH: 0,
CONF_OFFSET_HEIGHT: 0,
},
CONF_TRANSFORM: {
CONF_SWAP_XY: False,
CONF_MIRROR_X: False,
CONF_MIRROR_Y: False,
},
}
width, height, offset_w, offset_h, pad_w, pad_h = model.get_dimensions(
config, swap=False
)
assert (width, height) == (240, 320)
assert (offset_w, offset_h) == (0, 0)
assert (pad_w, pad_h) == (0, 0)
def test_swapped_native_dimensions_compute_padding(
self,
set_core_config: SetCoreConfigCallable,
) -> None:
"""Padding is derived from the swapped native size when swap_xy is active."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
# ILI9341 is natively 240x320. Request a 300x240 area in landscape; the
# swapped native size is 320x240, leaving 20px of horizontal padding.
model = MODELS["ILI9341"]
assert model.get_default("native_width") == 240
assert model.get_default("native_height") == 320
config = {
"model": "ILI9341",
CONF_DIMENSIONS: {
CONF_WIDTH: 300,
CONF_HEIGHT: 240,
CONF_OFFSET_WIDTH: 0,
CONF_OFFSET_HEIGHT: 0,
},
CONF_TRANSFORM: {
CONF_SWAP_XY: True,
CONF_MIRROR_X: False,
CONF_MIRROR_Y: False,
},
}
width, height, _, _, pad_w, pad_h = model.get_dimensions(config, swap=False)
assert (width, height) == (300, 240)
# native_width swapped to 320 -> pad_width = 320 - 300 - 0 = 20
assert pad_w == 20
assert pad_h == 0
@@ -0,0 +1,113 @@
"""Combined tests for PAGESEL/PAGESEL1 behaviour with MADCTL/PIXFMT.
Covers both the suppression behaviour (when PAGESEL or PAGESEL1 are present)
and the error behaviour when neither page-selection command is present.
"""
from __future__ import annotations
from typing import Any
import pytest
from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32
from esphome.components.mipi import MADCTL, PAGESEL, PAGESEL1, PIXFMT
from esphome.components.mipi_spi.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA
import esphome.config_validation as cv
from esphome.const import PlatformFramework
from tests.component_tests.types import SetCoreConfigCallable
def validated_config(config: dict[str, Any]) -> dict[str, Any]:
"""Run schema + final validation and return the validated config."""
cfg = CONFIG_SCHEMA(config)
FINAL_VALIDATE_SCHEMA(cfg)
return cfg
def test_madctl_error_suppressed_when_pagesel_present(
set_core_config: SetCoreConfigCallable,
) -> None:
"""If PAGESEL is present in init_sequence, MADCTL presence must not raise an error."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
cfg = {
"model": "custom",
"dc_pin": 18,
"dimensions": {"width": 320, "height": 240},
"transform": {"mirror_x": True, "mirror_y": True, "swap_xy": False},
"init_sequence": [[PAGESEL, 0x00], [MADCTL, 0x01]],
}
# Should not raise
validated = validated_config(cfg)
assert validated is not None
def test_pixfmt_error_suppressed_when_pagesel1_present(
set_core_config: SetCoreConfigCallable,
) -> None:
"""If PAGESEL1 is present in init_sequence, PIXFMT presence must not raise an error."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
cfg = {
"model": "custom",
"dc_pin": 18,
"dimensions": {"width": 320, "height": 240},
"init_sequence": [[PAGESEL1, 0x00], [PIXFMT, 0x01]],
}
# Should not raise
validated = validated_config(cfg)
assert validated is not None
def test_madctl_raises_without_pagesel(
set_core_config: SetCoreConfigCallable,
) -> None:
"""MADCTL in the init_sequence should raise when a transform is configured and
no PAGESEL/PAGESEL1 is present.
"""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
cfg: dict[str, Any] = {
"model": "custom",
"dc_pin": 18,
"dimensions": {"width": 320, "height": 240},
"transform": {"mirror_x": True, "mirror_y": True, "swap_xy": False},
"init_sequence": [[MADCTL, 0x01]],
}
with pytest.raises(cv.Invalid, match=r"MADCTL .* in the init sequence"):
CONFIG_SCHEMA(cfg)
def test_pixfmt_raises_without_pagesel1(
set_core_config: SetCoreConfigCallable,
) -> None:
"""PIXFMT in the init_sequence should raise when no PAGESEL/PAGESEL1 is present."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
cfg: dict[str, Any] = {
"model": "custom",
"dc_pin": 18,
"dimensions": {"width": 320, "height": 240},
"init_sequence": [[PIXFMT, 0x01]],
}
with pytest.raises(
cv.Invalid, match=r"PIXFMT .* should not be in the init sequence"
):
CONFIG_SCHEMA(cfg)
@@ -77,3 +77,34 @@ esp32_ble_server:
id: test_change_descriptor
value:
data: [0x01, 0x02, 0x03]
# Regression test for #17142: the set_value action used from a trigger that passes
# its argument by reference (climate on_control supplies ClimateCall&) previously
# failed to compile.
sensor:
- platform: template
id: ble_test_temp
lambda: "return 20.0;"
output:
- platform: template
id: ble_test_output
type: float
write_action:
- logger.log: "out"
climate:
- platform: pid
name: "BLE Test Climate"
id: ble_test_climate
sensor: ble_test_temp
default_target_temperature: 20
heat_output: ble_test_output
control_parameters:
kp: 0.1
ki: 0.001
kd: 0.1
on_control:
- ble_server.characteristic.set_value:
id: test_notify_characteristic
value: !lambda "return std::vector<uint8_t>{0, 1, 2};"
@@ -0,0 +1,61 @@
esphome:
name: logger-recursion-test
host:
api:
logger:
level: DEBUG
on_message:
# Fires on the main loop for every message delivered to listeners, including
# messages drained from the task log buffer (i.e. logged from a non-main thread).
# The lambda logs again on the main task. Without a recursion guard on the buffered
# drain path this re-entrant log reuses the shared tx_buffer_ and clobbers the
# buffered message that is still being delivered, corrupting its console output.
- level: VERY_VERBOSE
then:
- lambda: |-
ESP_LOGD("reentry", "REENTRANT_CLOBBER_MARKER");
button:
- platform: template
name: "Start Race Test"
id: start_test_button
on_press:
- lambda: |-
// Keep the count well under the host task-log-buffer slot count so every
// message goes through the ring buffer (buffered drain path) instead of the
// emergency console fallback. The main loop is blocked in pthread_join while
// the thread logs, so all messages are drained together once it returns.
static const int NUM_MESSAGES = 30;
struct ThreadTest {
static void *thread_func(void *arg) {
char thread_name[16];
snprintf(thread_name, sizeof(thread_name), "LogThread");
#ifdef __APPLE__
pthread_setname_np(thread_name);
#else
pthread_setname_np(pthread_self(), thread_name);
#endif
for (int i = 0; i < NUM_MESSAGES; i++) {
// Verifiable payload: data is a deterministic function of the message
// index, so a clobbered buffer shows up as a missing or mismatched line.
ESP_LOGD("thread_test", "THREADMSG%03d_DATA_%08X", i, i * 12345);
}
return nullptr;
}
};
// RACE_TEST_START / RACE_TEST_COMPLETE are logged from the main task (the
// synchronous path, which already holds the recursion guard) so the test can
// always detect completion even when the buffered path is corrupted.
ESP_LOGI("thread_test", "RACE_TEST_START: logging %d messages from a thread", NUM_MESSAGES);
pthread_t thread;
if (pthread_create(&thread, nullptr, ThreadTest::thread_func, nullptr) != 0) {
ESP_LOGE("thread_test", "RACE_TEST_ERROR: Failed to create thread");
return;
}
pthread_join(thread, nullptr);
ESP_LOGI("thread_test", "RACE_TEST_COMPLETE: thread finished, expected %d messages", NUM_MESSAGES);
@@ -0,0 +1,119 @@
"""Integration test for the recursion guard on the buffered logger drain path.
Regression test for a crash where a log message drained from the task log buffer
(i.e. logged from a non-main thread) re-entered the logger on the main task while it
was still being delivered to listeners. The buffered drain in
``Logger::process_messages_`` did not hold the main-task recursion guard that the
synchronous logging path holds, so a listener callback that logged again on the main
task (e.g. the API log-forwarding path, or a ``logger.on_message`` automation) reused
the shared ``tx_buffer_`` and clobbered the message mid-delivery. On ESP32 this showed
up as a ``StoreProhibited`` panic inside the API send path.
The fixture logs a small batch of verifiable messages from a non-main thread (kept
under the host task-log-buffer slot count so they all take the buffered drain path
rather than the emergency console fallback) while an ``on_message`` automation re-logs
``REENTRANT_CLOBBER_MARKER`` on the main task for every delivered message.
Without the guard the re-entrant marker is written into the shared ``tx_buffer_`` while
the buffered thread message is still being delivered, so the message the API receives is
contaminated (it contains the marker and an embedded newline glued onto the thread
payload). With the guard the re-entrant log is dropped during the drain, the marker
never appears, and every thread message is delivered clean.
"""
from __future__ import annotations
import asyncio
import re
from aioesphomeapi import LogLevel
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
_ANSI = re.compile(r"\x1b\[[0-9;]*m")
# THREADMSGnnn_DATA_xxxxxxxx where data is a deterministic checksum of the index
THREAD_MSG_PATTERN = re.compile(r"THREADMSG(\d{3})_DATA_([0-9A-F]{8})")
NUM_MESSAGES = 30
@pytest.mark.asyncio
async def test_logger_buffered_recursion_guard(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Buffered (non-main-thread) log messages survive a re-entrant main-task log."""
api_messages: list[str] = []
all_drained = asyncio.Event()
async with (
run_compiled(yaml_config),
api_client_connected() as client,
):
device_info = await client.device_info()
assert device_info is not None
assert device_info.name == "logger-recursion-test"
# Subscribe over the API: this is the exact path that crashed in the field
# (the API log callback runs during the buffered drain). The API message field
# preserves embedded newlines, so it reliably exposes a clobbered buffer.
#
# Every buffered thread message is delivered here whether it survives intact or
# gets clobbered (a clobbered message still carries its THREADMSG payload), so
# counting THREADMSG occurrences is a deterministic "drain complete" signal: no
# arbitrary sleep, no dependence on the fix being present.
def on_log(msg) -> None:
text = msg.message.decode("utf-8", errors="replace")
api_messages.append(text)
received = sum(len(THREAD_MSG_PATTERN.findall(m)) for m in api_messages)
if received >= NUM_MESSAGES:
all_drained.set()
client.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_VERY_VERBOSE)
entities, _ = await client.list_entities_services()
buttons = [e for e in entities if e.name == "Start Race Test"]
assert buttons, "Could not find Start Race Test button"
client.button_command(buttons[0].key)
# Wait until every buffered thread message has been delivered over the API.
try:
await asyncio.wait_for(all_drained.wait(), timeout=30.0)
except TimeoutError:
received = sum(len(THREAD_MSG_PATTERN.findall(m)) for m in api_messages)
pytest.fail(
f"Only {received}/{NUM_MESSAGES} thread messages arrived before timeout; "
"device likely crashed or hung."
)
intact: set[int] = set()
contaminated: list[str] = []
for raw in api_messages:
text = _ANSI.sub("", raw)
if "THREADMSG" not in text:
continue
# A clean thread message is a single line carrying only its own payload. A
# clobbered buffer glues the re-entrant marker (and an embedded newline) onto it.
if "REENTRANT" in text or "\n" in text:
contaminated.append(repr(raw))
continue
match = THREAD_MSG_PATTERN.search(text)
assert match, f"Unexpected thread message format: {raw!r}"
msg_num = int(match.group(1))
expected = f"{msg_num * 12345:08X}"
if match.group(2) != expected:
contaminated.append(repr(raw))
continue
intact.add(msg_num)
assert not contaminated, (
"Buffered thread messages were clobbered by a re-entrant main-task log "
"(missing recursion guard on the buffered drain path):\n"
+ "\n".join(contaminated[:10])
)
assert len(intact) == NUM_MESSAGES, (
f"Expected {NUM_MESSAGES} intact buffered thread messages over the API, got "
f"{len(intact)}. Missing ids: {sorted(set(range(NUM_MESSAGES)) - intact)}"
)
+76
View File
@@ -0,0 +1,76 @@
"""Unit tests for script/build_helpers.py."""
from pathlib import Path
import sys
import pytest
# Add the script directory to the path so we can import build_helpers.
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "script"))
import build_helpers # noqa: E402
from esphome.core import CORE # noqa: E402
class _FakeComponent:
def __init__(self, config_schema, *, is_target_platform=False):
self.multi_conf = False
self.is_platform_component = False
self.is_target_platform = is_target_platform
self.config_schema = config_schema
@pytest.fixture(autouse=True)
def _restore_core_toolchain():
"""Keep CORE.toolchain changes from leaking between tests."""
saved = CORE.toolchain
try:
yield
finally:
CORE.toolchain = saved
def test_populate_dependency_config_skips_target_platforms() -> None:
"""Target-platform deps must be skipped, not config-populated, in a host build.
Regression test for #17035: esp32 (a target platform) appears only as a
transitive dependency of a host C++ unit test. Running its schema with {}
set ``CORE.toolchain = ESP_IDF`` as a side effect before failing validation,
which crashed the host compile with KeyError('esp32'). The fix skips
target-platform components entirely so their schema never runs.
"""
CORE.toolchain = None # the state a host build starts from
schema_calls = []
def leaky_schema(value):
# If this ever runs for a target platform, the bug is back.
schema_calls.append(value)
CORE.toolchain = "esp-idf-leak"
raise ValueError("no board or variant")
config: dict = {}
build_helpers.populate_dependency_config(
config,
["esp32"],
get_component_fn=lambda name: _FakeComponent(
leaky_schema, is_target_platform=True
),
register_platform_fn=lambda domain: None,
)
assert "esp32" not in config # skipped: no synthesized entry
assert schema_calls == [] # schema never run
assert CORE.toolchain is None # no global side effect leaked
def test_populate_dependency_config_populates_defaults() -> None:
"""A non-target-platform dep still has its schema defaults harvested."""
config: dict = {}
build_helpers.populate_dependency_config(
config,
["ok"],
get_component_fn=lambda name: _FakeComponent(lambda value: {"default": 1}),
register_platform_fn=lambda domain: None,
)
assert config["ok"] == {"default": 1}
+2
View File
@@ -266,11 +266,13 @@ def _make_component_stub(
*,
multi_conf: bool = False,
is_platform_component: bool = False,
is_target_platform: bool = False,
config_schema=None,
) -> MagicMock:
stub = MagicMock()
stub.multi_conf = multi_conf
stub.is_platform_component = is_platform_component
stub.is_target_platform = is_target_platform
stub.config_schema = config_schema
return stub
+26 -3
View File
@@ -152,15 +152,21 @@ def test_multiple_areas_and_devices(yaml_file: Callable[[str], str]) -> None:
("multiple_areas_devices.yaml", "Main Area"),
],
)
async def test_to_code_records_core_area(
async def test_core_area_recorded_at_config_load(
yaml_file: Callable[[str], Path],
fixture: str,
expected_area: str,
) -> None:
"""``to_code`` records the node's area name on CORE for StorageJSON."""
"""The node's area name is recorded on CORE for StorageJSON.
It must be set during config load (preload_core_config), not deferred to
to_code(): storage.json is written before to_code() runs, so a late
assignment left the area as null in storage.json (regression #17218).
"""
result = load_config_from_fixture(yaml_file, fixture, FIXTURES_DIR)
assert result is not None
assert CORE.area is None
# Recorded already at config-load time, before any code generation.
assert CORE.area == expected_area
with patch("esphome.core.config.cg") as mock_cg:
mock_cg.RawStatement.side_effect = lambda *args, **kwargs: MagicMock()
@@ -170,6 +176,23 @@ async def test_to_code_records_core_area(
assert CORE.area == expected_area
def test_config_load_without_area_clears_stale_core_area(
yaml_file: Callable[[str], Path],
) -> None:
"""A config without an area must not inherit a stale CORE.area.
preload_core_config assigns CORE.area unconditionally, so the area from a
previous load in a long-running process cannot leak into a config that
omits it.
"""
CORE.area = "Stale Area From Previous Load"
result = load_config_from_fixture(
yaml_file, "device_without_area.yaml", FIXTURES_DIR
)
assert result is not None
assert CORE.area is None
def test_legacy_string_area(
yaml_file: Callable[[str], str], caplog: pytest.LogCaptureFixture
) -> None:
+25 -4
View File
@@ -64,6 +64,19 @@ from esphome.framework_helpers import _tar_extract_all, get_python_env_executabl
"https://github.com/espressif/esp-idf.git@v6.0.1",
("https://github.com/espressif/esp-idf.git", "v6.0.1"),
),
# '#' ref separator (PlatformIO/git-web convention) works on both forms
(
"https://github.com/espressif/esp-idf.git#release/v6.1",
("https://github.com/espressif/esp-idf.git", "release/v6.1"),
),
(
"github://espressif/esp-idf#release/v6.1",
("https://github.com/espressif/esp-idf.git", "release/v6.1"),
),
(
"github://espressif/esp-idf.git#master",
("https://github.com/espressif/esp-idf.git", "master"),
),
# Tolerate a trailing ".git" on the shorthand so the user doesn't
# silently end up with a doubled "...esp-idf.git.git" URL.
(
@@ -298,6 +311,9 @@ def espidf_mocks(setup_core: Path):
patch("esphome.espidf.framework.archive_extract_all") as extract,
patch("esphome.espidf.framework.create_venv") as venv,
patch("esphome.espidf.framework.run_command_ok", return_value=True) as run_ok,
patch(
"esphome.espidf.framework._get_idf_tool_paths", return_value=([], {})
) as tool_paths,
patch("esphome.espidf.framework._clone_idf_with_submodules") as clone,
patch("esphome.espidf.framework._write_idf_version_txt"),
patch("esphome.espidf.framework._patch_tools_json_for_linux_arm64"),
@@ -308,7 +324,12 @@ def espidf_mocks(setup_core: Path):
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
):
yield SimpleNamespace(
download=download, extract=extract, venv=venv, run_ok=run_ok, clone=clone
download=download,
extract=extract,
venv=venv,
run_ok=run_ok,
tool_paths=tool_paths,
clone=clone,
)
@@ -403,10 +424,10 @@ def test_check_esp_idf_install_stamp_mismatch_reinstalls(
def test_check_esp_idf_install_check_command_failure_reinstalls(
espidf_mocks: SimpleNamespace,
) -> None:
"""A failing idf_tools check reinstalls tools (marker present, no re-extract)."""
"""A failing tool-path resolution reinstalls tools (marker present, no re-extract)."""
_mark_installed()
# idf_tools check fails -> install stays True; the later installs succeed.
espidf_mocks.run_ok.side_effect = [False, True, True, True]
# Managed tool resolution fails -> install stays True; the later installs succeed.
espidf_mocks.tool_paths.side_effect = RuntimeError("missing ESP-IDF tool")
check_esp_idf_install(_IDF_VERSION, features=["fb"])
espidf_mocks.extract.assert_not_called()
+24 -4
View File
@@ -89,8 +89,9 @@ def test_get_idedata_generates_and_caches(setup_core: Path) -> None:
result = toolchain.get_idedata()
mock_transform.assert_called_once()
assert result == {"cxx_path": "g++"}
assert json.loads(cache.read_text()) == {"cxx_path": "g++"}
prog_path = str(toolchain.get_elf_path())
assert result == {"cxx_path": "g++", "prog_path": prog_path}
assert json.loads(cache.read_text()) == {"cxx_path": "g++", "prog_path": prog_path}
def test_get_idedata_uses_cache_when_valid(setup_core: Path) -> None:
@@ -127,7 +128,7 @@ def test_get_idedata_regenerates_when_compile_commands_newer(setup_core: Path) -
result = toolchain.get_idedata()
mock_transform.assert_called_once()
assert result == {"cxx_path": "fresh"}
assert result == {"cxx_path": "fresh", "prog_path": str(toolchain.get_elf_path())}
def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None:
@@ -147,7 +148,26 @@ def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None:
result = toolchain.get_idedata()
mock_transform.assert_called_once()
assert result == {"cxx_path": "regen"}
assert result == {"cxx_path": "regen", "prog_path": str(toolchain.get_elf_path())}
def test_get_idedata_prog_path_points_at_firmware_elf(setup_core: Path) -> None:
"""The idedata exposes prog_path (the ELF) so consumers like build-action
can locate firmware.factory.bin / firmware.ota.bin as its siblings."""
compile_commands, _ = _setup_build(setup_core)
compile_commands.parent.mkdir(parents=True, exist_ok=True)
compile_commands.write_text("[]")
with patch(
"esphome.espidf.idedata.idedata_from_build",
return_value={"cxx_path": "g++"},
):
result = toolchain.get_idedata()
# Use Path semantics so the contract holds on Windows too (backslashes).
prog_path = Path(result["prog_path"])
assert prog_path.name == "firmware.elf"
assert prog_path.parent.name == "build"
def test_get_idf_env_sets_git_ceiling_directories(setup_core: Path) -> None:
+91
View File
@@ -32,6 +32,7 @@ from esphome.__main__ import (
command_clean_all,
command_config,
command_config_hash,
command_idedata,
command_rename,
command_run,
command_update_all,
@@ -436,6 +437,36 @@ def test_redact_with_legacy_fallback__does_not_match_fragment_as_suffix(
assert not any("legacy substring" in rec.message for rec in caplog.records)
def test_redact_with_legacy_fallback__substitutions_redacted_without_warning(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Substitution keys have no schema validator, so their values are still
redacted but the unactionable cv.sensitive migration warning is suppressed
(see issue #17225)."""
text = "substitutions:\n ota_password: apolloautomation\nesphome:\n name: x\n"
with caplog.at_level(logging.WARNING, logger="esphome.__main__"):
out = _redact_with_legacy_fallback(text)
assert "ota_password: \\033[8mapolloautomation\\033[28m" in out
assert not any("legacy substring" in rec.message for rec in caplog.records)
def test_redact_with_legacy_fallback__warns_after_substitutions_block(
caplog: pytest.LogCaptureFixture,
) -> None:
"""The suppression ends at the next top-level key; a sensitive-shaped field
in a later block (a real schema field) still warns, while the substitution
above it does not."""
text = (
"substitutions:\n ota_password: apolloautomation\nwifi:\n password: hunter2\n"
)
with caplog.at_level(logging.WARNING, logger="esphome.__main__"):
out = _redact_with_legacy_fallback(text)
assert "ota_password: \\033[8mapolloautomation\\033[28m" in out
assert "password: \\033[8mhunter2\\033[28m" in out
assert any("'password'" in rec.message for rec in caplog.records)
assert not any("ota_password" in rec.message for rec in caplog.records)
def test_command_config__invokes_legacy_fallback_when_redacting(
tmp_path: Path, capfd: CaptureFixture[str]
) -> None:
@@ -689,6 +720,25 @@ def test_choose_upload_log_host_with_ota_device_with_ota_config() -> None:
assert result == ["192.168.1.100"]
def test_choose_upload_log_host_ota_mdns_disabled_uses_address_cache() -> None:
"""A .local device with mDNS disabled resolves via the dashboard-supplied cache."""
setup_core(
config={
CONF_API: {},
CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}],
CONF_MDNS: {CONF_DISABLED: True},
},
address="esp32-a1s.local",
)
CORE.address_cache = AddressCache(mdns_cache={"esp32-a1s.local": ["192.168.1.50"]})
for purpose in (Purpose.LOGGING, Purpose.UPLOADING):
result = choose_upload_log_host(
default="OTA", check_default=None, purpose=purpose
)
assert result == ["192.168.1.50"]
def test_choose_upload_log_host_with_ota_device_with_api_config() -> None:
"""Test OTA device when API is configured (no upload without OTA in config)."""
setup_core(config={CONF_API: {}}, address="192.168.1.100")
@@ -3135,6 +3185,22 @@ def test_has_resolvable_address() -> None:
setup_core(config={CONF_MDNS: {CONF_DISABLED: True}}, address=None)
assert has_resolvable_address() is False
# mDNS disabled + .local, but the dashboard cached the address -> resolvable
setup_core(
config={CONF_MDNS: {CONF_DISABLED: True}}, address="esphome-device.local"
)
CORE.address_cache = AddressCache(
mdns_cache={"esphome-device.local": ["192.168.1.100"]}
)
assert has_resolvable_address() is True
# mDNS disabled + .local, cache present but missing this host -> not resolvable
setup_core(
config={CONF_MDNS: {CONF_DISABLED: True}}, address="esphome-device.local"
)
CORE.address_cache = AddressCache(mdns_cache={"other-device.local": ["10.0.0.1"]})
assert has_resolvable_address() is False
def test_has_name_add_mac_suffix() -> None:
"""Test has_name_add_mac_suffix function."""
@@ -6222,3 +6288,28 @@ def test_command_run_defaults_subscribe_states_true(
mock_run_logs.assert_called_once_with(
CORE.config, ["192.168.1.100"], subscribe_states=True
)
def test_command_idedata_esp_idf_prints_json(capsys: CaptureFixture) -> None:
"""Under the native ESP-IDF toolchain, idedata is emitted as JSON."""
setup_core()
CORE.toolchain = Toolchain.ESP_IDF
data = {"cxx_path": "g++", "prog_path": "/build/firmware.elf"}
with patch("esphome.espidf.toolchain.get_idedata", return_value=data) as mock_get:
result = command_idedata(MagicMock(), CORE.config)
assert result == 0
mock_get.assert_called_once_with()
assert json.loads(capsys.readouterr().out) == data
def test_command_idedata_esp_idf_no_build_errors() -> None:
"""Under ESP-IDF, a missing build (no idedata) returns an error, not a crash."""
setup_core()
CORE.toolchain = Toolchain.ESP_IDF
with patch("esphome.espidf.toolchain.get_idedata", return_value=None):
result = command_idedata(MagicMock(), CORE.config)
assert result == 1