Compare commits

..
Author SHA1 Message Date
J. Nick Koston e7a5258118 Use MillisInternal in the scope and say setup() is not timed by the guard 2026-09-07 18:22:52 +02:00
J. Nick Koston d7dea9e74a Point at WatchdogManager for work longer than the watchdog timeout 2026-09-07 17:33:48 +02:00
J. Nick Koston 2307d811bb Note that later start time reads in the pass see the moved start 2026-09-07 17:31:34 +02:00
J. Nick Koston d6d2540823 Test that work outside the scope still ratchets 2026-09-07 17:22:25 +02:00
J. Nick Koston c1272a72aa Name the loop pass cases the scope is for 2026-09-07 17:19:43 +02:00
J. Nick Koston a4ec1791e2 Make nested scopes exact, point the examples at loop(), keep the test component alive 2026-09-07 17:10:39 +02:00
J. Nick Koston 86997a438c Clamp the moved pass start to now, document the watchdog and task constraints, test the threshold 2026-09-07 16:57:03 +02:00
J. Nick Koston 717fbb1373 [core] Say when UnavoidableBlockingScope must not be used 2026-09-07 16:40:25 +02:00
J. Nick Koston 56f6d7bc6d [core] Add UnavoidableBlockingScope for blocking that cannot be shortened
Some work has no shorter form: bringing up a radio, the first connect of
a network stack, a key generation whose cost is the algorithm. Wrapping
it in this scope moves the loop pass start forward by its duration, so
the blocking warning keeps reporting everything else in the pass and the
component's threshold does not ratchet over it. The comment says what it
is for and that it must never hide code that could be made faster.
2026-09-07 16:38:56 +02:00
Jesse Hills d34d3994e1 Merge branch 'beta' into dev 2026-09-07 12:50:44 +12:00
Jesse Hills d58b37faa1 [esp32_hosted] Add ESP-NOW-over-hosted shim for the ESP32-P4 (#17712) 2026-09-07 10:29:20 +12:00
J. Nick Koston 8966567be0 [core] Show the other downloader's progress while a prefetch job waits on its lock (#18983) 2026-09-07 10:28:31 +12:00
J. Nick Koston 20c7dcb1dd [mdns] Guard LEAmDNS main loop calls against lwIP re-entrancy on ESP8266 (#18990) 2026-09-07 10:26:44 +12:00
J. Nick Koston 688af60cbf [noise] Bump noise-c to 0.1.24 and libsodium to 1.10021.6 (#18989) 2026-09-07 10:12:52 +12:00
esphome[bot] 9c00f13606 Bump bundled esphome-device-builder to 1.14.4 (#19006) 2026-09-06 22:05:16 +00:00
J. Nick Koston 833dd0e812 [ota] Offer encryption with the api key so enabling it works over OTA (#18979) 2026-09-06 23:59:40 +02:00
Ricardo Sanz 8e1044e8ea [climate][template] New template climate component (#14455) 2026-09-06 14:03:07 -07:00
esphome[bot] e5200db6fd Bump bundled esphome-device-builder to 1.14.3 (#18996) 2026-09-06 09:28:02 +02:00
e3dd2f44a4 [mipi_dsi] Let IDF pick the DPHY PLL reference clock (#18984)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com>
2026-09-05 21:08:21 +00:00
esphome[bot] 3ef7460fca Bump bundled esphome-device-builder to 1.14.2 (#18988) 2026-09-05 15:25:58 +00:00
Clyde Stubbspre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>Claude
ae187f81f2 [wifi] Allow a forced roam check (#17349)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-09-05 21:44:37 +10:00
esphome[bot] 84f78831f9 Bump bundled esphome-device-builder to 1.14.1 (#18981) 2026-09-05 13:07:15 +02:00
Keith Burzinski 13dbbcaa32 [usb_uart] Keep the comm interface number valid when its claim fails (#18968) 2026-09-05 13:00:25 +02:00
Clyde Stubbs b66822d9bd [ai] Advice to agents to limit verbiage (#18980) 2026-09-05 12:21:47 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> d1829c495d Bump prek from 0.5.0 to 0.5.1 (#18977)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-04 19:05:36 -04:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> ce87bf9b17 Bump platformdirs from 4.11.5 to 4.11.7 (#18976)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-04 19:05:26 -04:00
Jesse Hills 51ea97deff [esp32_ble] Reference count BLE advertising (#18943) 2026-09-05 08:38:55 +12:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> ab800dc09d Bump filelock from 3.32.4 to 3.32.5 (#18963)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-03 17:19:29 -04:00
J. Nick Kostonandpre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> f65ab5629e [esp8266] Drop Arduino framework versions before 3.0.0 (#18917) to
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-09-03 15:16:36 -04:00
esphome[bot]esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>Jonathan Swoboda
b84532d254 Bump bundled esphome-device-builder to 1.14.0 (#18960)
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-09-03 12:15:06 +00:00
Keith Burzinski 6b11636491 [remote_transmitter] Fix BK7231N build by limiting the PWM path to BK7238 (#18958) 2026-09-03 08:12:36 -04:00
Jesse Hills 2bb98f2d64 Merge branch 'beta' into dev 2026-09-03 14:07:41 +12:00
Jesse Hills f3c786c784 Bump version to 2026.10.0-dev 2026-09-03 13:07:21 +12:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 2250430999 Bump zeroconf from 0.151.2 to 0.151.3 (#18951)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-02 20:56:51 -04:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> d1068d582f Bump ninja from 1.13.0 to 1.13.2 (#18952)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-02 20:56:42 -04:00
14 changed files with 218 additions and 21 deletions
+1
View File
@@ -553,6 +553,7 @@ file does, and it is the authority when they disagree. The most useful starting
4. **Lint:** Run `prek` to ensure code is compliant.
5. **Commit:** Commit your changes. There is no strict format for commit messages.
6. **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title must start with a `[tag]` prefix. For component work, use the component name (e.g., `[display] Fix bug`, `[abc123] Add new component`); for changes to shared/core code that isn't tied to a single component, use `[core]` (e.g., `[core] Add validator`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made using the `.github/PULL_REQUEST_TEMPLATE.md` template - fill out all sections completely without removing any parts of the template.
7. **Comments:** When commenting on GitHub PRs or issues, don't tag contributors, especially bots. Avoid referring to list items (e.g. from reviews) with the form #nn - this will be interpreted by GitHub as a reference to issue or PR nn. Keep comments short and exclude irrelevant details, backstories, restatement of previous comments and anything that is already obvious to the reader.
* **Documentation Contributions:**
* Documentation is hosted in the separate `esphome/esphome.io` repository.
+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.9.0b2
PROJECT_NUMBER = 2026.10.0-dev
# 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
+2 -2
View File
@@ -35,8 +35,8 @@ void MipiDsi::setup() {
.bus_id = 0, // index from 0, specify the DSI host to use
.num_data_lanes =
this->lanes_, // Number of data lanes to use, can't set a value that exceeds the chip's capability
.phy_clk_src = MIPI_DSI_PHY_CLK_SRC_DEFAULT, // Clock source for the DPHY
.lane_bit_rate_mbps = this->lane_bit_rate_, // Bit rate of the data lanes, in Mbps
// phy_clk_src left at 0 to enable runtime auto-select.
.lane_bit_rate_mbps = this->lane_bit_rate_, // Bit rate of the data lanes, in Mbps
};
auto err = esp_lcd_new_dsi_bus(&bus_config, &this->bus_handle_);
if (err != ESP_OK) {
+15 -1
View File
@@ -66,13 +66,14 @@ from esphome.const import (
)
from esphome.core import (
CORE,
ID,
CoroPriority,
EsphomeError,
HexInt,
coroutine_with_priority,
)
import esphome.final_validate as fv
from esphome.types import ConfigType
from esphome.types import ConfigType, TemplateArgsType
from . import wpa2_eap
@@ -208,6 +209,7 @@ WiFiEnabledCondition = wifi_ns.class_("WiFiEnabledCondition", Condition)
WiFiAPActiveCondition = wifi_ns.class_("WiFiAPActiveCondition", Condition)
WiFiEnableAction = wifi_ns.class_("WiFiEnableAction", automation.Action)
WiFiDisableAction = wifi_ns.class_("WiFiDisableAction", automation.Action)
WiFiRoamAction = wifi_ns.class_("WiFiRoamAction", automation.Action)
WiFiConfigureAction = wifi_ns.class_(
"WiFiConfigureAction", automation.Action, cg.Component
)
@@ -820,6 +822,18 @@ async def wifi_disable_to_code(config, action_id, template_arg, args):
return cg.new_Pvariable(action_id, template_arg)
@automation.register_action(
"wifi.roam", WiFiRoamAction, cv.Schema({}), synchronous=True
)
async def wifi_roam_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> cg.MockObj:
return cg.new_Pvariable(action_id, template_arg)
KEEP_SCAN_RESULTS_KEY = "wifi_keep_scan_results"
RUNTIME_POWER_SAVE_KEY = "wifi_runtime_power_save"
RUNTIME_ROAMING_SUPPRESSION_KEY = "wifi_runtime_roaming_suppression"
+5
View File
@@ -31,6 +31,11 @@ template<typename... Ts> class WiFiDisableAction final : public Action<Ts...> {
void play(const Ts &...x) override { global_wifi_component->disable(); }
};
template<typename... Ts> class WiFiRoamAction final : public Action<Ts...> {
public:
void play(const Ts &...x) override { global_wifi_component->force_roam_check(); }
};
template<typename... Ts> class WiFiConfigureAction final : public Action<Ts...>, public Component {
public:
TEMPLATABLE_VALUE(std::string, ssid)
+27 -11
View File
@@ -846,17 +846,18 @@ void WiFiComponent::loop() {
this->notify_connect_state_listeners_();
#endif
// Post-connect roaming: check for better AP
if (this->post_connect_roaming_) {
if (this->is_roaming_scan_active()) {
if (this->scan_done_) {
this->process_roaming_scan_();
}
// else: scan in progress, wait
} else if (this->roaming_state_ == RoamingState::IDLE && this->roaming_attempts_ < ROAMING_MAX_ATTEMPTS &&
now - this->roaming_last_check_ >= ROAMING_CHECK_INTERVAL && !this->roaming_suppressed_()) {
this->check_roaming_(now);
// Post-connect roaming: check for better AP. A scan may have been started by an
// explicit force_roam_check() even when post_connect_roaming_ is disabled, so the
// scan must always be consumed here to avoid leaving roaming_state_ stuck.
if (this->is_roaming_scan_active()) {
if (this->scan_done_) {
this->process_roaming_scan_();
}
// else: scan in progress, wait
} else if (this->post_connect_roaming_ && this->roaming_state_ == RoamingState::IDLE &&
this->roaming_attempts_ < ROAMING_MAX_ATTEMPTS &&
now - this->roaming_last_check_ >= ROAMING_CHECK_INTERVAL && !this->roaming_suppressed_()) {
this->check_roaming_(now);
}
}
break;
@@ -2463,6 +2464,17 @@ void WiFiComponent::notify_scan_results_listeners_() {
}
#endif // USE_WIFI_SCAN_RESULTS_LISTENERS
void WiFiComponent::force_roam_check() {
if (!this->is_connected() || this->roaming_state_ != RoamingState::IDLE || this->roaming_suppressed_()) {
ESP_LOGD(TAG, "Roam check requested, but not able to check now");
return;
}
// Reset the attempt counter so a prior run of failed roams doesn't block this explicit request
// Note that this re-arms automatic roaming if enabled.
this->roaming_attempts_ = 0;
this->check_roaming_(millis());
}
void WiFiComponent::check_roaming_(uint32_t now) {
// Guard: not for hidden networks (may not appear in scan)
const WiFiAP *selected = this->get_selected_sta_();
@@ -2484,7 +2496,11 @@ void WiFiComponent::check_roaming_(uint32_t now) {
ESP_LOGD(TAG, "Roam scan (%d dBm, attempt %u/%u)", rssi, this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
this->roaming_state_ = RoamingState::SCANNING;
this->wifi_scan_start_(this->passive_scan_);
if (!this->wifi_scan_start_(this->passive_scan_)) {
// Scan failed to start (e.g. busy) - don't get stuck in SCANNING forever
ESP_LOGD(TAG, "Roam scan failed to start");
this->roaming_state_ = RoamingState::IDLE;
}
}
void WiFiComponent::process_roaming_scan_() {
+6
View File
@@ -565,6 +565,12 @@ class WiFiComponent final : public Component {
void set_keep_scan_results(bool keep_scan_results) { this->keep_scan_results_ = keep_scan_results; }
void set_post_connect_roaming(bool enabled) { this->post_connect_roaming_ = enabled; }
/** Force an immediate post-connect roaming check, bypassing the periodic interval and the
* per-connection attempt limit. Does nothing (besides a debug log) if not connected, if a
* roam scan or connect is already in progress, or if roaming is currently suppressed.
*/
void force_roam_check();
#ifdef USE_WIFI_CONNECT_TRIGGER
Trigger<> *get_connect_trigger() { return &this->connect_trigger_; }
#endif
+1 -1
View File
@@ -4,7 +4,7 @@ from enum import Enum
from esphome.enum import StrEnum
__version__ = "2026.9.0b2"
__version__ = "2026.10.0-dev"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = (
+50
View File
@@ -389,6 +389,7 @@ class Application {
friend Component;
friend class Scheduler;
friend class LoopBlockingGuard;
friend class UnavoidableBlockingScope;
#ifdef USE_RUNTIME_STATS
friend class runtime_stats::RuntimeStatsCollector;
#endif
@@ -631,6 +632,55 @@ class LoopBlockingGuard {
static void __attribute__((noinline, cold)) warn_blocking(uint32_t blocking_time);
};
/// Leaves a stretch of the current loop pass out of the blocking warning.
///
/// Only for work done from a loop pass that cannot be made shorter and
/// cannot be split across passes: turning on a radio, the first Wi-Fi
/// connect, a key generation whose cost is the algorithm itself. The warning
/// then keeps reporting everything else in the pass, and the component's
/// threshold does not ratchet up over the one step nothing can be done about.
///
/// Never use it to paper over a problem that can be solved. A slow driver
/// call, a loop that could be a state machine, a computation that could be
/// cached or deferred, a blocking read that could be polled: those are what
/// the warning exists to find, and wrapping them in this scope hides the
/// bug instead of fixing it. If in doubt, leave the warning in.
///
/// Only work timed by a LoopBlockingGuard is affected, that is a component's
/// loop() or a scheduler callback; setup() is not timed by the guard, so the
/// scope has no effect on the warning there. Main loop task only. The watchdog is not fed inside the
/// scope, so the work must finish within the watchdog timeout, or be paired
/// with a watchdog::WatchdogManager that raises the timeout for the same
/// stretch. Scopes may nest; the outermost one decides how much of the pass
/// is left out.
/// App.get_loop_component_start_time() reads later in the same pass return
/// the moved start, so elapsed time across the scope needs millis().
///
/// void MyComponent::loop() {
/// if (this->needs_key_) {
/// UnavoidableBlockingScope scope;
/// this->generate_key_();
/// }
/// }
class UnavoidableBlockingScope {
public:
UnavoidableBlockingScope() : started_(MillisInternal::get()), pass_start_(App.get_loop_component_start_time()) {}
~UnavoidableBlockingScope() {
// Move the pass start seen at entry forward by the time spent here, so an
// outer scope overrides an inner one instead of adding to it; never past
// now, which would underflow the guard's subtraction
const uint32_t now = MillisInternal::get();
const uint32_t moved = this->pass_start_ + (now - this->started_);
App.set_loop_component_start_time_(static_cast<int32_t>(now - moved) < 0 ? now : moved);
}
UnavoidableBlockingScope(const UnavoidableBlockingScope &) = delete;
UnavoidableBlockingScope &operator=(const UnavoidableBlockingScope &) = delete;
private:
uint32_t started_;
uint32_t pass_start_;
};
// Phase A: drain wake notifications and run the scheduler. Invoked on every
// Application::loop() tick regardless of whether a component phase runs, so
// scheduler items fire at their requested cadence even when the caller has
+1
View File
@@ -51,6 +51,7 @@ class MillisInternal {
}
friend class Application;
friend class LoopBlockingGuard;
friend class UnavoidableBlockingScope;
};
} // namespace esphome
+4 -4
View File
@@ -14,7 +14,7 @@ esptool==5.3.1
click==8.3.3
aioesphomeapi==46.3.0
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
zeroconf==0.151.2
zeroconf==0.151.3
puremagic==2.2.0
ruamel.yaml==0.19.1 # dashboard_import
ruamel.yaml.clib==0.2.15 # dashboard_import
@@ -27,9 +27,9 @@ bleak==3.0.2
smpclient==7.2.0
requests==2.34.2
py7zr==1.1.3
platformdirs==4.11.5 # native esp-idf toolchain global cache dir
ninja==1.13.0 # native esp8266 arduino toolchain build driver
filelock==3.32.4 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg
platformdirs==4.11.7 # native esp-idf toolchain global cache dir
ninja==1.13.2 # native esp8266 arduino toolchain build driver
filelock==3.32.5 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg
# esp-idf >= 5.0 requires this
pyparsing >= 3.3.2
+1 -1
View File
@@ -2,7 +2,7 @@ pylint==4.0.8
flake8==7.3.0 # also change in .pre-commit-config.yaml when updating
ruff==0.16.5 # also change in .pre-commit-config.yaml when updating
pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating
prek==0.5.0 # also change in .github/workflows/ci.yml when updating
prek==0.5.1 # also change in .github/workflows/ci.yml when updating
# Unit tests
pytest==9.1.1
@@ -0,0 +1,103 @@
#include <gtest/gtest.h>
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
namespace esphome {
// The scope must push the pass start forward by the time it covers and by
// nothing else, so the blocking guard sees only the work outside it
TEST(UnavoidableBlockingScope, ExcludesItsDurationFromThePass) {
const uint32_t pass_start = millis();
LoopBlockingGuard guard(nullptr, nullptr, pass_start);
ASSERT_EQ(App.get_loop_component_start_time(), pass_start);
const uint32_t before = millis();
{
UnavoidableBlockingScope scope;
delay(30);
}
const uint32_t excused = millis() - before;
const uint32_t moved = App.get_loop_component_start_time() - pass_start;
EXPECT_GE(moved, 30u);
EXPECT_LE(moved, excused);
}
TEST(UnavoidableBlockingScope, ZeroLengthScopeLeavesTheStartAlone) {
const uint32_t pass_start = millis();
LoopBlockingGuard guard(nullptr, nullptr, pass_start);
const uint32_t before = millis();
{ UnavoidableBlockingScope scope; }
EXPECT_LE(App.get_loop_component_start_time() - pass_start, millis() - before);
}
// Nested scopes leave out the outer span exactly once, and never move the
// start past now
TEST(UnavoidableBlockingScope, NestedScopesExcuseTheOuterSpanOnce) {
const uint32_t pass_start = millis();
LoopBlockingGuard guard(nullptr, nullptr, pass_start);
const uint32_t before = millis();
{
UnavoidableBlockingScope outer;
{
UnavoidableBlockingScope inner;
delay(30);
}
delay(5);
}
const uint32_t excused = millis() - before;
const uint32_t moved = App.get_loop_component_start_time() - pass_start;
EXPECT_GE(moved, 35u);
EXPECT_LE(moved, excused);
EXPECT_GE(static_cast<int32_t>(millis() - App.get_loop_component_start_time()), 0);
}
namespace {
// Static: the guard publishes the component to App and nothing clears it.
// One instance per test, since a ratcheted threshold is permanent
class DummyComponent : public Component {};
DummyComponent &blocking_test_component(size_t index) {
static DummyComponent components[2];
return components[index];
}
} // namespace
// The excused stretch must neither warn nor ratchet the component's threshold
TEST(UnavoidableBlockingScope, ExcusedStretchDoesNotRatchetTheThreshold) {
DummyComponent &component = blocking_test_component(0);
uint32_t threshold_before = 0;
component.should_warn_of_blocking(0, threshold_before);
{
LoopBlockingGuard guard(&component, nullptr, millis());
{
UnavoidableBlockingScope scope;
delay(WARN_IF_BLOCKING_OVER_CS * 10U + 20);
}
guard.finish();
}
uint32_t threshold_after = 0;
component.should_warn_of_blocking(0, threshold_after);
EXPECT_EQ(threshold_after, threshold_before);
}
// Work outside the scope is still measured and still ratchets
TEST(UnavoidableBlockingScope, WorkOutsideTheScopeStillRatchetsTheThreshold) {
DummyComponent &component = blocking_test_component(1);
uint32_t threshold_before = 0;
component.should_warn_of_blocking(0, threshold_before);
{
LoopBlockingGuard guard(&component, nullptr, millis());
{
UnavoidableBlockingScope scope;
delay(20);
}
delay(WARN_IF_BLOCKING_OVER_CS * 10U + 20);
guard.finish();
}
uint32_t threshold_after = 0;
component.should_warn_of_blocking(0, threshold_after);
EXPECT_GT(threshold_after, threshold_before);
}
} // namespace esphome
+1
View File
@@ -14,6 +14,7 @@ esphome:
condition: wifi.ap_active
then:
- logger.log: "WiFi AP is active!"
- wifi.roam
wifi:
networks: