mirror of
https://github.com/esphome/esphome.git
synced 2026-10-06 19:06:37 +00:00
[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:
+110
-76
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user