mirror of
https://github.com/esphome/esphome.git
synced 2026-07-10 08:55:36 +00:00
Merge remote-tracking branch 'upstream/dev' into integration
This commit is contained in:
@@ -150,10 +150,14 @@ async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
cg.add(var.set_port(config[CONF_PORT]))
|
||||
|
||||
# Password could be set to an empty string and we can assume that means no password
|
||||
if config.get(CONF_PASSWORD):
|
||||
cg.add(var.set_auth_password(config[CONF_PASSWORD]))
|
||||
# Compile the auth path whenever `password:` is present in YAML, even if empty.
|
||||
# An empty password opts in to the auth code path so set_auth_password() can be
|
||||
# called at runtime (e.g. to rotate the password from a lambda). When `password:`
|
||||
# is omitted entirely, the auth path is excluded to save flash on small devices.
|
||||
if CONF_PASSWORD in config:
|
||||
cg.add_define("USE_OTA_PASSWORD")
|
||||
if config[CONF_PASSWORD]:
|
||||
cg.add(var.set_auth_password(config[CONF_PASSWORD]))
|
||||
cg.add_define("USE_OTA_VERSION", config[CONF_VERSION])
|
||||
# Build flag so lwip_fast_select.c (a .c file that can't include defines.h) sees it.
|
||||
cg.add_build_flag("-DUSE_OTA_PLATFORM_ESPHOME")
|
||||
|
||||
@@ -28,6 +28,14 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
|
||||
};
|
||||
#ifdef USE_OTA_PASSWORD
|
||||
void set_auth_password(const std::string &password) { password_ = password; }
|
||||
#else
|
||||
// Stub so lambdas referencing set_auth_password() produce a clear error instead of
|
||||
// a cryptic "no member" diagnostic. Only fires if the stub is actually instantiated.
|
||||
template<bool B = false> void set_auth_password(const std::string &) {
|
||||
static_assert(B, "set_auth_password() requires the OTA auth path to be compiled. "
|
||||
"Add 'password: \"\"' (empty string) to your 'ota: - platform: esphome' "
|
||||
"config to enable runtime password rotation.");
|
||||
}
|
||||
#endif // USE_OTA_PASSWORD
|
||||
|
||||
/// Manually set the port OTA should listen on
|
||||
|
||||
@@ -22,7 +22,7 @@ from ..defines import (
|
||||
literal,
|
||||
)
|
||||
from ..lv_validation import animated, lv_int, size
|
||||
from ..lvcode import LocalVariable, lv, lv_assign, lv_expr, lv_obj
|
||||
from ..lvcode import LocalVariable, lv, lv_assign, lv_expr, lv_obj, lv_Pvariable
|
||||
from ..schemas import container_schema, part_schema
|
||||
from ..types import LV_EVENT, LvType, ObjUpdateAction, lv_obj_t, lv_obj_t_ptr
|
||||
from . import Widget, WidgetType, add_widgets, get_widgets, set_obj_properties
|
||||
@@ -83,8 +83,8 @@ class TabviewType(WidgetType):
|
||||
await w.set_property("tab_bar_size", await size.process(config[CONF_SIZE]))
|
||||
for tab_conf in config[CONF_TABS]:
|
||||
w_id = tab_conf[CONF_ID]
|
||||
tab_obj = cg.Pvariable(w_id, cg.nullptr, type_=lv_tab_t)
|
||||
tab_widget = Widget.create(w_id, tab_obj, obj_spec)
|
||||
tab_obj = lv_Pvariable(lv_tab_t, w_id)
|
||||
tab_widget = Widget.create(w_id, tab_obj, obj_spec, tab_conf)
|
||||
lv_assign(tab_obj, lv_expr.tabview_add_tab(w.obj, tab_conf[CONF_NAME]))
|
||||
await set_obj_properties(tab_widget, tab_conf)
|
||||
await add_widgets(tab_widget, tab_conf)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import errno
|
||||
from importlib import resources
|
||||
import logging
|
||||
|
||||
@@ -74,6 +75,12 @@ def _load_tzdata(iana_key: str) -> bytes | None:
|
||||
return (resources.files(package) / resource).read_bytes()
|
||||
except (FileNotFoundError, ModuleNotFoundError, IsADirectoryError):
|
||||
return None
|
||||
except OSError as e:
|
||||
# Windows raises EINVAL for paths with NTFS-illegal chars (e.g. '<'/'>'
|
||||
# in POSIX TZ strings like "<+08>-8" that validate_tz feeds back here).
|
||||
if e.errno == errno.EINVAL:
|
||||
return None
|
||||
raise
|
||||
|
||||
|
||||
def _extract_tz_string(tzfile: bytes) -> str:
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
[build-system]
|
||||
requires = ["setuptools==82.0.1", "wheel>=0.43,<0.47"]
|
||||
requires = ["setuptools==82.0.1", "wheel>=0.43,<0.48"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
|
||||
+2
-2
@@ -10,9 +10,9 @@ tzdata>=2026.1 # from time
|
||||
pyserial==3.5
|
||||
platformio==6.1.19
|
||||
esptool==5.2.0
|
||||
click==8.3.2
|
||||
click==8.3.3
|
||||
esphome-dashboard==20260408.1
|
||||
aioesphomeapi==44.19.0
|
||||
aioesphomeapi==44.20.0
|
||||
zeroconf==0.148.0
|
||||
puremagic==1.30
|
||||
ruamel.yaml==0.19.1 # dashboard_import
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
id: my_ota
|
||||
port: 3287
|
||||
password: ""
|
||||
|
||||
esphome:
|
||||
on_boot:
|
||||
then:
|
||||
- lambda: id(my_ota).set_auth_password("runtime_password");
|
||||
@@ -1,6 +1,11 @@
|
||||
"""Tests for time component cron expression parsing."""
|
||||
|
||||
from esphome.components.time import _parse_cron_part
|
||||
import errno
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.time import _load_tzdata, _parse_cron_part, validate_tz
|
||||
|
||||
|
||||
def test_star_slash_seconds() -> None:
|
||||
@@ -78,3 +83,63 @@ def test_range() -> None:
|
||||
|
||||
def test_single_value() -> None:
|
||||
assert _parse_cron_part("30", 0, 59, {}) == {30}
|
||||
|
||||
|
||||
def _mock_resources_with_error(error: Exception) -> MagicMock:
|
||||
"""Return a mock of importlib.resources.files where read_bytes raises error."""
|
||||
leaf = MagicMock()
|
||||
leaf.read_bytes.side_effect = error
|
||||
package = MagicMock()
|
||||
package.__truediv__.return_value = leaf
|
||||
return MagicMock(return_value=package)
|
||||
|
||||
|
||||
def test_load_tzdata_returns_none_on_windows_einval() -> None:
|
||||
"""On Windows, opening a tzdata path with NTFS-illegal chars raises OSError(EINVAL).
|
||||
|
||||
Regression test for crash when the system TZ resolves to a POSIX string like
|
||||
"<+08>-8" (Asia/Shanghai, IST, etc.) and is fed back into _load_tzdata by
|
||||
validate_tz to check whether it is also a valid IANA key.
|
||||
"""
|
||||
err = OSError(errno.EINVAL, "Invalid argument")
|
||||
with patch(
|
||||
"esphome.components.time.resources.files",
|
||||
_mock_resources_with_error(err),
|
||||
):
|
||||
assert _load_tzdata("<+08>-8") is None
|
||||
|
||||
|
||||
def test_load_tzdata_propagates_unexpected_oserror() -> None:
|
||||
"""Unrelated OSErrors (e.g. PermissionError) must not be swallowed."""
|
||||
with (
|
||||
patch(
|
||||
"esphome.components.time.resources.files",
|
||||
_mock_resources_with_error(
|
||||
PermissionError(errno.EACCES, "Permission denied")
|
||||
),
|
||||
),
|
||||
pytest.raises(PermissionError),
|
||||
):
|
||||
_load_tzdata("Some/Zone")
|
||||
|
||||
|
||||
def test_load_tzdata_returns_none_on_file_not_found() -> None:
|
||||
"""Existing behavior: missing tz file returns None rather than raising."""
|
||||
with patch(
|
||||
"esphome.components.time.resources.files",
|
||||
_mock_resources_with_error(FileNotFoundError()),
|
||||
):
|
||||
assert _load_tzdata("Not/A/Zone") is None
|
||||
|
||||
|
||||
def test_validate_tz_accepts_posix_string_when_read_bytes_raises_einval() -> None:
|
||||
"""validate_tz must not crash when _load_tzdata hits the Windows EINVAL path.
|
||||
|
||||
Simulates the Windows case where the auto-detected POSIX TZ string is fed
|
||||
back through _load_tzdata and the underlying read_bytes raises errno 22.
|
||||
"""
|
||||
with patch(
|
||||
"esphome.components.time.resources.files",
|
||||
_mock_resources_with_error(OSError(errno.EINVAL, "Invalid argument")),
|
||||
):
|
||||
assert validate_tz("<+08>-8") == "<+08>-8"
|
||||
|
||||
Reference in New Issue
Block a user