mirror of
https://github.com/esphome/esphome.git
synced 2025-10-24 11:08:48 +00:00
Compare commits
13 Commits
mqtt_reduc
...
2025.10.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebc0f5f7c9 | ||
|
|
87ca8784ef | ||
|
|
a186c1062f | ||
|
|
ea38237f29 | ||
|
|
6aff1394ad | ||
|
|
0e34d1b64d | ||
|
|
1483cee0fb | ||
|
|
8c1bd2fd85 | ||
|
|
ea609dc0f6 | ||
|
|
913095f6be | ||
|
|
bb24ad4a30 | ||
|
|
0d612fecfc | ||
|
|
9c235b4140 |
2
Doxyfile
2
Doxyfile
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
|
|||||||
# could be handy for archiving the generated documentation or if some version
|
# could be handy for archiving the generated documentation or if some version
|
||||||
# control system is used.
|
# control system is used.
|
||||||
|
|
||||||
PROJECT_NUMBER = 2025.10.1
|
PROJECT_NUMBER = 2025.10.2
|
||||||
|
|
||||||
# Using the PROJECT_BRIEF tag one can provide an optional one line description
|
# 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
|
# for a project that appears at the top of each page and should give viewer a
|
||||||
|
|||||||
@@ -185,7 +185,9 @@ def choose_upload_log_host(
|
|||||||
else:
|
else:
|
||||||
resolved.append(device)
|
resolved.append(device)
|
||||||
if not resolved:
|
if not resolved:
|
||||||
_LOGGER.error("All specified devices: %s could not be resolved.", defaults)
|
raise EsphomeError(
|
||||||
|
f"All specified devices {defaults} could not be resolved. Is the device connected to the network?"
|
||||||
|
)
|
||||||
return resolved
|
return resolved
|
||||||
|
|
||||||
# No devices specified, show interactive chooser
|
# No devices specified, show interactive chooser
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ CONFIG_SCHEMA = cv.All(
|
|||||||
cv.Schema(
|
cv.Schema(
|
||||||
{
|
{
|
||||||
cv.GenerateID(): cv.declare_id(BME680BSECComponent),
|
cv.GenerateID(): cv.declare_id(BME680BSECComponent),
|
||||||
cv.Optional(CONF_TEMPERATURE_OFFSET, default=0): cv.temperature,
|
cv.Optional(CONF_TEMPERATURE_OFFSET, default=0): cv.temperature_delta,
|
||||||
cv.Optional(CONF_IAQ_MODE, default="STATIC"): cv.enum(
|
cv.Optional(CONF_IAQ_MODE, default="STATIC"): cv.enum(
|
||||||
IAQ_MODE_OPTIONS, upper=True
|
IAQ_MODE_OPTIONS, upper=True
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ CONFIG_SCHEMA_BASE = (
|
|||||||
cv.Optional(CONF_SUPPLY_VOLTAGE, default="3.3V"): cv.enum(
|
cv.Optional(CONF_SUPPLY_VOLTAGE, default="3.3V"): cv.enum(
|
||||||
VOLTAGE_OPTIONS, upper=True
|
VOLTAGE_OPTIONS, upper=True
|
||||||
),
|
),
|
||||||
cv.Optional(CONF_TEMPERATURE_OFFSET, default=0): cv.temperature,
|
cv.Optional(CONF_TEMPERATURE_OFFSET, default=0): cv.temperature_delta,
|
||||||
cv.Optional(
|
cv.Optional(
|
||||||
CONF_STATE_SAVE_INTERVAL, default="6hours"
|
CONF_STATE_SAVE_INTERVAL, default="6hours"
|
||||||
): cv.positive_time_period_minutes,
|
): cv.positive_time_period_minutes,
|
||||||
|
|||||||
@@ -30,14 +30,12 @@ class DateTimeBase : public EntityBase {
|
|||||||
#endif
|
#endif
|
||||||
};
|
};
|
||||||
|
|
||||||
#ifdef USE_TIME
|
|
||||||
class DateTimeStateTrigger : public Trigger<ESPTime> {
|
class DateTimeStateTrigger : public Trigger<ESPTime> {
|
||||||
public:
|
public:
|
||||||
explicit DateTimeStateTrigger(DateTimeBase *parent) {
|
explicit DateTimeStateTrigger(DateTimeBase *parent) {
|
||||||
parent->add_on_state_callback([this, parent]() { this->trigger(parent->state_as_esptime()); });
|
parent->add_on_state_callback([this, parent]() { this->trigger(parent->state_as_esptime()); });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
#endif
|
|
||||||
|
|
||||||
} // namespace datetime
|
} // namespace datetime
|
||||||
} // namespace esphome
|
} // namespace esphome
|
||||||
|
|||||||
@@ -790,6 +790,7 @@ async def to_code(config):
|
|||||||
add_idf_sdkconfig_option("CONFIG_AUTOSTART_ARDUINO", True)
|
add_idf_sdkconfig_option("CONFIG_AUTOSTART_ARDUINO", True)
|
||||||
add_idf_sdkconfig_option("CONFIG_MBEDTLS_PSK_MODES", True)
|
add_idf_sdkconfig_option("CONFIG_MBEDTLS_PSK_MODES", True)
|
||||||
add_idf_sdkconfig_option("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE", True)
|
add_idf_sdkconfig_option("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE", True)
|
||||||
|
add_idf_sdkconfig_option("CONFIG_ESP_PHY_REDUCE_TX_POWER", True)
|
||||||
|
|
||||||
cg.add_build_flag("-Wno-nonnull-compare")
|
cg.add_build_flag("-Wno-nonnull-compare")
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
#include <freertos/FreeRTOS.h>
|
#include <freertos/FreeRTOS.h>
|
||||||
#include <freertos/task.h>
|
#include <freertos/task.h>
|
||||||
#include <esp_idf_version.h>
|
#include <esp_idf_version.h>
|
||||||
|
#include <esp_ota_ops.h>
|
||||||
#include <esp_task_wdt.h>
|
#include <esp_task_wdt.h>
|
||||||
#include <esp_timer.h>
|
#include <esp_timer.h>
|
||||||
#include <soc/rtc.h>
|
#include <soc/rtc.h>
|
||||||
@@ -52,6 +53,16 @@ void arch_init() {
|
|||||||
disableCore1WDT();
|
disableCore1WDT();
|
||||||
#endif
|
#endif
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
// If the bootloader was compiled with CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE the current
|
||||||
|
// partition will get rolled back unless it is marked as valid.
|
||||||
|
esp_ota_img_states_t state;
|
||||||
|
const esp_partition_t *running = esp_ota_get_running_partition();
|
||||||
|
if (esp_ota_get_state_partition(running, &state) == ESP_OK) {
|
||||||
|
if (state == ESP_OTA_IMG_PENDING_VERIFY) {
|
||||||
|
esp_ota_mark_app_valid_cancel_rollback();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
void IRAM_ATTR HOT arch_feed_wdt() { esp_task_wdt_reset(); }
|
void IRAM_ATTR HOT arch_feed_wdt() { esp_task_wdt_reset(); }
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ static const char *const TAG = "htu21d";
|
|||||||
|
|
||||||
static const uint8_t HTU21D_ADDRESS = 0x40;
|
static const uint8_t HTU21D_ADDRESS = 0x40;
|
||||||
static const uint8_t HTU21D_REGISTER_RESET = 0xFE;
|
static const uint8_t HTU21D_REGISTER_RESET = 0xFE;
|
||||||
static const uint8_t HTU21D_REGISTER_TEMPERATURE = 0xE3;
|
static const uint8_t HTU21D_REGISTER_TEMPERATURE = 0xF3;
|
||||||
static const uint8_t HTU21D_REGISTER_HUMIDITY = 0xE5;
|
static const uint8_t HTU21D_REGISTER_HUMIDITY = 0xF5;
|
||||||
static const uint8_t HTU21D_WRITERHT_REG_CMD = 0xE6; /**< Write RH/T User Register 1 */
|
static const uint8_t HTU21D_WRITERHT_REG_CMD = 0xE6; /**< Write RH/T User Register 1 */
|
||||||
static const uint8_t HTU21D_REGISTER_STATUS = 0xE7;
|
static const uint8_t HTU21D_REGISTER_STATUS = 0xE7;
|
||||||
static const uint8_t HTU21D_WRITEHEATER_REG_CMD = 0x51; /**< Write Heater Control Register */
|
static const uint8_t HTU21D_WRITEHEATER_REG_CMD = 0x51; /**< Write Heater Control Register */
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ CONFIG_SCHEMA = (
|
|||||||
cv.int_range(min=0, max=0xFFFF, max_included=False),
|
cv.int_range(min=0, max=0xFFFF, max_included=False),
|
||||||
),
|
),
|
||||||
cv.Optional(CONF_AMBIENT_PRESSURE_COMPENSATION): cv.pressure,
|
cv.Optional(CONF_AMBIENT_PRESSURE_COMPENSATION): cv.pressure,
|
||||||
cv.Optional(CONF_TEMPERATURE_OFFSET, default="4°C"): cv.temperature,
|
cv.Optional(CONF_TEMPERATURE_OFFSET, default="4°C"): cv.temperature_delta,
|
||||||
cv.Optional(CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE): cv.use_id(
|
cv.Optional(CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE): cv.use_id(
|
||||||
sensor.Sensor
|
sensor.Sensor
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -244,6 +244,20 @@ RESERVED_IDS = [
|
|||||||
"uart0",
|
"uart0",
|
||||||
"uart1",
|
"uart1",
|
||||||
"uart2",
|
"uart2",
|
||||||
|
# ESP32 ROM functions
|
||||||
|
"crc16_be",
|
||||||
|
"crc16_le",
|
||||||
|
"crc32_be",
|
||||||
|
"crc32_le",
|
||||||
|
"crc8_be",
|
||||||
|
"crc8_le",
|
||||||
|
"dbg_state",
|
||||||
|
"debug_timer",
|
||||||
|
"one_bits",
|
||||||
|
"recv_packet",
|
||||||
|
"send_packet",
|
||||||
|
"check_pos",
|
||||||
|
"software_reset",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from enum import Enum
|
|||||||
|
|
||||||
from esphome.enum import StrEnum
|
from esphome.enum import StrEnum
|
||||||
|
|
||||||
__version__ = "2025.10.1"
|
__version__ = "2025.10.2"
|
||||||
|
|
||||||
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
|
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
|
||||||
VALID_SUBSTITUTIONS_CHARACTERS = (
|
VALID_SUBSTITUTIONS_CHARACTERS = (
|
||||||
@@ -696,6 +696,7 @@ CONF_OPEN_DRAIN = "open_drain"
|
|||||||
CONF_OPEN_DRAIN_INTERRUPT = "open_drain_interrupt"
|
CONF_OPEN_DRAIN_INTERRUPT = "open_drain_interrupt"
|
||||||
CONF_OPEN_DURATION = "open_duration"
|
CONF_OPEN_DURATION = "open_duration"
|
||||||
CONF_OPEN_ENDSTOP = "open_endstop"
|
CONF_OPEN_ENDSTOP = "open_endstop"
|
||||||
|
CONF_OPENTHREAD = "openthread"
|
||||||
CONF_OPERATION = "operation"
|
CONF_OPERATION = "operation"
|
||||||
CONF_OPTIMISTIC = "optimistic"
|
CONF_OPTIMISTIC = "optimistic"
|
||||||
CONF_OPTION = "option"
|
CONF_OPTION = "option"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from esphome.const import (
|
|||||||
CONF_COMMENT,
|
CONF_COMMENT,
|
||||||
CONF_ESPHOME,
|
CONF_ESPHOME,
|
||||||
CONF_ETHERNET,
|
CONF_ETHERNET,
|
||||||
|
CONF_OPENTHREAD,
|
||||||
CONF_PORT,
|
CONF_PORT,
|
||||||
CONF_USE_ADDRESS,
|
CONF_USE_ADDRESS,
|
||||||
CONF_WEB_SERVER,
|
CONF_WEB_SERVER,
|
||||||
@@ -641,6 +642,9 @@ class EsphomeCore:
|
|||||||
if CONF_ETHERNET in self.config:
|
if CONF_ETHERNET in self.config:
|
||||||
return self.config[CONF_ETHERNET][CONF_USE_ADDRESS]
|
return self.config[CONF_ETHERNET][CONF_USE_ADDRESS]
|
||||||
|
|
||||||
|
if CONF_OPENTHREAD in self.config:
|
||||||
|
return f"{self.name}.local"
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ from esphome.helpers import get_bool_env
|
|||||||
|
|
||||||
from .util.password import password_hash
|
from .util.password import password_hash
|
||||||
|
|
||||||
|
# Sentinel file name used for CORE.config_path when dashboard initializes.
|
||||||
|
# This ensures .parent returns the config directory instead of root.
|
||||||
|
_DASHBOARD_SENTINEL_FILE = "___DASHBOARD_SENTINEL___.yaml"
|
||||||
|
|
||||||
|
|
||||||
class DashboardSettings:
|
class DashboardSettings:
|
||||||
"""Settings for the dashboard."""
|
"""Settings for the dashboard."""
|
||||||
@@ -48,7 +52,12 @@ class DashboardSettings:
|
|||||||
self.config_dir = Path(args.configuration)
|
self.config_dir = Path(args.configuration)
|
||||||
self.absolute_config_dir = self.config_dir.resolve()
|
self.absolute_config_dir = self.config_dir.resolve()
|
||||||
self.verbose = args.verbose
|
self.verbose = args.verbose
|
||||||
CORE.config_path = self.config_dir / "."
|
# Set to a sentinel file so .parent gives us the config directory.
|
||||||
|
# Previously this was `os.path.join(self.config_dir, ".")` which worked because
|
||||||
|
# os.path.dirname("/config/.") returns "/config", but Path("/config/.").parent
|
||||||
|
# normalizes to Path("/config") first, then .parent returns Path("/"), breaking
|
||||||
|
# secret resolution. Using a sentinel file ensures .parent gives the correct directory.
|
||||||
|
CORE.config_path = self.config_dir / _DASHBOARD_SENTINEL_FILE
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def relative_url(self) -> str:
|
def relative_url(self) -> str:
|
||||||
|
|||||||
@@ -1058,7 +1058,8 @@ class DownloadBinaryRequestHandler(BaseHandler):
|
|||||||
"download",
|
"download",
|
||||||
f"{storage_json.name}-{file_name}",
|
f"{storage_json.name}-{file_name}",
|
||||||
)
|
)
|
||||||
path = storage_json.firmware_bin_path.with_name(file_name)
|
|
||||||
|
path = storage_json.firmware_bin_path.parent.joinpath(file_name)
|
||||||
|
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
args = ["esphome", "idedata", settings.rel_path(configuration)]
|
args = ["esphome", "idedata", settings.rel_path(configuration)]
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from argparse import Namespace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from esphome.core import CORE
|
||||||
from esphome.dashboard.settings import DashboardSettings
|
from esphome.dashboard.settings import DashboardSettings
|
||||||
|
|
||||||
|
|
||||||
@@ -159,3 +161,63 @@ def test_rel_path_with_numeric_args(dashboard_settings: DashboardSettings) -> No
|
|||||||
result = dashboard_settings.rel_path("123", "456.789")
|
result = dashboard_settings.rel_path("123", "456.789")
|
||||||
expected = dashboard_settings.config_dir / "123" / "456.789"
|
expected = dashboard_settings.config_dir / "123" / "456.789"
|
||||||
assert result == expected
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_path_parent_resolves_to_config_dir(tmp_path: Path) -> None:
|
||||||
|
"""Test that CORE.config_path.parent resolves to config_dir after parse_args.
|
||||||
|
|
||||||
|
This is a regression test for issue #11280 where binary download failed
|
||||||
|
when using packages with secrets after the Path migration in 2025.10.0.
|
||||||
|
|
||||||
|
The issue was that after switching from os.path to Path:
|
||||||
|
- Before: os.path.dirname("/config/.") → "/config"
|
||||||
|
- After: Path("/config/.").parent → Path("/") (normalized first!)
|
||||||
|
|
||||||
|
The fix uses a sentinel file so .parent returns the correct directory:
|
||||||
|
- Fixed: Path("/config/___DASHBOARD_SENTINEL___.yaml").parent → Path("/config")
|
||||||
|
"""
|
||||||
|
# Create test directory structure with secrets and packages
|
||||||
|
config_dir = tmp_path / "config"
|
||||||
|
config_dir.mkdir()
|
||||||
|
|
||||||
|
# Create secrets.yaml with obviously fake test values
|
||||||
|
secrets_file = config_dir / "secrets.yaml"
|
||||||
|
secrets_file.write_text(
|
||||||
|
"wifi_ssid: TEST-DUMMY-SSID\n"
|
||||||
|
"wifi_password: not-a-real-password-just-for-testing\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create package file that uses secrets
|
||||||
|
package_file = config_dir / "common.yaml"
|
||||||
|
package_file.write_text(
|
||||||
|
"wifi:\n ssid: !secret wifi_ssid\n password: !secret wifi_password\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create main device config that includes the package
|
||||||
|
device_config = config_dir / "test-device.yaml"
|
||||||
|
device_config.write_text(
|
||||||
|
"esphome:\n name: test-device\n\npackages:\n common: !include common.yaml\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set up dashboard settings with our test config directory
|
||||||
|
settings = DashboardSettings()
|
||||||
|
args = Namespace(
|
||||||
|
configuration=str(config_dir),
|
||||||
|
password=None,
|
||||||
|
username=None,
|
||||||
|
ha_addon=False,
|
||||||
|
verbose=False,
|
||||||
|
)
|
||||||
|
settings.parse_args(args)
|
||||||
|
|
||||||
|
# Verify that CORE.config_path.parent correctly points to the config directory
|
||||||
|
# This is critical for secret resolution in yaml_util.py which does:
|
||||||
|
# main_config_dir = CORE.config_path.parent
|
||||||
|
# main_secret_yml = main_config_dir / "secrets.yaml"
|
||||||
|
assert CORE.config_path.parent == config_dir.resolve()
|
||||||
|
assert (CORE.config_path.parent / "secrets.yaml").exists()
|
||||||
|
assert (CORE.config_path.parent / "common.yaml").exists()
|
||||||
|
|
||||||
|
# Verify that CORE.config_path itself uses the sentinel file
|
||||||
|
assert CORE.config_path.name == "___DASHBOARD_SENTINEL___.yaml"
|
||||||
|
assert not CORE.config_path.exists() # Sentinel file doesn't actually exist
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from argparse import Namespace
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
@@ -17,6 +18,8 @@ from tornado.ioloop import IOLoop
|
|||||||
from tornado.testing import bind_unused_port
|
from tornado.testing import bind_unused_port
|
||||||
from tornado.websocket import WebSocketClientConnection, websocket_connect
|
from tornado.websocket import WebSocketClientConnection, websocket_connect
|
||||||
|
|
||||||
|
from esphome import yaml_util
|
||||||
|
from esphome.core import CORE
|
||||||
from esphome.dashboard import web_server
|
from esphome.dashboard import web_server
|
||||||
from esphome.dashboard.const import DashboardEvent
|
from esphome.dashboard.const import DashboardEvent
|
||||||
from esphome.dashboard.core import DASHBOARD
|
from esphome.dashboard.core import DASHBOARD
|
||||||
@@ -32,6 +35,26 @@ from esphome.zeroconf import DiscoveredImport
|
|||||||
from .common import get_fixture_path
|
from .common import get_fixture_path
|
||||||
|
|
||||||
|
|
||||||
|
def get_build_path(base_path: Path, device_name: str) -> Path:
|
||||||
|
"""Get the build directory path for a device.
|
||||||
|
|
||||||
|
This is a test helper that constructs the standard ESPHome build directory
|
||||||
|
structure. Note: This helper does NOT perform path traversal sanitization
|
||||||
|
because it's only used in tests where we control the inputs. The actual
|
||||||
|
web_server.py code handles sanitization in DownloadBinaryRequestHandler.get()
|
||||||
|
via file_name.replace("..", "").lstrip("/").
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_path: The base temporary path (typically tmp_path from pytest)
|
||||||
|
device_name: The name of the device (should not contain path separators
|
||||||
|
in production use, but tests may use it for specific scenarios)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the build directory (.esphome/build/device_name)
|
||||||
|
"""
|
||||||
|
return base_path / ".esphome" / "build" / device_name
|
||||||
|
|
||||||
|
|
||||||
class DashboardTestHelper:
|
class DashboardTestHelper:
|
||||||
def __init__(self, io_loop: IOLoop, client: AsyncHTTPClient, port: int) -> None:
|
def __init__(self, io_loop: IOLoop, client: AsyncHTTPClient, port: int) -> None:
|
||||||
self.io_loop = io_loop
|
self.io_loop = io_loop
|
||||||
@@ -414,6 +437,180 @@ async def test_download_binary_handler_idedata_fallback(
|
|||||||
assert response.body == b"bootloader content"
|
assert response.body == b"bootloader content"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.usefixtures("mock_ext_storage_path")
|
||||||
|
async def test_download_binary_handler_subdirectory_file(
|
||||||
|
dashboard: DashboardTestHelper,
|
||||||
|
tmp_path: Path,
|
||||||
|
mock_storage_json: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test the DownloadBinaryRequestHandler.get with file in subdirectory (nRF52 case).
|
||||||
|
|
||||||
|
This is a regression test for issue #11343 where the Path migration broke
|
||||||
|
downloads for nRF52 firmware files in subdirectories like 'zephyr/zephyr.uf2'.
|
||||||
|
|
||||||
|
The issue was that with_name() doesn't accept path separators:
|
||||||
|
- Before: path = storage_json.firmware_bin_path.with_name(file_name)
|
||||||
|
ValueError: Invalid name 'zephyr/zephyr.uf2'
|
||||||
|
- After: path = storage_json.firmware_bin_path.parent.joinpath(file_name)
|
||||||
|
Works correctly with subdirectory paths
|
||||||
|
"""
|
||||||
|
# Create a fake nRF52 build structure with firmware in subdirectory
|
||||||
|
build_dir = get_build_path(tmp_path, "nrf52-device")
|
||||||
|
zephyr_dir = build_dir / "zephyr"
|
||||||
|
zephyr_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
# Create the main firmware binary (would be in build root)
|
||||||
|
firmware_file = build_dir / "firmware.bin"
|
||||||
|
firmware_file.write_bytes(b"main firmware")
|
||||||
|
|
||||||
|
# Create the UF2 file in zephyr subdirectory (nRF52 specific)
|
||||||
|
uf2_file = zephyr_dir / "zephyr.uf2"
|
||||||
|
uf2_file.write_bytes(b"nRF52 UF2 firmware content")
|
||||||
|
|
||||||
|
# Mock storage JSON
|
||||||
|
mock_storage = Mock()
|
||||||
|
mock_storage.name = "nrf52-device"
|
||||||
|
mock_storage.firmware_bin_path = firmware_file
|
||||||
|
mock_storage_json.load.return_value = mock_storage
|
||||||
|
|
||||||
|
# Request the UF2 file with subdirectory path
|
||||||
|
response = await dashboard.fetch(
|
||||||
|
"/download.bin?configuration=nrf52-device.yaml&file=zephyr/zephyr.uf2",
|
||||||
|
method="GET",
|
||||||
|
)
|
||||||
|
assert response.code == 200
|
||||||
|
assert response.body == b"nRF52 UF2 firmware content"
|
||||||
|
assert response.headers["Content-Type"] == "application/octet-stream"
|
||||||
|
assert "attachment" in response.headers["Content-Disposition"]
|
||||||
|
# Download name should be device-name + full file path
|
||||||
|
assert "nrf52-device-zephyr/zephyr.uf2" in response.headers["Content-Disposition"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.usefixtures("mock_ext_storage_path")
|
||||||
|
async def test_download_binary_handler_subdirectory_file_url_encoded(
|
||||||
|
dashboard: DashboardTestHelper,
|
||||||
|
tmp_path: Path,
|
||||||
|
mock_storage_json: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test the DownloadBinaryRequestHandler.get with URL-encoded subdirectory path.
|
||||||
|
|
||||||
|
Verifies that URL-encoded paths (e.g., zephyr%2Fzephyr.uf2) are correctly
|
||||||
|
decoded and handled, and that custom download names work with subdirectories.
|
||||||
|
"""
|
||||||
|
# Create a fake build structure with firmware in subdirectory
|
||||||
|
build_dir = get_build_path(tmp_path, "test")
|
||||||
|
zephyr_dir = build_dir / "zephyr"
|
||||||
|
zephyr_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
firmware_file = build_dir / "firmware.bin"
|
||||||
|
firmware_file.write_bytes(b"content")
|
||||||
|
|
||||||
|
uf2_file = zephyr_dir / "zephyr.uf2"
|
||||||
|
uf2_file.write_bytes(b"content")
|
||||||
|
|
||||||
|
# Mock storage JSON
|
||||||
|
mock_storage = Mock()
|
||||||
|
mock_storage.name = "test_device"
|
||||||
|
mock_storage.firmware_bin_path = firmware_file
|
||||||
|
mock_storage_json.load.return_value = mock_storage
|
||||||
|
|
||||||
|
# Request with URL-encoded path and custom download name
|
||||||
|
response = await dashboard.fetch(
|
||||||
|
"/download.bin?configuration=test.yaml&file=zephyr%2Fzephyr.uf2&download=custom_name.bin",
|
||||||
|
method="GET",
|
||||||
|
)
|
||||||
|
assert response.code == 200
|
||||||
|
assert "custom_name.bin" in response.headers["Content-Disposition"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.usefixtures("mock_ext_storage_path")
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"attack_path",
|
||||||
|
[
|
||||||
|
pytest.param("../../../secrets.yaml", id="basic_traversal"),
|
||||||
|
pytest.param("..%2F..%2F..%2Fsecrets.yaml", id="url_encoded"),
|
||||||
|
pytest.param("zephyr/../../../secrets.yaml", id="traversal_with_prefix"),
|
||||||
|
pytest.param("/etc/passwd", id="absolute_path"),
|
||||||
|
pytest.param("//etc/passwd", id="double_slash_absolute"),
|
||||||
|
pytest.param("....//secrets.yaml", id="multiple_dots"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_download_binary_handler_path_traversal_protection(
|
||||||
|
dashboard: DashboardTestHelper,
|
||||||
|
tmp_path: Path,
|
||||||
|
mock_storage_json: MagicMock,
|
||||||
|
attack_path: str,
|
||||||
|
) -> None:
|
||||||
|
"""Test that DownloadBinaryRequestHandler prevents path traversal attacks.
|
||||||
|
|
||||||
|
Verifies that attempts to use '..' in file paths are sanitized to prevent
|
||||||
|
accessing files outside the build directory. Tests multiple attack vectors.
|
||||||
|
"""
|
||||||
|
# Create build structure
|
||||||
|
build_dir = get_build_path(tmp_path, "test")
|
||||||
|
build_dir.mkdir(parents=True)
|
||||||
|
firmware_file = build_dir / "firmware.bin"
|
||||||
|
firmware_file.write_bytes(b"firmware content")
|
||||||
|
|
||||||
|
# Create a sensitive file outside the build directory that should NOT be accessible
|
||||||
|
sensitive_file = tmp_path / "secrets.yaml"
|
||||||
|
sensitive_file.write_bytes(b"secret: my_secret_password")
|
||||||
|
|
||||||
|
# Mock storage JSON
|
||||||
|
mock_storage = Mock()
|
||||||
|
mock_storage.name = "test_device"
|
||||||
|
mock_storage.firmware_bin_path = firmware_file
|
||||||
|
mock_storage_json.load.return_value = mock_storage
|
||||||
|
|
||||||
|
# Attempt path traversal attack - should be blocked
|
||||||
|
with pytest.raises(HTTPClientError) as exc_info:
|
||||||
|
await dashboard.fetch(
|
||||||
|
f"/download.bin?configuration=test.yaml&file={attack_path}",
|
||||||
|
method="GET",
|
||||||
|
)
|
||||||
|
# Should get 404 (file not found after sanitization) or 500 (idedata fails)
|
||||||
|
assert exc_info.value.code in (404, 500)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.usefixtures("mock_ext_storage_path")
|
||||||
|
async def test_download_binary_handler_multiple_subdirectory_levels(
|
||||||
|
dashboard: DashboardTestHelper,
|
||||||
|
tmp_path: Path,
|
||||||
|
mock_storage_json: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test downloading files from multiple subdirectory levels.
|
||||||
|
|
||||||
|
Verifies that joinpath correctly handles multi-level paths like 'build/output/firmware.bin'.
|
||||||
|
"""
|
||||||
|
# Create nested directory structure
|
||||||
|
build_dir = get_build_path(tmp_path, "test")
|
||||||
|
nested_dir = build_dir / "build" / "output"
|
||||||
|
nested_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
firmware_file = build_dir / "firmware.bin"
|
||||||
|
firmware_file.write_bytes(b"main")
|
||||||
|
|
||||||
|
nested_file = nested_dir / "firmware.bin"
|
||||||
|
nested_file.write_bytes(b"nested firmware content")
|
||||||
|
|
||||||
|
# Mock storage JSON
|
||||||
|
mock_storage = Mock()
|
||||||
|
mock_storage.name = "test_device"
|
||||||
|
mock_storage.firmware_bin_path = firmware_file
|
||||||
|
mock_storage_json.load.return_value = mock_storage
|
||||||
|
|
||||||
|
response = await dashboard.fetch(
|
||||||
|
"/download.bin?configuration=test.yaml&file=build/output/firmware.bin",
|
||||||
|
method="GET",
|
||||||
|
)
|
||||||
|
assert response.code == 200
|
||||||
|
assert response.body == b"nested firmware content"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_edit_request_handler_post_invalid_file(
|
async def test_edit_request_handler_post_invalid_file(
|
||||||
dashboard: DashboardTestHelper,
|
dashboard: DashboardTestHelper,
|
||||||
@@ -1302,3 +1499,71 @@ async def test_dashboard_subscriber_refresh_event(
|
|||||||
|
|
||||||
# Give it a moment to clean up
|
# Give it a moment to clean up
|
||||||
await asyncio.sleep(0.01)
|
await asyncio.sleep(0.01)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dashboard_yaml_loading_with_packages_and_secrets(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Test dashboard YAML loading with packages referencing secrets.
|
||||||
|
|
||||||
|
This is a regression test for issue #11280 where binary download failed
|
||||||
|
when using packages with secrets after the Path migration in 2025.10.0.
|
||||||
|
|
||||||
|
This test verifies that CORE.config_path initialization in the dashboard
|
||||||
|
allows yaml_util.load_yaml() to correctly resolve secrets from packages.
|
||||||
|
"""
|
||||||
|
# Create test directory structure with secrets and packages
|
||||||
|
config_dir = tmp_path / "config"
|
||||||
|
config_dir.mkdir()
|
||||||
|
|
||||||
|
# Create secrets.yaml with obviously fake test values
|
||||||
|
secrets_file = config_dir / "secrets.yaml"
|
||||||
|
secrets_file.write_text(
|
||||||
|
"wifi_ssid: TEST-DUMMY-SSID\n"
|
||||||
|
"wifi_password: not-a-real-password-just-for-testing\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create package file that uses secrets
|
||||||
|
package_file = config_dir / "common.yaml"
|
||||||
|
package_file.write_text(
|
||||||
|
"wifi:\n ssid: !secret wifi_ssid\n password: !secret wifi_password\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create main device config that includes the package
|
||||||
|
device_config = config_dir / "test-download-secrets.yaml"
|
||||||
|
device_config.write_text(
|
||||||
|
"esphome:\n name: test-download-secrets\n platform: ESP32\n board: esp32dev\n\n"
|
||||||
|
"packages:\n common: !include common.yaml\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Initialize DASHBOARD settings with our test config directory
|
||||||
|
# This is what sets CORE.config_path - the critical code path for the bug
|
||||||
|
args = Namespace(
|
||||||
|
configuration=str(config_dir),
|
||||||
|
password=None,
|
||||||
|
username=None,
|
||||||
|
ha_addon=False,
|
||||||
|
verbose=False,
|
||||||
|
)
|
||||||
|
DASHBOARD.settings.parse_args(args)
|
||||||
|
|
||||||
|
# With the fix: CORE.config_path should be config_dir / "___DASHBOARD_SENTINEL___.yaml"
|
||||||
|
# so CORE.config_path.parent would be config_dir
|
||||||
|
# Without the fix: CORE.config_path is config_dir / "." which normalizes to config_dir
|
||||||
|
# so CORE.config_path.parent would be tmp_path (the parent of config_dir)
|
||||||
|
|
||||||
|
# The fix ensures CORE.config_path.parent points to config_dir
|
||||||
|
assert CORE.config_path.parent == config_dir.resolve(), (
|
||||||
|
f"CORE.config_path.parent should point to config_dir. "
|
||||||
|
f"Got {CORE.config_path.parent}, expected {config_dir.resolve()}. "
|
||||||
|
f"CORE.config_path is {CORE.config_path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Now load the YAML with packages that reference secrets
|
||||||
|
# This is where the bug would manifest - yaml_util.load_yaml would fail
|
||||||
|
# to find secrets.yaml because CORE.config_path.parent pointed to the wrong place
|
||||||
|
config = yaml_util.load_yaml(device_config)
|
||||||
|
# If we get here, secret resolution worked!
|
||||||
|
assert "esphome" in config
|
||||||
|
assert config["esphome"]["name"] == "test-download-secrets"
|
||||||
|
|||||||
@@ -570,6 +570,13 @@ class TestEsphomeCore:
|
|||||||
|
|
||||||
assert target.address == "4.3.2.1"
|
assert target.address == "4.3.2.1"
|
||||||
|
|
||||||
|
def test_address__openthread(self, target):
|
||||||
|
target.name = "test-device"
|
||||||
|
target.config = {}
|
||||||
|
target.config[const.CONF_OPENTHREAD] = {}
|
||||||
|
|
||||||
|
assert target.address == "test-device.local"
|
||||||
|
|
||||||
def test_is_esp32(self, target):
|
def test_is_esp32(self, target):
|
||||||
target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "esp32"}
|
target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "esp32"}
|
||||||
|
|
||||||
|
|||||||
@@ -321,12 +321,14 @@ def test_choose_upload_log_host_with_serial_device_no_ports(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Test SERIAL device when no serial ports are found."""
|
"""Test SERIAL device when no serial ports are found."""
|
||||||
setup_core()
|
setup_core()
|
||||||
result = choose_upload_log_host(
|
with pytest.raises(
|
||||||
|
EsphomeError, match="All specified devices .* could not be resolved"
|
||||||
|
):
|
||||||
|
choose_upload_log_host(
|
||||||
default="SERIAL",
|
default="SERIAL",
|
||||||
check_default=None,
|
check_default=None,
|
||||||
purpose=Purpose.UPLOADING,
|
purpose=Purpose.UPLOADING,
|
||||||
)
|
)
|
||||||
assert result == []
|
|
||||||
assert "No serial ports found, skipping SERIAL device" in caplog.text
|
assert "No serial ports found, skipping SERIAL device" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
@@ -367,12 +369,14 @@ 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)."""
|
"""Test OTA device when API is configured (no upload without OTA in config)."""
|
||||||
setup_core(config={CONF_API: {}}, address="192.168.1.100")
|
setup_core(config={CONF_API: {}}, address="192.168.1.100")
|
||||||
|
|
||||||
result = choose_upload_log_host(
|
with pytest.raises(
|
||||||
|
EsphomeError, match="All specified devices .* could not be resolved"
|
||||||
|
):
|
||||||
|
choose_upload_log_host(
|
||||||
default="OTA",
|
default="OTA",
|
||||||
check_default=None,
|
check_default=None,
|
||||||
purpose=Purpose.UPLOADING,
|
purpose=Purpose.UPLOADING,
|
||||||
)
|
)
|
||||||
assert result == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_choose_upload_log_host_with_ota_device_with_api_config_logging() -> None:
|
def test_choose_upload_log_host_with_ota_device_with_api_config_logging() -> None:
|
||||||
@@ -405,12 +409,14 @@ def test_choose_upload_log_host_with_ota_device_no_fallback() -> None:
|
|||||||
"""Test OTA device with no valid fallback options."""
|
"""Test OTA device with no valid fallback options."""
|
||||||
setup_core()
|
setup_core()
|
||||||
|
|
||||||
result = choose_upload_log_host(
|
with pytest.raises(
|
||||||
|
EsphomeError, match="All specified devices .* could not be resolved"
|
||||||
|
):
|
||||||
|
choose_upload_log_host(
|
||||||
default="OTA",
|
default="OTA",
|
||||||
check_default=None,
|
check_default=None,
|
||||||
purpose=Purpose.UPLOADING,
|
purpose=Purpose.UPLOADING,
|
||||||
)
|
)
|
||||||
assert result == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.usefixtures("mock_choose_prompt")
|
@pytest.mark.usefixtures("mock_choose_prompt")
|
||||||
@@ -615,21 +621,19 @@ def test_choose_upload_log_host_empty_defaults_list() -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.usefixtures("mock_no_serial_ports", "mock_no_mqtt_logging")
|
@pytest.mark.usefixtures("mock_no_serial_ports", "mock_no_mqtt_logging")
|
||||||
def test_choose_upload_log_host_all_devices_unresolved(
|
def test_choose_upload_log_host_all_devices_unresolved() -> None:
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
) -> None:
|
|
||||||
"""Test when all specified devices cannot be resolved."""
|
"""Test when all specified devices cannot be resolved."""
|
||||||
setup_core()
|
setup_core()
|
||||||
|
|
||||||
result = choose_upload_log_host(
|
with pytest.raises(
|
||||||
|
EsphomeError,
|
||||||
|
match=r"All specified devices \['SERIAL', 'OTA'\] could not be resolved",
|
||||||
|
):
|
||||||
|
choose_upload_log_host(
|
||||||
default=["SERIAL", "OTA"],
|
default=["SERIAL", "OTA"],
|
||||||
check_default=None,
|
check_default=None,
|
||||||
purpose=Purpose.UPLOADING,
|
purpose=Purpose.UPLOADING,
|
||||||
)
|
)
|
||||||
assert result == []
|
|
||||||
assert (
|
|
||||||
"All specified devices: ['SERIAL', 'OTA'] could not be resolved." in caplog.text
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.usefixtures("mock_no_serial_ports", "mock_no_mqtt_logging")
|
@pytest.mark.usefixtures("mock_no_serial_ports", "mock_no_mqtt_logging")
|
||||||
@@ -762,12 +766,14 @@ def test_choose_upload_log_host_no_address_with_ota_config() -> None:
|
|||||||
"""Test OTA device when OTA is configured but no address is set."""
|
"""Test OTA device when OTA is configured but no address is set."""
|
||||||
setup_core(config={CONF_OTA: {}})
|
setup_core(config={CONF_OTA: {}})
|
||||||
|
|
||||||
result = choose_upload_log_host(
|
with pytest.raises(
|
||||||
|
EsphomeError, match="All specified devices .* could not be resolved"
|
||||||
|
):
|
||||||
|
choose_upload_log_host(
|
||||||
default="OTA",
|
default="OTA",
|
||||||
check_default=None,
|
check_default=None,
|
||||||
purpose=Purpose.UPLOADING,
|
purpose=Purpose.UPLOADING,
|
||||||
)
|
)
|
||||||
assert result == []
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
Reference in New Issue
Block a user