mirror of
				https://github.com/esphome/esphome.git
				synced 2025-11-04 08:28:41 +00:00 
			
		
		
		
	Compare commits
	
		
			18 Commits
		
	
	
		
			cache_gith
			...
			2025.10.3
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					6a478b9070 | ||
| 
						 | 
					a32a1d11fb | ||
| 
						 | 
					daeb8ef88c | ||
| 
						 | 
					febee437d6 | ||
| 
						 | 
					de2f475dbd | ||
| 
						 | 
					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
 | 
			
		||||
# control system is used.
 | 
			
		||||
 | 
			
		||||
PROJECT_NUMBER         = 2025.10.1
 | 
			
		||||
PROJECT_NUMBER         = 2025.10.3
 | 
			
		||||
 | 
			
		||||
# 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
 | 
			
		||||
 
 | 
			
		||||
@@ -185,7 +185,9 @@ def choose_upload_log_host(
 | 
			
		||||
            else:
 | 
			
		||||
                resolved.append(device)
 | 
			
		||||
        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
 | 
			
		||||
 | 
			
		||||
    # No devices specified, show interactive chooser
 | 
			
		||||
 
 | 
			
		||||
@@ -41,7 +41,7 @@ CONFIG_SCHEMA = cv.All(
 | 
			
		||||
    cv.Schema(
 | 
			
		||||
        {
 | 
			
		||||
            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(
 | 
			
		||||
                IAQ_MODE_OPTIONS, upper=True
 | 
			
		||||
            ),
 | 
			
		||||
 
 | 
			
		||||
@@ -139,7 +139,7 @@ CONFIG_SCHEMA_BASE = (
 | 
			
		||||
            cv.Optional(CONF_SUPPLY_VOLTAGE, default="3.3V"): cv.enum(
 | 
			
		||||
                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(
 | 
			
		||||
                CONF_STATE_SAVE_INTERVAL, default="6hours"
 | 
			
		||||
            ): cv.positive_time_period_minutes,
 | 
			
		||||
 
 | 
			
		||||
@@ -30,14 +30,12 @@ class DateTimeBase : public EntityBase {
 | 
			
		||||
#endif
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
#ifdef USE_TIME
 | 
			
		||||
class DateTimeStateTrigger : public Trigger<ESPTime> {
 | 
			
		||||
 public:
 | 
			
		||||
  explicit DateTimeStateTrigger(DateTimeBase *parent) {
 | 
			
		||||
    parent->add_on_state_callback([this, parent]() { this->trigger(parent->state_as_esptime()); });
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
#endif
 | 
			
		||||
 | 
			
		||||
}  // namespace datetime
 | 
			
		||||
}  // namespace esphome
 | 
			
		||||
 
 | 
			
		||||
@@ -790,6 +790,7 @@ async def to_code(config):
 | 
			
		||||
        add_idf_sdkconfig_option("CONFIG_AUTOSTART_ARDUINO", True)
 | 
			
		||||
        add_idf_sdkconfig_option("CONFIG_MBEDTLS_PSK_MODES", 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")
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -6,6 +6,7 @@
 | 
			
		||||
#include <freertos/FreeRTOS.h>
 | 
			
		||||
#include <freertos/task.h>
 | 
			
		||||
#include <esp_idf_version.h>
 | 
			
		||||
#include <esp_ota_ops.h>
 | 
			
		||||
#include <esp_task_wdt.h>
 | 
			
		||||
#include <esp_timer.h>
 | 
			
		||||
#include <soc/rtc.h>
 | 
			
		||||
@@ -52,6 +53,16 @@ void arch_init() {
 | 
			
		||||
  disableCore1WDT();
 | 
			
		||||
#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(); }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -16,7 +16,8 @@ void HDC1080Component::setup() {
 | 
			
		||||
 | 
			
		||||
  // if configuration fails - there is a problem
 | 
			
		||||
  if (this->write_register(HDC1080_CMD_CONFIGURATION, config, 2) != i2c::ERROR_OK) {
 | 
			
		||||
    this->mark_failed();
 | 
			
		||||
    ESP_LOGW(TAG, "Failed to configure HDC1080");
 | 
			
		||||
    this->status_set_warning();
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -9,8 +9,8 @@ static const char *const TAG = "htu21d";
 | 
			
		||||
 | 
			
		||||
static const uint8_t HTU21D_ADDRESS = 0x40;
 | 
			
		||||
static const uint8_t HTU21D_REGISTER_RESET = 0xFE;
 | 
			
		||||
static const uint8_t HTU21D_REGISTER_TEMPERATURE = 0xE3;
 | 
			
		||||
static const uint8_t HTU21D_REGISTER_HUMIDITY = 0xE5;
 | 
			
		||||
static const uint8_t HTU21D_REGISTER_TEMPERATURE = 0xF3;
 | 
			
		||||
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_REGISTER_STATUS = 0xE7;
 | 
			
		||||
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.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(
 | 
			
		||||
                sensor.Sensor
 | 
			
		||||
            ),
 | 
			
		||||
 
 | 
			
		||||
@@ -56,6 +56,13 @@ uint32_t ESP8266UartComponent::get_config() {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
void ESP8266UartComponent::setup() {
 | 
			
		||||
  if (this->rx_pin_) {
 | 
			
		||||
    this->rx_pin_->setup();
 | 
			
		||||
  }
 | 
			
		||||
  if (this->tx_pin_ && this->rx_pin_ != this->tx_pin_) {
 | 
			
		||||
    this->tx_pin_->setup();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Use Arduino HardwareSerial UARTs if all used pins match the ones
 | 
			
		||||
  // preconfigured by the platform. For example if RX disabled but TX pin
 | 
			
		||||
  // is 1 we still want to use Serial.
 | 
			
		||||
 
 | 
			
		||||
@@ -6,6 +6,9 @@
 | 
			
		||||
#include "esphome/core/defines.h"
 | 
			
		||||
#include "esphome/core/helpers.h"
 | 
			
		||||
#include "esphome/core/log.h"
 | 
			
		||||
#include "esphome/core/gpio.h"
 | 
			
		||||
#include "driver/gpio.h"
 | 
			
		||||
#include "soc/gpio_num.h"
 | 
			
		||||
 | 
			
		||||
#ifdef USE_LOGGER
 | 
			
		||||
#include "esphome/components/logger/logger.h"
 | 
			
		||||
@@ -104,6 +107,13 @@ void IDFUARTComponent::load_settings(bool dump_config) {
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (this->rx_pin_) {
 | 
			
		||||
    this->rx_pin_->setup();
 | 
			
		||||
  }
 | 
			
		||||
  if (this->tx_pin_ && this->rx_pin_ != this->tx_pin_) {
 | 
			
		||||
    this->tx_pin_->setup();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  int8_t tx = this->tx_pin_ != nullptr ? this->tx_pin_->get_pin() : -1;
 | 
			
		||||
  int8_t rx = this->rx_pin_ != nullptr ? this->rx_pin_->get_pin() : -1;
 | 
			
		||||
  int8_t flow_control = this->flow_control_pin_ != nullptr ? this->flow_control_pin_->get_pin() : -1;
 | 
			
		||||
 
 | 
			
		||||
@@ -46,6 +46,13 @@ uint16_t LibreTinyUARTComponent::get_config() {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
void LibreTinyUARTComponent::setup() {
 | 
			
		||||
  if (this->rx_pin_) {
 | 
			
		||||
    this->rx_pin_->setup();
 | 
			
		||||
  }
 | 
			
		||||
  if (this->tx_pin_ && this->rx_pin_ != this->tx_pin_) {
 | 
			
		||||
    this->tx_pin_->setup();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  int8_t tx_pin = tx_pin_ == nullptr ? -1 : tx_pin_->get_pin();
 | 
			
		||||
  int8_t rx_pin = rx_pin_ == nullptr ? -1 : rx_pin_->get_pin();
 | 
			
		||||
  bool tx_inverted = tx_pin_ != nullptr && tx_pin_->is_inverted();
 | 
			
		||||
 
 | 
			
		||||
@@ -52,6 +52,13 @@ uint16_t RP2040UartComponent::get_config() {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
void RP2040UartComponent::setup() {
 | 
			
		||||
  if (this->rx_pin_) {
 | 
			
		||||
    this->rx_pin_->setup();
 | 
			
		||||
  }
 | 
			
		||||
  if (this->tx_pin_ && this->rx_pin_ != this->tx_pin_) {
 | 
			
		||||
    this->tx_pin_->setup();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  uint16_t config = get_config();
 | 
			
		||||
 | 
			
		||||
  constexpr uint32_t valid_tx_uart_0 = __bitset({0, 12, 16, 28});
 | 
			
		||||
 
 | 
			
		||||
@@ -244,6 +244,20 @@ RESERVED_IDS = [
 | 
			
		||||
    "uart0",
 | 
			
		||||
    "uart1",
 | 
			
		||||
    "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
 | 
			
		||||
 | 
			
		||||
__version__ = "2025.10.1"
 | 
			
		||||
__version__ = "2025.10.3"
 | 
			
		||||
 | 
			
		||||
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
 | 
			
		||||
VALID_SUBSTITUTIONS_CHARACTERS = (
 | 
			
		||||
@@ -696,6 +696,7 @@ CONF_OPEN_DRAIN = "open_drain"
 | 
			
		||||
CONF_OPEN_DRAIN_INTERRUPT = "open_drain_interrupt"
 | 
			
		||||
CONF_OPEN_DURATION = "open_duration"
 | 
			
		||||
CONF_OPEN_ENDSTOP = "open_endstop"
 | 
			
		||||
CONF_OPENTHREAD = "openthread"
 | 
			
		||||
CONF_OPERATION = "operation"
 | 
			
		||||
CONF_OPTIMISTIC = "optimistic"
 | 
			
		||||
CONF_OPTION = "option"
 | 
			
		||||
 
 | 
			
		||||
@@ -11,6 +11,7 @@ from esphome.const import (
 | 
			
		||||
    CONF_COMMENT,
 | 
			
		||||
    CONF_ESPHOME,
 | 
			
		||||
    CONF_ETHERNET,
 | 
			
		||||
    CONF_OPENTHREAD,
 | 
			
		||||
    CONF_PORT,
 | 
			
		||||
    CONF_USE_ADDRESS,
 | 
			
		||||
    CONF_WEB_SERVER,
 | 
			
		||||
@@ -641,6 +642,9 @@ class EsphomeCore:
 | 
			
		||||
        if CONF_ETHERNET in self.config:
 | 
			
		||||
            return self.config[CONF_ETHERNET][CONF_USE_ADDRESS]
 | 
			
		||||
 | 
			
		||||
        if CONF_OPENTHREAD in self.config:
 | 
			
		||||
            return f"{self.name}.local"
 | 
			
		||||
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
 
 | 
			
		||||
@@ -10,6 +10,10 @@ from esphome.helpers import get_bool_env
 | 
			
		||||
 | 
			
		||||
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:
 | 
			
		||||
    """Settings for the dashboard."""
 | 
			
		||||
@@ -48,7 +52,12 @@ class DashboardSettings:
 | 
			
		||||
        self.config_dir = Path(args.configuration)
 | 
			
		||||
        self.absolute_config_dir = self.config_dir.resolve()
 | 
			
		||||
        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
 | 
			
		||||
    def relative_url(self) -> str:
 | 
			
		||||
 
 | 
			
		||||
@@ -1058,7 +1058,8 @@ class DownloadBinaryRequestHandler(BaseHandler):
 | 
			
		||||
            "download",
 | 
			
		||||
            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():
 | 
			
		||||
            args = ["esphome", "idedata", settings.rel_path(configuration)]
 | 
			
		||||
 
 | 
			
		||||
@@ -224,36 +224,37 @@ def resolve_ip_address(
 | 
			
		||||
        return res
 | 
			
		||||
 | 
			
		||||
    # Process hosts
 | 
			
		||||
    cached_addresses: list[str] = []
 | 
			
		||||
 | 
			
		||||
    uncached_hosts: list[str] = []
 | 
			
		||||
    has_cache = address_cache is not None
 | 
			
		||||
 | 
			
		||||
    for h in hosts:
 | 
			
		||||
        if is_ip_address(h):
 | 
			
		||||
            if has_cache:
 | 
			
		||||
                # If we have a cache, treat IPs as cached
 | 
			
		||||
                cached_addresses.append(h)
 | 
			
		||||
            else:
 | 
			
		||||
                # If no cache, pass IPs through to resolver with hostnames
 | 
			
		||||
                uncached_hosts.append(h)
 | 
			
		||||
            _add_ip_addresses_to_addrinfo([h], port, res)
 | 
			
		||||
        elif address_cache and (cached := address_cache.get_addresses(h)):
 | 
			
		||||
            # Found in cache
 | 
			
		||||
            cached_addresses.extend(cached)
 | 
			
		||||
            _add_ip_addresses_to_addrinfo(cached, port, res)
 | 
			
		||||
        else:
 | 
			
		||||
            # Not cached, need to resolve
 | 
			
		||||
            if address_cache and address_cache.has_cache():
 | 
			
		||||
                _LOGGER.info("Host %s not in cache, will need to resolve", h)
 | 
			
		||||
            uncached_hosts.append(h)
 | 
			
		||||
 | 
			
		||||
    # Process cached addresses (includes direct IPs and cached lookups)
 | 
			
		||||
    _add_ip_addresses_to_addrinfo(cached_addresses, port, res)
 | 
			
		||||
 | 
			
		||||
    # If we have uncached hosts (only non-IP hostnames), resolve them
 | 
			
		||||
    if uncached_hosts:
 | 
			
		||||
        from aioesphomeapi.host_resolver import AddrInfo as AioAddrInfo
 | 
			
		||||
 | 
			
		||||
        from esphome.core import EsphomeError
 | 
			
		||||
        from esphome.resolver import AsyncResolver
 | 
			
		||||
 | 
			
		||||
        resolver = AsyncResolver(uncached_hosts, port)
 | 
			
		||||
        addr_infos: list[AioAddrInfo] = []
 | 
			
		||||
        try:
 | 
			
		||||
            addr_infos = resolver.resolve()
 | 
			
		||||
        except EsphomeError as err:
 | 
			
		||||
            if not res:
 | 
			
		||||
                # No pre-resolved addresses available, DNS resolution is fatal
 | 
			
		||||
                raise
 | 
			
		||||
            _LOGGER.info("%s (using %d already resolved IP addresses)", err, len(res))
 | 
			
		||||
 | 
			
		||||
        # Convert aioesphomeapi AddrInfo to our format
 | 
			
		||||
        for addr_info in addr_infos:
 | 
			
		||||
            sockaddr = addr_info.sockaddr
 | 
			
		||||
 
 | 
			
		||||
@@ -2,11 +2,13 @@
 | 
			
		||||
 | 
			
		||||
from __future__ import annotations
 | 
			
		||||
 | 
			
		||||
from argparse import Namespace
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
import tempfile
 | 
			
		||||
 | 
			
		||||
import pytest
 | 
			
		||||
 | 
			
		||||
from esphome.core import CORE
 | 
			
		||||
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")
 | 
			
		||||
    expected = dashboard_settings.config_dir / "123" / "456.789"
 | 
			
		||||
    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 argparse import Namespace
 | 
			
		||||
import asyncio
 | 
			
		||||
from collections.abc import Generator
 | 
			
		||||
from contextlib import asynccontextmanager
 | 
			
		||||
@@ -17,6 +18,8 @@ from tornado.ioloop import IOLoop
 | 
			
		||||
from tornado.testing import bind_unused_port
 | 
			
		||||
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.const import DashboardEvent
 | 
			
		||||
from esphome.dashboard.core import DASHBOARD
 | 
			
		||||
@@ -32,6 +35,26 @@ from esphome.zeroconf import DiscoveredImport
 | 
			
		||||
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:
 | 
			
		||||
    def __init__(self, io_loop: IOLoop, client: AsyncHTTPClient, port: int) -> None:
 | 
			
		||||
        self.io_loop = io_loop
 | 
			
		||||
@@ -414,6 +437,180 @@ async def test_download_binary_handler_idedata_fallback(
 | 
			
		||||
    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
 | 
			
		||||
async def test_edit_request_handler_post_invalid_file(
 | 
			
		||||
    dashboard: DashboardTestHelper,
 | 
			
		||||
@@ -1302,3 +1499,71 @@ async def test_dashboard_subscriber_refresh_event(
 | 
			
		||||
 | 
			
		||||
        # Give it a moment to clean up
 | 
			
		||||
        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"
 | 
			
		||||
 | 
			
		||||
    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):
 | 
			
		||||
        target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "esp32"}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -454,9 +454,27 @@ def test_resolve_ip_address_mixed_list() -> None:
 | 
			
		||||
        # Mix of IP and hostname - should use async resolver
 | 
			
		||||
        result = helpers.resolve_ip_address(["192.168.1.100", "test.local"], 6053)
 | 
			
		||||
 | 
			
		||||
        assert len(result) == 2
 | 
			
		||||
        assert result[0][4][0] == "192.168.1.100"
 | 
			
		||||
        assert result[1][4][0] == "192.168.1.200"
 | 
			
		||||
        MockResolver.assert_called_once_with(["test.local"], 6053)
 | 
			
		||||
        mock_resolver.resolve.assert_called_once()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_resolve_ip_address_mixed_list_fail() -> None:
 | 
			
		||||
    """Test resolving a mix of IPs and hostnames with resolve failed."""
 | 
			
		||||
    with patch("esphome.resolver.AsyncResolver") as MockResolver:
 | 
			
		||||
        mock_resolver = MockResolver.return_value
 | 
			
		||||
        mock_resolver.resolve.side_effect = EsphomeError(
 | 
			
		||||
            "Error resolving IP address: [test.local]"
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # Mix of IP and hostname - should use async resolver
 | 
			
		||||
        result = helpers.resolve_ip_address(["192.168.1.100", "test.local"], 6053)
 | 
			
		||||
 | 
			
		||||
        assert len(result) == 1
 | 
			
		||||
        assert result[0][4][0] == "192.168.1.200"
 | 
			
		||||
        MockResolver.assert_called_once_with(["192.168.1.100", "test.local"], 6053)
 | 
			
		||||
        assert result[0][4][0] == "192.168.1.100"
 | 
			
		||||
        MockResolver.assert_called_once_with(["test.local"], 6053)
 | 
			
		||||
        mock_resolver.resolve.assert_called_once()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -321,12 +321,14 @@ def test_choose_upload_log_host_with_serial_device_no_ports(
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Test SERIAL device when no serial ports are found."""
 | 
			
		||||
    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",
 | 
			
		||||
            check_default=None,
 | 
			
		||||
            purpose=Purpose.UPLOADING,
 | 
			
		||||
        )
 | 
			
		||||
    assert result == []
 | 
			
		||||
    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)."""
 | 
			
		||||
    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",
 | 
			
		||||
            check_default=None,
 | 
			
		||||
            purpose=Purpose.UPLOADING,
 | 
			
		||||
        )
 | 
			
		||||
    assert result == []
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
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."""
 | 
			
		||||
    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",
 | 
			
		||||
            check_default=None,
 | 
			
		||||
            purpose=Purpose.UPLOADING,
 | 
			
		||||
        )
 | 
			
		||||
    assert result == []
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@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")
 | 
			
		||||
def test_choose_upload_log_host_all_devices_unresolved(
 | 
			
		||||
    caplog: pytest.LogCaptureFixture,
 | 
			
		||||
) -> None:
 | 
			
		||||
def test_choose_upload_log_host_all_devices_unresolved() -> None:
 | 
			
		||||
    """Test when all specified devices cannot be resolved."""
 | 
			
		||||
    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"],
 | 
			
		||||
            check_default=None,
 | 
			
		||||
            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")
 | 
			
		||||
@@ -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."""
 | 
			
		||||
    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",
 | 
			
		||||
            check_default=None,
 | 
			
		||||
            purpose=Purpose.UPLOADING,
 | 
			
		||||
        )
 | 
			
		||||
    assert result == []
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@dataclass
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user