Merge branch 'rp2040-upload-improvements' into integration

This commit is contained in:
J. Nick Koston
2026-03-05 13:11:22 -10:00
4 changed files with 333 additions and 325 deletions
+135 -76
View File
@@ -40,8 +40,8 @@ from esphome.__main__ import (
show_logs,
upload_program,
upload_using_esptool,
upload_using_picotool,
upload_using_platformio,
upload_using_uf2_copy,
)
from esphome.components.esp32 import KEY_ESP32, KEY_VARIANT, VARIANT_ESP32
from esphome.const import (
@@ -177,9 +177,9 @@ def mock_upload_using_platformio() -> Generator[Mock]:
@pytest.fixture
def mock_upload_using_uf2_copy() -> Generator[Mock]:
"""Mock upload_using_uf2_copy for testing."""
with patch("esphome.__main__.upload_using_uf2_copy") as mock:
def mock_upload_using_picotool() -> Generator[Mock]:
"""Mock upload_using_picotool for testing."""
with patch("esphome.__main__.upload_using_picotool") as mock:
yield mock
@@ -861,18 +861,17 @@ def test_choose_upload_log_host_no_address_with_ota_config() -> None:
@pytest.mark.usefixtures("mock_no_serial_ports")
def test_choose_upload_log_host_no_defaults_with_rp2040_mass_storage(
def test_choose_upload_log_host_no_defaults_with_rp2040_bootsel(
mock_choose_prompt: Mock,
) -> None:
"""Test interactive mode shows RP2040 mass storage volumes."""
"""Test interactive mode shows RP2040 BOOTSEL option via picotool."""
setup_core(platform=PLATFORM_RP2040)
mock_volumes = [
MagicMock(path=Path("/Volumes/RPI-RP2"), description="RP2040 BOOTSEL"),
]
with patch(
"esphome.__main__.get_rp2040_mass_storage_volumes",
return_value=mock_volumes,
with (
patch(
"esphome.__main__._find_picotool", return_value=Path("/usr/bin/picotool")
),
patch("esphome.__main__.detect_rp2040_bootsel", return_value=1),
):
result = choose_upload_log_host(
default=None,
@@ -880,9 +879,8 @@ def test_choose_upload_log_host_no_defaults_with_rp2040_mass_storage(
purpose=Purpose.UPLOADING,
)
assert result == ["/dev/ttyUSB0"] # mock_choose_prompt default
vol_path = str(Path("/Volumes/RPI-RP2"))
mock_choose_prompt.assert_called_once_with(
[(f"{vol_path} (RP2040 BOOTSEL)", f"MS:{vol_path}")],
[("RP2040 BOOTSEL (via picotool)", "BOOTSEL")],
purpose=Purpose.UPLOADING,
)
@@ -894,9 +892,9 @@ def test_choose_upload_log_host_rp2040_no_device_shows_bootsel_help() -> None:
with (
patch(
"esphome.__main__.get_rp2040_mass_storage_volumes",
return_value=[],
"esphome.__main__._find_picotool", return_value=Path("/usr/bin/picotool")
),
patch("esphome.__main__.detect_rp2040_bootsel", return_value=0),
pytest.raises(EsphomeError, match="BOOTSEL"),
):
choose_upload_log_host(
@@ -919,9 +917,9 @@ def test_choose_upload_log_host_rp2040_bootsel_tip_with_ota(
with (
patch(
"esphome.__main__.get_rp2040_mass_storage_volumes",
return_value=[],
"esphome.__main__._find_picotool", return_value=Path("/usr/bin/picotool")
),
patch("esphome.__main__.detect_rp2040_bootsel", return_value=0),
patch(
"esphome.__main__.choose_prompt",
return_value="192.168.1.100",
@@ -936,10 +934,35 @@ def test_choose_upload_log_host_rp2040_bootsel_tip_with_ota(
assert "BOOTSEL" in caplog.text
def test_choose_upload_log_host_no_mass_storage_for_non_rp2040(
def test_choose_upload_log_host_rp2040_bootsel_tip_with_serial_ports(
caplog: pytest.LogCaptureFixture,
mock_choose_prompt: Mock,
) -> None:
"""Test BOOTSEL tip shown when serial ports exist but no BOOTSEL device."""
setup_core(platform=PLATFORM_RP2040)
mock_ports = [MockSerialPort("/dev/ttyACM0", "RP2040 Serial")]
with (
patch("esphome.__main__.get_serial_ports", return_value=mock_ports),
patch(
"esphome.__main__._find_picotool",
return_value=Path("/usr/bin/picotool"),
),
patch("esphome.__main__.detect_rp2040_bootsel", return_value=0),
caplog.at_level(logging.INFO, logger="esphome.__main__"),
):
choose_upload_log_host(
default=None,
check_default=None,
purpose=Purpose.UPLOADING,
)
assert "BOOTSEL" in caplog.text
def test_choose_upload_log_host_no_bootsel_for_non_rp2040(
mock_no_serial_ports: Mock,
) -> None:
"""Test that mass storage detection is not run for non-RP2040 platforms."""
"""Test that BOOTSEL detection is not run for non-RP2040 platforms."""
setup_core(
platform=PLATFORM_ESP32,
config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]},
@@ -947,9 +970,7 @@ def test_choose_upload_log_host_no_mass_storage_for_non_rp2040(
)
with (
patch(
"esphome.__main__.get_rp2040_mass_storage_volumes",
) as mock_get_volumes,
patch("esphome.__main__._find_picotool") as mock_find_picotool,
patch(
"esphome.__main__.choose_prompt",
return_value="192.168.1.100",
@@ -960,36 +981,32 @@ def test_choose_upload_log_host_no_mass_storage_for_non_rp2040(
check_default=None,
purpose=Purpose.UPLOADING,
)
mock_get_volumes.assert_not_called()
mock_find_picotool.assert_not_called()
def test_choose_upload_log_host_rp2040_serial_and_mass_storage(
def test_choose_upload_log_host_rp2040_serial_and_bootsel(
mock_choose_prompt: Mock,
) -> None:
"""Test both serial ports and mass storage volumes shown for RP2040."""
"""Test both serial ports and BOOTSEL option shown for RP2040."""
setup_core(platform=PLATFORM_RP2040)
mock_ports = [MockSerialPort("/dev/ttyACM0", "RP2040 Serial")]
mock_volumes = [
MagicMock(path=Path("/Volumes/RPI-RP2"), description="RP2040 BOOTSEL"),
]
with (
patch("esphome.__main__.get_serial_ports", return_value=mock_ports),
patch(
"esphome.__main__.get_rp2040_mass_storage_volumes",
return_value=mock_volumes,
"esphome.__main__._find_picotool", return_value=Path("/usr/bin/picotool")
),
patch("esphome.__main__.detect_rp2040_bootsel", return_value=1),
):
choose_upload_log_host(
default=None,
check_default=None,
purpose=Purpose.UPLOADING,
)
vol_path = str(Path("/Volumes/RPI-RP2"))
mock_choose_prompt.assert_called_once_with(
[
("/dev/ttyACM0 (RP2040 Serial)", "/dev/ttyACM0"),
(f"{vol_path} (RP2040 BOOTSEL)", f"MS:{vol_path}"),
("RP2040 BOOTSEL (via picotool)", "BOOTSEL"),
],
purpose=Purpose.UPLOADING,
)
@@ -1266,108 +1283,152 @@ def test_upload_program_serial_upload_failed(
mock_upload_using_esptool.assert_called_once()
def test_upload_program_mass_storage(
mock_upload_using_uf2_copy: Mock,
def test_upload_program_bootsel(
mock_upload_using_picotool: Mock,
mock_get_port_type: Mock,
) -> None:
"""Test upload_program with mass storage for RP2040."""
"""Test upload_program with BOOTSEL for RP2040."""
setup_core(platform=PLATFORM_RP2040)
mock_get_port_type.return_value = "MASS_STORAGE"
mock_upload_using_uf2_copy.return_value = 0
mock_get_port_type.return_value = "BOOTSEL"
mock_upload_using_picotool.return_value = 0
config = {}
args = MockArgs()
devices = ["MS:/Volumes/RPI-RP2"]
devices = ["BOOTSEL"]
exit_code, host = upload_program(config, args, devices)
assert exit_code == 0
# Mass storage device can't be used for logging, so host should be None
# BOOTSEL device can't be used for logging, so host should be None
assert host is None
mock_upload_using_uf2_copy.assert_called_once_with(config, "/Volumes/RPI-RP2")
mock_upload_using_picotool.assert_called_once_with(config)
def test_upload_program_mass_storage_failed(
mock_upload_using_uf2_copy: Mock,
def test_upload_program_bootsel_failed(
mock_upload_using_picotool: Mock,
mock_get_port_type: Mock,
) -> None:
"""Test upload_program when mass storage upload fails."""
"""Test upload_program when BOOTSEL upload fails."""
setup_core(platform=PLATFORM_RP2040)
mock_get_port_type.return_value = "MASS_STORAGE"
mock_upload_using_uf2_copy.return_value = 1
mock_get_port_type.return_value = "BOOTSEL"
mock_upload_using_picotool.return_value = 1
config = {}
args = MockArgs()
devices = ["MS:/Volumes/RPI-RP2"]
devices = ["BOOTSEL"]
exit_code, host = upload_program(config, args, devices)
assert exit_code == 1
assert host is None
mock_upload_using_uf2_copy.assert_called_once_with(config, "/Volumes/RPI-RP2")
mock_upload_using_picotool.assert_called_once_with(config)
def test_upload_using_uf2_copy_success(tmp_path: Path) -> None:
"""Test upload_using_uf2_copy copies UF2 file with progress."""
def test_upload_using_picotool_success(tmp_path: Path) -> None:
"""Test upload_using_picotool succeeds."""
setup_core(platform=PLATFORM_RP2040, tmp_path=tmp_path)
# Create a mock UF2 file
build_dir = tmp_path / "build"
build_dir.mkdir()
uf2_file = build_dir / "firmware.uf2"
uf2_file.write_bytes(b"\x00" * 1024)
firmware_elf = build_dir / "firmware.elf"
firmware_elf.write_bytes(b"\x00" * 1024)
# Create a mock mount point
mount_dir = tmp_path / "RPI-RP2"
mount_dir.mkdir()
# Create picotool binary
packages_dir = tmp_path / "packages"
toolchain_bin = packages_dir / "toolchain-rp2040-earlephilhower" / "bin"
toolchain_bin.mkdir(parents=True)
picotool_dir = packages_dir / "tool-picotool-rp2040-earlephilhower"
picotool_dir.mkdir(parents=True)
picotool = picotool_dir / "picotool"
picotool.touch()
mock_idedata = MagicMock()
mock_idedata.firmware_elf_path = str(build_dir / "firmware.elf")
mock_idedata.firmware_elf_path = str(firmware_elf)
mock_idedata.cc_path = str(toolchain_bin / "arm-none-eabi-gcc")
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stderr = b""
config = {}
with patch("esphome.platformio_api.get_idedata", return_value=mock_idedata):
exit_code = upload_using_uf2_copy(config, str(mount_dir))
with (
patch("esphome.platformio_api.get_idedata", return_value=mock_idedata),
patch("subprocess.run", return_value=mock_result),
):
exit_code = upload_using_picotool(config)
assert exit_code == 0
assert (mount_dir / "firmware.uf2").exists()
assert (mount_dir / "firmware.uf2").read_bytes() == b"\x00" * 1024
def test_upload_using_uf2_copy_no_uf2_file(tmp_path: Path) -> None:
"""Test upload_using_uf2_copy when UF2 file is missing."""
def test_upload_using_picotool_no_elf(tmp_path: Path) -> None:
"""Test upload_using_picotool when ELF file is missing."""
setup_core(platform=PLATFORM_RP2040, tmp_path=tmp_path)
build_dir = tmp_path / "build"
build_dir.mkdir()
mount_dir = tmp_path / "RPI-RP2"
mount_dir.mkdir()
mock_idedata = MagicMock()
mock_idedata.firmware_elf_path = str(build_dir / "firmware.elf")
mock_idedata.cc_path = "/fake/path/gcc"
config = {}
with patch("esphome.platformio_api.get_idedata", return_value=mock_idedata):
exit_code = upload_using_uf2_copy(config, str(mount_dir))
exit_code = upload_using_picotool(config)
assert exit_code == 1
def test_upload_using_uf2_copy_mount_gone(tmp_path: Path) -> None:
"""Test upload_using_uf2_copy when mount point disappeared."""
def test_upload_using_picotool_not_found(tmp_path: Path) -> None:
"""Test upload_using_picotool when picotool binary not found."""
setup_core(platform=PLATFORM_RP2040, tmp_path=tmp_path)
build_dir = tmp_path / "build"
build_dir.mkdir()
uf2_file = build_dir / "firmware.uf2"
uf2_file.write_bytes(b"\x00" * 512)
firmware_elf = build_dir / "firmware.elf"
firmware_elf.write_bytes(b"\x00" * 512)
mock_idedata = MagicMock()
mock_idedata.firmware_elf_path = str(build_dir / "firmware.elf")
mock_idedata.firmware_elf_path = str(firmware_elf)
mock_idedata.cc_path = "/fake/path/gcc"
config = {}
with patch("esphome.platformio_api.get_idedata", return_value=mock_idedata):
exit_code = upload_using_uf2_copy(config, str(tmp_path / "nonexistent"))
exit_code = upload_using_picotool(config)
assert exit_code == 1
def test_upload_using_picotool_permission_error(tmp_path: Path) -> None:
"""Test upload_using_picotool shows helpful message on permission error."""
setup_core(platform=PLATFORM_RP2040, tmp_path=tmp_path)
build_dir = tmp_path / "build"
build_dir.mkdir()
firmware_elf = build_dir / "firmware.elf"
firmware_elf.write_bytes(b"\x00" * 512)
packages_dir = tmp_path / "packages"
toolchain_bin = packages_dir / "toolchain-rp2040-earlephilhower" / "bin"
toolchain_bin.mkdir(parents=True)
picotool_dir = packages_dir / "tool-picotool-rp2040-earlephilhower"
picotool_dir.mkdir(parents=True)
picotool = picotool_dir / "picotool"
picotool.touch()
mock_idedata = MagicMock()
mock_idedata.firmware_elf_path = str(firmware_elf)
mock_idedata.cc_path = str(toolchain_bin / "arm-none-eabi-gcc")
mock_result = MagicMock()
mock_result.returncode = 1
mock_result.stderr = b"LIBUSB_ERROR_ACCESS"
config = {}
with (
patch("esphome.platformio_api.get_idedata", return_value=mock_idedata),
patch("subprocess.run", return_value=mock_result),
):
exit_code = upload_using_picotool(config)
assert exit_code == 1
@@ -1896,9 +1957,7 @@ def test_get_port_type() -> None:
assert get_port_type("esphome-device.local") == "NETWORK"
assert get_port_type("10.0.0.1") == "NETWORK"
assert get_port_type("MS:/Volumes/RPI-RP2") == "MASS_STORAGE"
assert get_port_type("MS:/media/user/RPI-RP2") == "MASS_STORAGE"
assert get_port_type("MS:D:\\") == "MASS_STORAGE"
assert get_port_type("BOOTSEL") == "BOOTSEL"
def test_has_mqtt_ip_lookup() -> None:
+91 -132
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
from pathlib import Path
import subprocess
from unittest.mock import MagicMock, patch
import pytest
@@ -405,137 +406,95 @@ def test_shlex_quote_edge_cases() -> None:
assert util.shlex_quote(" ") == "' '"
def test_get_rp2040_mass_storage_volumes_macos(tmp_path: Path) -> None:
"""Test RP2040 mass storage detection on macOS."""
volumes_dir = tmp_path / "Volumes"
volumes_dir.mkdir()
rpi_vol = volumes_dir / "RPI-RP2"
rpi_vol.mkdir()
def test_get_picotool_path_found(tmp_path: Path) -> None:
"""Test picotool path derivation from cc_path."""
# Create the expected directory structure
packages_dir = tmp_path / "packages"
toolchain_dir = packages_dir / "toolchain-rp2040-earlephilhower" / "bin"
toolchain_dir.mkdir(parents=True)
gcc = toolchain_dir / "arm-none-eabi-gcc"
gcc.touch()
with (
patch("esphome.util.sys") as mock_sys,
patch("esphome.util.Path") as mock_path_cls,
picotool_dir = packages_dir / "tool-picotool-rp2040-earlephilhower"
picotool_dir.mkdir(parents=True)
picotool = picotool_dir / "picotool"
picotool.touch()
result = util.get_picotool_path(str(gcc))
assert result == picotool
def test_get_picotool_path_not_found(tmp_path: Path) -> None:
"""Test picotool path returns None when not installed."""
packages_dir = tmp_path / "packages"
toolchain_dir = packages_dir / "toolchain-rp2040-earlephilhower" / "bin"
toolchain_dir.mkdir(parents=True)
gcc = toolchain_dir / "arm-none-eabi-gcc"
gcc.touch()
result = util.get_picotool_path(str(gcc))
assert result is None
def test_get_picotool_path_windows(tmp_path: Path) -> None:
"""Test picotool path uses .exe on Windows."""
packages_dir = tmp_path / "packages"
toolchain_dir = packages_dir / "toolchain-rp2040-earlephilhower" / "bin"
toolchain_dir.mkdir(parents=True)
gcc = toolchain_dir / "arm-none-eabi-gcc.exe"
gcc.touch()
picotool_dir = packages_dir / "tool-picotool-rp2040-earlephilhower"
picotool_dir.mkdir(parents=True)
picotool = picotool_dir / "picotool.exe"
picotool.touch()
with patch("esphome.util.sys.platform", "win32"):
result = util.get_picotool_path(str(gcc))
assert result == picotool
def test_detect_rp2040_bootsel_found() -> None:
"""Test BOOTSEL device detection when device is present."""
mock_result = MagicMock()
mock_result.stdout = b"Device Information\n type: RP2040\n"
with patch("esphome.util.subprocess.run", return_value=mock_result):
count = util.detect_rp2040_bootsel("/usr/bin/picotool")
assert count == 1
def test_detect_rp2040_bootsel_multiple() -> None:
"""Test BOOTSEL detection with multiple devices."""
mock_result = MagicMock()
mock_result.stdout = b"type: RP2040\ntype: RP2350\n"
with patch("esphome.util.subprocess.run", return_value=mock_result):
count = util.detect_rp2040_bootsel("/usr/bin/picotool")
assert count == 2
def test_detect_rp2040_bootsel_none() -> None:
"""Test BOOTSEL detection when no device found."""
mock_result = MagicMock()
mock_result.stdout = (
b"No accessible RP2040/RP2350 devices in BOOTSEL mode were found.\n"
)
with patch("esphome.util.subprocess.run", return_value=mock_result):
count = util.detect_rp2040_bootsel("/usr/bin/picotool")
assert count == 0
def test_detect_rp2040_bootsel_oserror() -> None:
"""Test BOOTSEL detection handles OSError."""
with patch("esphome.util.subprocess.run", side_effect=OSError("not found")):
count = util.detect_rp2040_bootsel("/usr/bin/picotool")
assert count == 0
def test_detect_rp2040_bootsel_timeout() -> None:
"""Test BOOTSEL detection handles timeout."""
with patch(
"esphome.util.subprocess.run",
side_effect=subprocess.TimeoutExpired("picotool", 10),
):
mock_sys.platform = "darwin"
# Make Path("/Volumes") return our tmp_path version
mock_path_cls.side_effect = lambda p: (
volumes_dir if p == "/Volumes" else Path(p)
)
result = util.get_rp2040_mass_storage_volumes()
assert len(result) == 1
assert result[0].description == "RP2040 BOOTSEL"
def test_get_rp2040_mass_storage_volumes_none_found(tmp_path: Path) -> None:
"""Test RP2040 mass storage detection when no volumes found."""
# Point at an empty directory so no RPI-RP2* matches
empty_dir = tmp_path / "Volumes"
empty_dir.mkdir()
with (
patch("esphome.util.sys.platform", "darwin"),
patch(
"esphome.util.Path",
side_effect=lambda p: empty_dir if p == "/Volumes" else Path(p),
),
):
result = util.get_rp2040_mass_storage_volumes()
assert result == []
def test_get_rp2040_mass_storage_volumes_linux(tmp_path: Path) -> None:
"""Test RP2040 mass storage detection on Linux."""
# Create /media/<user>/RPI-RP2 structure
media_dir = tmp_path / "media"
media_dir.mkdir()
user_dir = media_dir / "testuser"
user_dir.mkdir()
rp2_dir = user_dir / "RPI-RP2"
rp2_dir.mkdir()
# Create /run/media and /mnt as empty dirs
run_media_dir = tmp_path / "run_media"
run_media_dir.mkdir()
mnt_dir = tmp_path / "mnt"
mnt_dir.mkdir()
def mock_path_side_effect(p: str) -> Path:
if p == "/media":
return media_dir
if p == "/run/media":
return run_media_dir
if p == "/mnt":
return mnt_dir
return Path(p)
with (
patch("esphome.util.sys.platform", "linux"),
patch("esphome.util.Path", side_effect=mock_path_side_effect),
):
result = util.get_rp2040_mass_storage_volumes()
assert len(result) == 1
assert result[0].description == "RP2040 BOOTSEL"
def test_get_rp2040_mass_storage_volumes_linux_oserror(tmp_path: Path) -> None:
"""Test RP2040 mass storage detection on Linux handles OSError."""
media_dir = tmp_path / "media"
media_dir.mkdir()
def mock_path_side_effect(p: str) -> Path:
if p == "/media":
return media_dir
if p in ("/run/media", "/mnt"):
# Return a path that will raise OSError when globbed
return tmp_path / "nonexistent"
return Path(p)
with (
patch("esphome.util.sys.platform", "linux"),
patch("esphome.util.Path", side_effect=mock_path_side_effect),
):
result = util.get_rp2040_mass_storage_volumes()
assert result == []
def test_get_rp2040_mass_storage_volumes_windows() -> None:
"""Test RP2040 mass storage detection on Windows."""
mock_ctypes = MagicMock()
mock_volume_name = MagicMock()
mock_volume_name.value = "RPI-RP2"
mock_ctypes.create_unicode_buffer.return_value = mock_volume_name
def path_side_effect(p: str) -> MagicMock:
inst = MagicMock()
inst.exists.return_value = p == "D:\\"
return inst
with (
patch("esphome.util.sys.platform", "win32"),
patch.dict("sys.modules", {"ctypes": mock_ctypes}),
patch("esphome.util.Path", side_effect=path_side_effect),
):
result = util.get_rp2040_mass_storage_volumes()
assert len(result) >= 1
assert result[0].description == "RP2040 BOOTSEL"
def test_get_rp2040_mass_storage_volumes_unsupported_platform() -> None:
"""Test RP2040 mass storage detection on unsupported platform returns empty."""
with patch("esphome.util.sys.platform", "freebsd"):
result = util.get_rp2040_mass_storage_volumes()
assert result == []
def test_mass_storage_volume_attributes() -> None:
"""Test MassStorageVolume class attributes."""
vol = util.MassStorageVolume(Path("/Volumes/RPI-RP2"), "RP2040 BOOTSEL")
assert vol.path == Path("/Volumes/RPI-RP2")
assert vol.description == "RP2040 BOOTSEL"
count = util.detect_rp2040_bootsel("/usr/bin/picotool")
assert count == 0