mirror of
https://github.com/esphome/esphome.git
synced 2026-08-22 22:26:21 +00:00
@@ -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.7.0b3
|
||||
PROJECT_NUMBER = 2026.7.0b4
|
||||
|
||||
# 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
@@ -22,7 +22,7 @@ RUN \
|
||||
-r /requirements.txt
|
||||
|
||||
# Install the ESPHome Device Builder dashboard.
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.5.0
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.6.1
|
||||
|
||||
RUN \
|
||||
platformio settings set enable_telemetry No \
|
||||
|
||||
@@ -166,10 +166,14 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
#endif
|
||||
bool try_send_log_message(int level, const char *tag, const char *line, size_t message_len);
|
||||
#ifdef USE_API_HOMEASSISTANT_SERVICES
|
||||
void send_homeassistant_action(const HomeassistantActionRequest &call) {
|
||||
// Returns whether this client has subscribed to Home Assistant actions; the message
|
||||
// is only handed to the send path when subscribed. A true return does not guarantee
|
||||
// delivery - it lets the caller warn when no connected client has the subscription.
|
||||
bool send_homeassistant_action(const HomeassistantActionRequest &call) {
|
||||
if (!this->flags_.service_call_subscription)
|
||||
return;
|
||||
return false;
|
||||
this->send_message(call);
|
||||
return true;
|
||||
}
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
|
||||
void on_homeassistant_action_response(const HomeassistantActionResponse &msg);
|
||||
|
||||
@@ -426,8 +426,16 @@ void APIServer::set_batch_delay(uint16_t batch_delay) { this->batch_delay_ = bat
|
||||
|
||||
#ifdef USE_API_HOMEASSISTANT_SERVICES
|
||||
void APIServer::send_homeassistant_action(const HomeassistantActionRequest &call) {
|
||||
bool has_subscriber = false;
|
||||
for (auto &client : this->active_clients()) {
|
||||
client->send_homeassistant_action(call);
|
||||
has_subscriber |= client->send_homeassistant_action(call);
|
||||
}
|
||||
if (!has_subscriber) {
|
||||
// Home Assistant subscribes to actions shortly *after* authenticating, so actions
|
||||
// fired right at connection time (on_client_connected, on_time_sync, ...) can
|
||||
// arrive before the subscription and are lost - warn instead of failing silently.
|
||||
ESP_LOGW(TAG, "Home Assistant %s '%s' dropped; %s", call.is_event ? "event" : "action", call.service.c_str(),
|
||||
this->is_connected() ? "client has not subscribed to actions (yet)" : "no client connected");
|
||||
}
|
||||
}
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
|
||||
|
||||
@@ -65,12 +65,11 @@ optional<ParseResult> ATCMiThermometer::parse_header_(const esp32_ble_tracker::S
|
||||
return {};
|
||||
}
|
||||
|
||||
static uint8_t last_frame_count = 0;
|
||||
if (last_frame_count == raw[12]) {
|
||||
ESP_LOGVV(TAG, "parse_header(): duplicate data packet received (%hhu).", last_frame_count);
|
||||
if (this->last_frame_count_ == raw[12]) {
|
||||
ESP_LOGVV(TAG, "parse_header(): duplicate data packet received (%hhu).", this->last_frame_count_);
|
||||
return {};
|
||||
}
|
||||
last_frame_count = raw[12];
|
||||
this->last_frame_count_ = raw[12];
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ class ATCMiThermometer final : public Component, public esp32_ble_tracker::ESPBT
|
||||
sensor::Sensor *battery_voltage_{nullptr};
|
||||
sensor::Sensor *signal_strength_{nullptr};
|
||||
|
||||
uint8_t last_frame_count_{0};
|
||||
|
||||
optional<ParseResult> parse_header_(const esp32_ble_tracker::ServiceData &service_data);
|
||||
bool parse_message_(const std::vector<uint8_t> &message, ParseResult &result);
|
||||
bool report_results_(const optional<ParseResult> &result, const char *address);
|
||||
|
||||
@@ -19,6 +19,10 @@ void MCP23017::setup() {
|
||||
this->read_reg(mcp23x17_base::MCP23X17_OLATA, &this->olat_a_);
|
||||
this->read_reg(mcp23x17_base::MCP23X17_OLATB, &this->olat_b_);
|
||||
|
||||
// Reset IPOL to 0x00: ESPHome handles 'inverted' in software.
|
||||
this->write_reg(mcp23x17_base::MCP23X17_IPOLA, 0x00);
|
||||
this->write_reg(mcp23x17_base::MCP23X17_IPOLB, 0x00);
|
||||
|
||||
uint8_t iocon_flags = 0;
|
||||
if (this->open_drain_ints_) {
|
||||
iocon_flags |= IOCON_ODR;
|
||||
|
||||
@@ -274,7 +274,7 @@ async def attributes_to_code(
|
||||
async def esp32_to_code(config: ConfigType) -> "MockObj":
|
||||
add_idf_component(
|
||||
name="espressif/esp-zigbee-lib",
|
||||
ref="2.0.2",
|
||||
ref="2.0.3",
|
||||
)
|
||||
|
||||
# add sdkconfigs later so they can overwrite esp32 defaults
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ from enum import Enum
|
||||
|
||||
from esphome.enum import StrEnum
|
||||
|
||||
__version__ = "2026.7.0b3"
|
||||
__version__ = "2026.7.0b4"
|
||||
|
||||
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
|
||||
VALID_SUBSTITUTIONS_CHARACTERS = (
|
||||
|
||||
@@ -502,6 +502,9 @@ template<typename... Ts> class WaitUntilAction : public Action<Ts...>, public Co
|
||||
|
||||
void stop() override {
|
||||
this->var_queue_.clear();
|
||||
// Tell any process_queue_() call further down the stack that the items it is
|
||||
// still holding were cancelled
|
||||
this->stop_generation_++;
|
||||
this->disable_loop();
|
||||
}
|
||||
|
||||
@@ -511,33 +514,57 @@ template<typename... Ts> class WaitUntilAction : public Action<Ts...>, public Co
|
||||
}
|
||||
|
||||
protected:
|
||||
using QueueItem = std::tuple<uint32_t, optional<uint32_t>, std::tuple<Ts...>>;
|
||||
|
||||
// Helper: Process queue, triggering completed items and removing them
|
||||
// Returns true if queue still has pending items
|
||||
bool process_queue_(uint32_t now) {
|
||||
// Process each queued wait_until and remove completed ones
|
||||
this->var_queue_.remove_if([&](auto &queued) {
|
||||
auto start = std::get<uint32_t>(queued);
|
||||
auto timeout = std::get<optional<uint32_t>>(queued);
|
||||
auto &var = std::get<std::tuple<Ts...>>(queued);
|
||||
// Completed items run the rest of the action chain synchronously, and that chain
|
||||
// can re-enter this same action (e.g. a script with mode: restart that executes
|
||||
// itself) and add to or clear var_queue_. Iterating the member list directly would
|
||||
// then corrupt it, so move it aside and iterate a local list instead.
|
||||
std::list<QueueItem> queue;
|
||||
queue.swap(this->var_queue_);
|
||||
std::list<QueueItem> pending;
|
||||
while (!queue.empty()) {
|
||||
auto it = queue.begin();
|
||||
auto start = std::get<uint32_t>(*it);
|
||||
auto timeout = std::get<optional<uint32_t>>(*it);
|
||||
|
||||
// Check if timeout has expired
|
||||
auto expired = timeout && (now - start) >= *timeout;
|
||||
|
||||
// Keep waiting if not expired and condition not met
|
||||
if (!expired && !this->condition_->check_tuple(var)) {
|
||||
return false;
|
||||
if (!expired && !this->condition_->check_tuple(std::get<std::tuple<Ts...>>(*it))) {
|
||||
pending.splice(pending.end(), queue, it);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Condition met or timed out - trigger next action
|
||||
this->play_next_tuple_(var);
|
||||
return true;
|
||||
});
|
||||
// Condition met or timed out - trigger the next action. Keep the item in a local
|
||||
// holder so its arguments stay valid while the chain runs, without any nested
|
||||
// process_queue_() call being able to see (and fire) it again.
|
||||
std::list<QueueItem> completed;
|
||||
completed.splice(completed.begin(), queue, it);
|
||||
uint8_t generation = this->stop_generation_;
|
||||
this->play_next_tuple_(std::get<std::tuple<Ts...>>(completed.front()));
|
||||
if (generation != this->stop_generation_) {
|
||||
// stop() ran inside the chain - the items still held locally were cancelled
|
||||
pending.clear();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-entrant continuations may have enqueued new waits into var_queue_; put the
|
||||
// older still-waiting items back in front of them to keep FIFO firing order
|
||||
this->var_queue_.splice(this->var_queue_.begin(), pending);
|
||||
|
||||
return !this->var_queue_.empty();
|
||||
}
|
||||
|
||||
Condition<Ts...> *condition_;
|
||||
std::list<std::tuple<uint32_t, optional<uint32_t>, std::tuple<Ts...>>> var_queue_{};
|
||||
std::list<QueueItem> var_queue_{};
|
||||
// Bumped by stop() so process_queue_() can detect a stop from inside play_next_tuple_()
|
||||
uint8_t stop_generation_{0};
|
||||
};
|
||||
|
||||
template<typename... Ts> class UpdateComponentAction : public Action<Ts...> {
|
||||
|
||||
@@ -48,7 +48,7 @@ dependencies:
|
||||
rules:
|
||||
- if: "target in [esp32, esp32p4]"
|
||||
espressif/esp-zigbee-lib:
|
||||
version: 2.0.2
|
||||
version: 2.0.3
|
||||
rules:
|
||||
- if: "target in [esp32h2, esp32c5, esp32c6]"
|
||||
espressif/lan87xx:
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
esphome:
|
||||
name: test-ha-action-no-subscriber
|
||||
friendly_name: Home Assistant Action No Subscriber Test
|
||||
on_boot:
|
||||
# Fires before any client is connected - dropped with a warning.
|
||||
- homeassistant.action:
|
||||
action: test.boot_action
|
||||
|
||||
host:
|
||||
|
||||
api:
|
||||
on_client_connected:
|
||||
# Fires at authentication time, before the client has subscribed to
|
||||
# Home Assistant actions - dropped with a warning.
|
||||
- homeassistant.action:
|
||||
action: test.connected_action
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: Send Action Button
|
||||
id: send_action_button
|
||||
on_press:
|
||||
# Pressed only after the client has subscribed - must be delivered.
|
||||
- homeassistant.action:
|
||||
action: test.button_action
|
||||
data:
|
||||
value: subscribed
|
||||
@@ -0,0 +1,86 @@
|
||||
esphome:
|
||||
name: wait-until-reentrant-restart
|
||||
|
||||
host:
|
||||
|
||||
api:
|
||||
actions:
|
||||
- action: start_self_restart
|
||||
then:
|
||||
- script.execute: retry_script
|
||||
- action: start_stop_during_wait
|
||||
then:
|
||||
- globals.set:
|
||||
id: gate_open
|
||||
value: 'false'
|
||||
# num 0 is a blocker: its condition never becomes true, so it is still
|
||||
# waiting (already checked and set aside) when num 1 stops the script -
|
||||
# it must be cancelled, not restored, so its timeout must never fire
|
||||
- script.execute:
|
||||
id: waiter
|
||||
num: 0
|
||||
- script.execute:
|
||||
id: waiter
|
||||
num: 1
|
||||
- script.execute:
|
||||
id: waiter
|
||||
num: 2
|
||||
- script.execute:
|
||||
id: waiter
|
||||
num: 3
|
||||
# Give all three instances time to queue in the same wait_until
|
||||
- delay: 100ms
|
||||
- globals.set:
|
||||
id: gate_open
|
||||
value: 'true'
|
||||
- delay: 200ms
|
||||
- logger.log: "stop test complete"
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
globals:
|
||||
- id: attempt
|
||||
type: int
|
||||
initial_value: '0'
|
||||
- id: gate_open
|
||||
type: bool
|
||||
initial_value: 'false'
|
||||
|
||||
script:
|
||||
# Self-restart retry pattern: when the wait_until times out, the rest of the
|
||||
# script runs synchronously from inside the wait queue processing and restarts
|
||||
# this same script - re-entering the same WaitUntilAction while it is still
|
||||
# processing its queue. This used to corrupt the queue and crash.
|
||||
- id: retry_script
|
||||
mode: restart
|
||||
then:
|
||||
- wait_until:
|
||||
condition:
|
||||
lambda: 'return false;'
|
||||
timeout: 20ms
|
||||
- lambda: |-
|
||||
id(attempt) += 1;
|
||||
ESP_LOGD("test", "attempt %d done", id(attempt));
|
||||
- if:
|
||||
condition:
|
||||
lambda: 'return id(attempt) < 5;'
|
||||
then:
|
||||
- script.execute: retry_script
|
||||
else:
|
||||
- logger.log: "retry test complete"
|
||||
|
||||
# Parallel waiters all queued in the same wait_until; the first one to pass the
|
||||
# gate stops the script from its continuation, cancelling the other waiters
|
||||
# while the queue is still being processed.
|
||||
- id: waiter
|
||||
mode: parallel
|
||||
parameters:
|
||||
num: int
|
||||
then:
|
||||
- wait_until:
|
||||
condition:
|
||||
lambda: 'return num != 0 && id(gate_open);'
|
||||
timeout: 1s
|
||||
- lambda: 'ESP_LOGD("test", "gate passed %d", num);'
|
||||
- script.stop: waiter
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Integration test for Home Assistant actions fired without a subscriber.
|
||||
|
||||
Home Assistant subscribes to device actions shortly after authenticating, while
|
||||
on_client_connected (and similar triggers) fire right at authentication. Actions
|
||||
fired before any client has subscribed cannot be delivered - they must produce a
|
||||
warning in the log instead of vanishing silently.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from aioesphomeapi import ButtonInfo, HomeassistantServiceCall
|
||||
import pytest
|
||||
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_homeassistant_action_no_subscriber(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Undeliverable actions warn in the log; actions after subscribing arrive."""
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
boot_warning_future = loop.create_future()
|
||||
connected_warning_future = loop.create_future()
|
||||
button_action_future = loop.create_future()
|
||||
|
||||
def check_output(line: str) -> None:
|
||||
if (
|
||||
not boot_warning_future.done()
|
||||
and "Home Assistant action 'test.boot_action' dropped; no client connected"
|
||||
in line
|
||||
):
|
||||
boot_warning_future.set_result(True)
|
||||
if (
|
||||
not connected_warning_future.done()
|
||||
and "Home Assistant action 'test.connected_action' dropped; "
|
||||
"client has not subscribed to actions (yet)"
|
||||
in line
|
||||
):
|
||||
connected_warning_future.set_result(True)
|
||||
|
||||
service_calls: list[HomeassistantServiceCall] = []
|
||||
|
||||
def on_service_call(service_call: HomeassistantServiceCall) -> None:
|
||||
service_calls.append(service_call)
|
||||
if (
|
||||
service_call.service == "test.button_action"
|
||||
and not button_action_future.done()
|
||||
):
|
||||
button_action_future.set_result(service_call)
|
||||
|
||||
async with run_compiled(yaml_config, line_callback=check_output):
|
||||
# The on_boot action fires with no client connected at all.
|
||||
await asyncio.wait_for(boot_warning_future, timeout=10.0)
|
||||
|
||||
async with api_client_connected() as client:
|
||||
device_info = await client.device_info()
|
||||
assert device_info.name == "test-ha-action-no-subscriber"
|
||||
|
||||
# on_client_connected fired at authentication, before this client
|
||||
# subscribed to Home Assistant actions.
|
||||
await asyncio.wait_for(connected_warning_future, timeout=5.0)
|
||||
|
||||
# After subscribing, actions must be delivered normally (and the
|
||||
# dropped ones must not suddenly show up).
|
||||
client.subscribe_service_calls(on_service_call)
|
||||
|
||||
entities, _ = await client.list_entities_services()
|
||||
button = next(e for e in entities if isinstance(e, ButtonInfo))
|
||||
client.button_command(button.key)
|
||||
|
||||
button_call = await asyncio.wait_for(button_action_future, timeout=5.0)
|
||||
assert button_call.data == {"value": "subscribed"}
|
||||
assert [call.service for call in service_calls] == ["test.button_action"]
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Integration test for wait_until queue reentrancy.
|
||||
|
||||
When a wait_until completes, the rest of the action chain runs synchronously
|
||||
from inside the wait queue processing. That chain can re-enter the very same
|
||||
WaitUntilAction - for example a script with mode: restart that executes itself
|
||||
as a retry pattern, or a waiter that stops its own script. Both used to mutate
|
||||
the std::list while it was being iterated, corrupting it and crashing the
|
||||
device (Guru Meditation StoreProhibited in _M_transfer).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_until_reentrant_restart(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test that re-entering a wait_until from its own continuation is safe."""
|
||||
retry_complete = asyncio.Event()
|
||||
stop_complete = asyncio.Event()
|
||||
|
||||
attempt_pattern = re.compile(r"attempt (\d+) done")
|
||||
gate_pattern = re.compile(r"gate passed (\d+)")
|
||||
|
||||
attempts: list[int] = []
|
||||
gate_passed: list[int] = []
|
||||
|
||||
def check_output(line: str) -> None:
|
||||
"""Check log output for expected messages."""
|
||||
if mo := attempt_pattern.search(line):
|
||||
attempts.append(int(mo.group(1)))
|
||||
elif mo := gate_pattern.search(line):
|
||||
gate_passed.append(int(mo.group(1)))
|
||||
elif "retry test complete" in line:
|
||||
retry_complete.set()
|
||||
elif "stop test complete" in line:
|
||||
stop_complete.set()
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=check_output),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
device_info = await client.device_info()
|
||||
assert device_info is not None
|
||||
assert device_info.name == "wait-until-reentrant-restart"
|
||||
|
||||
_, services = await client.list_entities_services()
|
||||
self_restart_service = next(
|
||||
(s for s in services if s.name == "start_self_restart"), None
|
||||
)
|
||||
assert self_restart_service is not None, "start_self_restart not found"
|
||||
stop_service = next(
|
||||
(s for s in services if s.name == "start_stop_during_wait"), None
|
||||
)
|
||||
assert stop_service is not None, "start_stop_during_wait not found"
|
||||
|
||||
# Scenario 1: the wait_until timeout continuation restarts its own
|
||||
# script five times, re-entering the same wait_until each time.
|
||||
await client.execute_service(self_restart_service, {})
|
||||
try:
|
||||
await asyncio.wait_for(retry_complete.wait(), timeout=10.0)
|
||||
except TimeoutError:
|
||||
pytest.fail(f"Self-restart retry did not finish. Attempts: {attempts}")
|
||||
assert attempts == [1, 2, 3, 4, 5], attempts
|
||||
|
||||
# Scenario 2: the first waiter through the gate stops the script while
|
||||
# the other waiters are still queued in the same wait_until; both the
|
||||
# not-yet-checked waiters (2, 3) and the already-checked still-waiting
|
||||
# blocker (0) must be cancelled, not fired.
|
||||
await client.execute_service(stop_service, {})
|
||||
try:
|
||||
await asyncio.wait_for(stop_complete.wait(), timeout=10.0)
|
||||
except TimeoutError:
|
||||
pytest.fail(f"Stop-during-wait did not finish. Gate passed: {gate_passed}")
|
||||
assert gate_passed == [1], gate_passed
|
||||
|
||||
# If the cancelled blocker had been kept, its 1s wait_until timeout
|
||||
# would still fire - give it the chance and check it stays silent.
|
||||
await asyncio.sleep(1.5)
|
||||
assert gate_passed == [1], gate_passed
|
||||
Reference in New Issue
Block a user