[rp2040] Use picotool for BOOTSEL upload instead of mass storage copy

Replace UF2 file copy to mass storage volume with direct picotool upload.
This avoids macOS "disk not ejected properly" warnings caused by the RP2040
resetting immediately after receiving the firmware.

- Use picotool (already installed by PlatformIO) for BOOTSEL detection
  and firmware upload via USB
- Upload ELF directly with `picotool load -v -x` for real-time progress
- Remove platform-specific mass storage volume detection (macOS/Linux/Windows)
- Show helpful udev rules message on Linux permission errors
This commit is contained in:
J. Nick Koston
2026-03-05 12:59:07 -10:00
parent a1ae9fadc2
commit aec76fafce
4 changed files with 302 additions and 323 deletions
+110 -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,10 @@ 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_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 +945,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 +956,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 +1258,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 +1932,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