Repository: esphome/esphome (2026-04-13T23:37:51Z)

Issue:
Picking wrong files when flashing
### The problem

Hi,

I used `esphome wizard my-device.yaml` to create a `my-device.yaml` for my ESP32, which is an Adafruit QT Py ESP32-S3 (4M Flash 2M PSRAM). See below for the generated config.

Now I wanted to build and flash that to my board (using MacOS) using `esphome run my-device.yaml`.
But that failed:

```
Found multiple options for uploading, please choose one:
  [1] /dev/cu.usbmodem101 (QT Py ESP32-S3 (4MB Flash 2MB PSRAM))
  [2] Over The Air (p1s-temperature.local)
(number): 1
esptool v5.2.0

 Usage: esptool write-flash [OPTIONS] <address> <filename>...         
       
 Try 'esptool write-flash -h' for help     
 Invalid value for '<address> <filename>...': [Errno 2] No such file or directory: '<PROJECTDIR>/.esphome/build/my-device/variants/tasmota/variants/adafruit_qtpy_esp32s3_n4r2/tinyuf2.bin'        
```
And indeed, there is no `<PROJECTDIR>/.esphome/build/my-device/variants` folder. And why "tasmota" at all?

So I tried the web version (`esphome dashboard`). It was able to build and flash to my board using the previously generated yaml file.
Now I noticed that the esphome-cli uses esptool to flash the device, while the web-version uses esptool-js.
If I looked correctly, the web-version loads the built `<PROJECTDIR>/.esphome/build/my-device/.pioenvs/my-device/firmware.factory.bin` and flashes that using esptool-js.
So I looked at what esphome cli does. Inside `upload_using_esptool` it checks which images (`flash_images`) to flash. That is either a single file that is passed to the function or the function will determine itself which multiple files to flash based on some return value of `platformio_api.get_idedata`. I logged the command that was actually executed:

```
esptool --before default-reset --after hard-reset --baud 115200 --port /dev/cu.usbmodem2101 --chip esp32s3 write-flash -z --flash-size detect 0x10000  <PROJECTDIR>/.esphome/build/my-device/.pioenvs/my-device/firmware.bin 0x0  <PROJECTDIR>/.esphome/build/my-device/.pioenvs/my-device/bootloader.bin 0x8000  <PROJECTDIR>/.esphome/build/my-device/.pioenvs/my-device/partitions.bin 0x9000  <PROJECTDIR>/.esphome/build/my-device/.pioenvs/my-device/ota_data_initial.bin 0x2d0000  <PROJECTDIR>/.esphome/build/my-device/variants/tasmota/variants/adafruit_qtpy_esp32s3_n4r2/tinyuf2.bin
```

So we get in the case where `upload_using_esptool` is determining itself which files to flash. That means, the function does not get passed the path to the generated `firmware.factory.bin` directly.
Taking a closer look I noticed that the file-path, which gets passed to `upload_using_esptool` is taken from the command arguments, which means the arguments from the `esphome run`-call, which do not contain a `file`-argument.

So, am I doing something wrong, or did I find a bug? Maybe the bug is even in `platformio_api.get_idedata`, because it returns some tasmota image. When I removed that tasmota entry from `<PROJECTDIR>/.esphome/idedata/my-device.json` and run the `esphome run` command again, it was finally able to flash the device.

So, is it a bug of esphome or platformio?
Even if it is a platformio bug I would at least expect the same behavior of the esphome-cli and esphome-dashboard/web-version. 😉 

### Which version of ESPHome has the issue?

2026.3.2

### What type of installation are you using?

pip

### What platform are you using?

ESP32

### Component causing the issue

_No response_

### YAML Config

```yaml
esphome:
  name: my-device

esp32:
  board: adafruit_qtpy_esp32s3_n4r2
  framework:
    type: esp-idf

# Enable logging
logger:

# Enable Home Assistant API
api:
  encryption:
    key: "KEY"

ota:
  - platform: esphome
    password: "KEY"

wifi:
  ssid: "SSID"
  password: "KEY"

  # Enable fallback hotspot (captive portal) in case wifi connection fails
  ap:
    ssid: "My-Device Fallback Hotspot"
    password: "KEY"

captive_portal:
```

### Anything in the logs that might be useful for us?

```txt

```

### Additional information

_No response_


Discussion:

Patch:
diff --git a/esphome/__main__.py b/esphome/__main__.py
index 25b404ae45c0..7879cdad0ca4 100644
--- a/esphome/__main__.py
+++ b/esphome/__main__.py
@@ -750,8 +750,15 @@ def upload_using_esptool(
             platformio_api.FlashImage(
                 path=idedata.firmware_bin_path, offset=firmware_offset
             ),
-            *idedata.extra_flash_images,
         ]
+        for image in idedata.extra_flash_images:
+            if not image.path.is_file():
+                _LOGGER.warning(
+                    "Skipping missing flash image declared by platform: %s",
+                    image.path,
+                )
+                continue
+            flash_images.append(image)
 
     mcu = "esp8266"
     if CORE.is_esp32:


Test patch:
diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py
index 85536d2f1ce6..e07b4accf23f 100644
--- a/tests/unit_tests/test_main.py
+++ b/tests/unit_tests/test_main.py
@@ -1231,6 +1231,48 @@ def test_upload_using_esptool_path_conversion(
     assert partitions_path.endswith("partitions.bin")
 
 
+def test_upload_using_esptool_skips_missing_extra_flash_images(
+    tmp_path: Path,
+    mock_run_external_command_main: Mock,
+    mock_get_idedata: Mock,
+    caplog: pytest.LogCaptureFixture,
+) -> None:
+    """A non-existent path in extra_flash_images must be filtered out with a
+    warning, and must not appear in the esptool command line. Only the valid
+    images are flashed. Regression test for
+    https://github.com/esphome/esphome/issues/15634.
+    """
+    setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test")
+    CORE.data[KEY_ESP32] = {KEY_VARIANT: VARIANT_ESP32}
+
+    missing_path = tmp_path / "variants" / "tasmota" / "tinyuf2.bin"
+
+    mock_idedata = MagicMock(spec=platformio_api.IDEData)
+    mock_idedata.firmware_bin_path = tmp_path / "firmware.bin"
+    mock_idedata.extra_flash_images = [
+        platformio_api.FlashImage(path=tmp_path / "bootloader.bin", offset="0x1000"),
+        platformio_api.FlashImage(path=missing_path, offset="0x2d0000"),
+    ]
+    mock_get_idedata.return_value = mock_idedata
+
+    (tmp_path / "firmware.bin").touch()
+    (tmp_path / "bootloader.bin").touch()
+    # Intentionally do NOT create missing_path
+
+    config = {CONF_ESPHOME: {"platformio_options": {}}}
+
+    with caplog.at_level(logging.WARNING, logger="esphome.__main__"):
+        result = upload_using_esptool(config, "/dev/ttyUSB0", None, None)
+
+    assert result == 0
+    assert "Skipping missing flash image" in caplog.text
+    assert str(missing_path) in caplog.text
+
+    cmd_list = list(mock_run_external_command_main.call_args[0][1:])
+    assert str(missing_path) not in cmd_list
+    assert "0x2d0000" not in cmd_list
+
+
 def test_upload_using_esptool_with_file_path(
     tmp_path: Path,
     mock_run_external_command_main: Mock,