Merge remote-tracking branch 'upstream/dev' into integration

This commit is contained in:
J. Nick Koston
2026-02-03 01:25:44 +01:00
8 changed files with 226 additions and 30 deletions
+12
View File
@@ -443,6 +443,18 @@ async def to_code(config):
var.get_disconnect_trigger(), [], on_disconnect_config
)
if on_connect_config := config.get(CONF_ON_CONNECT):
cg.add_define("USE_ETHERNET_CONNECT_TRIGGER")
await automation.build_automation(
var.get_connect_trigger(), [], on_connect_config
)
if on_disconnect_config := config.get(CONF_ON_DISCONNECT):
cg.add_define("USE_ETHERNET_DISCONNECT_TRIGGER")
await automation.build_automation(
var.get_disconnect_trigger(), [], on_disconnect_config
)
CORE.add_job(final_step)
+2 -1
View File
@@ -139,7 +139,8 @@ class MQTTBackendESP32 final : public MQTTBackend {
this->lwt_retain_ = retain;
}
void set_server(network::IPAddress ip, uint16_t port) final {
this->host_ = ip.str();
char ip_buf[network::IP_ADDRESS_BUFFER_SIZE];
this->host_ = ip.str_to(ip_buf);
this->port_ = port;
}
void set_server(const char *host, uint16_t port) final {
+4
View File
@@ -359,6 +359,10 @@ void Component::defer(const std::string &name, std::function<void()> &&f) { //
void Component::defer(const char *name, std::function<void()> &&f) { // NOLINT
App.scheduler.set_timeout(this, name, 0, std::move(f));
}
void Component::defer(uint32_t id, std::function<void()> &&f) { // NOLINT
App.scheduler.set_timeout(this, id, 0, std::move(f));
}
bool Component::cancel_defer(uint32_t id) { return App.scheduler.cancel_timeout(this, id); }
void Component::set_timeout(uint32_t timeout, std::function<void()> &&f) { // NOLINT
App.scheduler.set_timeout(this, static_cast<const char *>(nullptr), timeout, std::move(f));
}
+4
View File
@@ -494,11 +494,15 @@ class Component {
/// Defer a callback to the next loop() call.
void defer(std::function<void()> &&f); // NOLINT
/// Defer a callback with a numeric ID (zero heap allocation)
void defer(uint32_t id, std::function<void()> &&f); // NOLINT
/// Cancel a defer callback using the specified name, name must not be empty.
// Remove before 2026.7.0
ESPDEPRECATED("Use const char* overload instead. Removed in 2026.7.0", "2026.1.0")
bool cancel_defer(const std::string &name); // NOLINT
bool cancel_defer(const char *name); // NOLINT
bool cancel_defer(uint32_t id); // NOLINT
// Ordered for optimal packing on 32-bit systems
const LogString *component_source_{nullptr};
+45 -21
View File
@@ -1,5 +1,7 @@
import base64
from pathlib import Path
import random
import secrets
import string
from typing import Literal, NotRequired, TypedDict, Unpack
import unicodedata
@@ -116,7 +118,6 @@ class WizardFileKwargs(TypedDict):
board: str
ssid: NotRequired[str]
psk: NotRequired[str]
password: NotRequired[str]
ota_password: NotRequired[str]
api_encryption_key: NotRequired[str]
friendly_name: NotRequired[str]
@@ -144,9 +145,7 @@ def wizard_file(**kwargs: Unpack[WizardFileKwargs]) -> str:
config += API_CONFIG
# Configure API
if "password" in kwargs:
config += f' password: "{kwargs["password"]}"\n'
# Configure API encryption
if "api_encryption_key" in kwargs:
config += f' encryption:\n key: "{kwargs["api_encryption_key"]}"\n'
@@ -155,8 +154,6 @@ def wizard_file(**kwargs: Unpack[WizardFileKwargs]) -> str:
config += " - platform: esphome\n"
if "ota_password" in kwargs:
config += f' password: "{kwargs["ota_password"]}"'
elif "password" in kwargs:
config += f' password: "{kwargs["password"]}"'
# Configuring wifi
config += "\n\nwifi:\n"
@@ -205,7 +202,6 @@ class WizardWriteKwargs(TypedDict):
platform: NotRequired[str]
ssid: NotRequired[str]
psk: NotRequired[str]
password: NotRequired[str]
ota_password: NotRequired[str]
api_encryption_key: NotRequired[str]
friendly_name: NotRequired[str]
@@ -232,7 +228,7 @@ def wizard_write(path: Path, **kwargs: Unpack[WizardWriteKwargs]) -> bool:
else: # "basic"
board = kwargs["board"]
for key in ("ssid", "psk", "password", "ota_password"):
for key in ("ssid", "psk", "ota_password"):
if key in kwargs:
kwargs[key] = sanitize_double_quotes(kwargs[key])
if "platform" not in kwargs:
@@ -522,26 +518,54 @@ def wizard(path: Path) -> int:
"Almost there! ESPHome can automatically upload custom firmwares over WiFi "
"(over the air) and integrates into Home Assistant with a native API."
)
safe_print()
sleep(0.5)
# Generate encryption key (32 bytes, base64 encoded) for secure API communication
noise_psk = secrets.token_bytes(32)
api_encryption_key = base64.b64encode(noise_psk).decode()
safe_print(
f"This can be insecure if you do not trust the WiFi network. Do you want to set a {color(AnsiFore.GREEN, 'password')} for connecting to this ESP?"
"For secure API communication, I've generated a random encryption key."
)
safe_print()
safe_print(
f"Your {color(AnsiFore.GREEN, 'API encryption key')} is: "
f"{color(AnsiFore.BOLD_WHITE, api_encryption_key)}"
)
safe_print()
safe_print("You'll need this key when adding the device to Home Assistant.")
sleep(1)
safe_print()
safe_print(
f"Do you want to set a {color(AnsiFore.GREEN, 'password')} for OTA updates? "
"This can be insecure if you do not trust the WiFi network."
)
safe_print()
sleep(0.25)
safe_print("Press ENTER for no password")
password = safe_input(color(AnsiFore.BOLD_WHITE, "(password): "))
ota_password = safe_input(color(AnsiFore.BOLD_WHITE, "(password): "))
else:
ssid, password, psk = "", "", ""
ssid, psk = "", ""
api_encryption_key = None
ota_password = ""
if not wizard_write(
path=path,
name=name,
platform=platform,
board=board,
ssid=ssid,
psk=psk,
password=password,
type="basic",
):
kwargs = {
"path": path,
"name": name,
"platform": platform,
"board": board,
"ssid": ssid,
"psk": psk,
"type": "basic",
}
if api_encryption_key:
kwargs["api_encryption_key"] = api_encryption_key
if ota_password:
kwargs["ota_password"] = ota_password
if not wizard_write(**kwargs):
return 1
safe_print()
@@ -20,6 +20,9 @@ globals:
- id: retry_counter
type: int
initial_value: '0'
- id: defer_counter
type: int
initial_value: '0'
- id: tests_done
type: bool
initial_value: 'false'
@@ -136,11 +139,49 @@ script:
App.scheduler.cancel_retry(component1, 6002U);
ESP_LOGI("test", "Cancelled numeric retry 6002");
// Test 12: defer with numeric ID (Component method)
class TestDeferComponent : public Component {
public:
void test_defer_methods() {
// Test defer with uint32_t ID - should execute on next loop
this->defer(7001U, []() {
ESP_LOGI("test", "Component numeric defer 7001 fired");
id(defer_counter) += 1;
});
// Test another defer with numeric ID
this->defer(7002U, []() {
ESP_LOGI("test", "Component numeric defer 7002 fired");
id(defer_counter) += 1;
});
}
};
static TestDeferComponent test_defer_component;
test_defer_component.test_defer_methods();
// Test 13: cancel_defer with numeric ID (Component method)
class TestCancelDeferComponent : public Component {
public:
void test_cancel_defer() {
// Set a defer that should be cancelled
this->defer(8001U, []() {
ESP_LOGE("test", "ERROR: Numeric defer 8001 should have been cancelled");
});
// Cancel it immediately
bool cancelled = this->cancel_defer(8001U);
ESP_LOGI("test", "Cancelled numeric defer 8001: %s", cancelled ? "true" : "false");
}
};
static TestCancelDeferComponent test_cancel_defer_component;
test_cancel_defer_component.test_cancel_defer();
- id: report_results
then:
- lambda: |-
ESP_LOGI("test", "Final results - Timeouts: %d, Intervals: %d, Retries: %d",
id(timeout_counter), id(interval_counter), id(retry_counter));
ESP_LOGI("test", "Final results - Timeouts: %d, Intervals: %d, Retries: %d, Defers: %d",
id(timeout_counter), id(interval_counter), id(retry_counter), id(defer_counter));
sensor:
- platform: template
@@ -19,6 +19,7 @@ async def test_scheduler_numeric_id_test(
timeout_count = 0
interval_count = 0
retry_count = 0
defer_count = 0
# Events for each test completion
numeric_timeout_1001_fired = asyncio.Event()
@@ -33,6 +34,9 @@ async def test_scheduler_numeric_id_test(
max_id_timeout_fired = asyncio.Event()
numeric_retry_done = asyncio.Event()
numeric_retry_cancelled = asyncio.Event()
numeric_defer_7001_fired = asyncio.Event()
numeric_defer_7002_fired = asyncio.Event()
numeric_defer_cancelled = asyncio.Event()
final_results_logged = asyncio.Event()
# Track interval counts
@@ -40,7 +44,7 @@ async def test_scheduler_numeric_id_test(
numeric_retry_count = 0
def on_log_line(line: str) -> None:
nonlocal timeout_count, interval_count, retry_count
nonlocal timeout_count, interval_count, retry_count, defer_count
nonlocal numeric_interval_count, numeric_retry_count
# Strip ANSI color codes
@@ -105,15 +109,27 @@ async def test_scheduler_numeric_id_test(
elif "Cancelled numeric retry 6002" in clean_line:
numeric_retry_cancelled.set()
# Check for numeric defer tests
elif "Component numeric defer 7001 fired" in clean_line:
numeric_defer_7001_fired.set()
elif "Component numeric defer 7002 fired" in clean_line:
numeric_defer_7002_fired.set()
elif "Cancelled numeric defer 8001: true" in clean_line:
numeric_defer_cancelled.set()
# Check for final results
elif "Final results" in clean_line:
match = re.search(
r"Timeouts: (\d+), Intervals: (\d+), Retries: (\d+)", clean_line
r"Timeouts: (\d+), Intervals: (\d+), Retries: (\d+), Defers: (\d+)",
clean_line,
)
if match:
timeout_count = int(match.group(1))
interval_count = int(match.group(2))
retry_count = int(match.group(3))
defer_count = int(match.group(4))
final_results_logged.set()
async with (
@@ -201,6 +217,23 @@ async def test_scheduler_numeric_id_test(
"Numeric retry 6002 should have been cancelled"
)
# Wait for numeric defer tests
try:
await asyncio.wait_for(numeric_defer_7001_fired.wait(), timeout=0.5)
except TimeoutError:
pytest.fail("Numeric defer 7001 did not fire within 0.5 seconds")
try:
await asyncio.wait_for(numeric_defer_7002_fired.wait(), timeout=0.5)
except TimeoutError:
pytest.fail("Numeric defer 7002 did not fire within 0.5 seconds")
# Verify numeric defer was cancelled
try:
await asyncio.wait_for(numeric_defer_cancelled.wait(), timeout=0.5)
except TimeoutError:
pytest.fail("Numeric defer 8001 cancel confirmation not received")
# Wait for final results
try:
await asyncio.wait_for(final_results_logged.wait(), timeout=3.0)
@@ -215,3 +248,4 @@ async def test_scheduler_numeric_id_test(
assert retry_count >= 2, (
f"Expected at least 2 retry attempts, got {retry_count}"
)
assert defer_count >= 2, f"Expected at least 2 defer fires, got {defer_count}"
+80 -4
View File
@@ -25,7 +25,6 @@ def default_config() -> dict[str, Any]:
"board": "esp01_1m",
"ssid": "test_ssid",
"psk": "test_psk",
"password": "",
}
@@ -37,7 +36,7 @@ def wizard_answers() -> list[str]:
"nodemcuv2", # board
"SSID", # ssid
"psk", # wifi password
"ota_pass", # ota password
"", # ota password (empty for no password)
]
@@ -105,16 +104,35 @@ def test_config_file_should_include_ota_when_password_set(
default_config: dict[str, Any],
):
"""
The Over-The-Air update should be enabled when a password is set
The Over-The-Air update should be enabled when an OTA password is set
"""
# Given
default_config["password"] = "foo"
default_config["ota_password"] = "foo"
# When
config = wz.wizard_file(**default_config)
# Then
assert "ota:" in config
assert 'password: "foo"' in config
def test_config_file_should_include_api_encryption_key(
default_config: dict[str, Any],
):
"""
The API encryption key should be included when set
"""
# Given
default_config["api_encryption_key"] = "test_encryption_key_base64=="
# When
config = wz.wizard_file(**default_config)
# Then
assert "api:" in config
assert "encryption:" in config
assert 'key: "test_encryption_key_base64=="' in config
def test_wizard_write_sets_platform(
@@ -556,3 +574,61 @@ def test_wizard_write_protects_existing_config(
# Then
assert result is False # Should return False when file exists
assert config_file.read_text() == original_content
def test_wizard_accepts_ota_password(
tmp_path: Path, monkeypatch: MonkeyPatch, wizard_answers: list[str]
):
"""
The wizard should pass ota_password to wizard_write when the user provides one
"""
# Given
wizard_answers[5] = "my_ota_password" # Set OTA password
config_file = tmp_path / "test.yaml"
input_mock = MagicMock(side_effect=wizard_answers)
monkeypatch.setattr("builtins.input", input_mock)
monkeypatch.setattr(wz, "safe_print", lambda t=None, end=None: 0)
monkeypatch.setattr(wz, "sleep", lambda _: 0)
wizard_write_mock = MagicMock(return_value=True)
monkeypatch.setattr(wz, "wizard_write", wizard_write_mock)
# When
retval = wz.wizard(config_file)
# Then
assert retval == 0
call_kwargs = wizard_write_mock.call_args.kwargs
assert "ota_password" in call_kwargs
assert call_kwargs["ota_password"] == "my_ota_password"
def test_wizard_accepts_rpipico_board(tmp_path: Path, monkeypatch: MonkeyPatch):
"""
The wizard should handle rpipico board which doesn't support WiFi.
This tests the branch where api_encryption_key is None.
"""
# Given
wizard_answers_rp2040 = [
"test-node", # Name of the node
"RP2040", # platform
"rpipico", # board (no WiFi support)
]
config_file = tmp_path / "test.yaml"
input_mock = MagicMock(side_effect=wizard_answers_rp2040)
monkeypatch.setattr("builtins.input", input_mock)
monkeypatch.setattr(wz, "safe_print", lambda t=None, end=None: 0)
monkeypatch.setattr(wz, "sleep", lambda _: 0)
wizard_write_mock = MagicMock(return_value=True)
monkeypatch.setattr(wz, "wizard_write", wizard_write_mock)
# When
retval = wz.wizard(config_file)
# Then
assert retval == 0
call_kwargs = wizard_write_mock.call_args.kwargs
# rpipico doesn't support WiFi, so no api_encryption_key or ota_password
assert "api_encryption_key" not in call_kwargs
assert "ota_password" not in call_kwargs