diff --git a/esphome/__main__.py b/esphome/__main__.py index 16fa32ee96..21c5db606e 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -259,13 +259,17 @@ def choose_upload_log_host( ] # Add RP2040 BOOTSEL device option when uploading + bootsel_permission_error = False if ( purpose == Purpose.UPLOADING and CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040 and (picotool := _find_picotool()) is not None - and detect_rp2040_bootsel(picotool) > 0 ): - options.append(("RP2040 BOOTSEL (via picotool)", "BOOTSEL")) + bootsel = detect_rp2040_bootsel(picotool) + if bootsel.device_count > 0: + options.append(("RP2040 BOOTSEL (via picotool)", "BOOTSEL")) + elif bootsel.permission_error: + bootsel_permission_error = True if purpose == Purpose.LOGGING: if has_mqtt_logging(): @@ -290,6 +294,17 @@ def choose_upload_log_host( and CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040 and not any(get_port_type(opt[1]) == PortType.BOOTSEL for opt in options) ): + if bootsel_permission_error: + _LOGGER.warning( + "An RP2040 device in BOOTSEL mode was detected but could " + "not be accessed due to USB permissions." + ) + if sys.platform.startswith("linux"): + _LOGGER.warning( + "You may need to add a udev rule for RP2040 devices. " + "See: https://github.com/raspberrypi/picotool" + "/blob/master/udev/60-picotool.rules" + ) if not options: raise EsphomeError( f"No RP2040 device found. {_RP2040_BOOTSEL_INSTRUCTIONS}" @@ -824,7 +839,8 @@ def upload_using_picotool(config: ConfigType) -> int: if sys.platform.startswith("linux"): msg += ( " You may need to add udev rules for RP2040 devices." - " See: https://github.com/raspberrypi/picotool#linux-permissions" + " See: https://github.com/raspberrypi/picotool" + "/blob/master/udev/60-picotool.rules" ) _LOGGER.error(msg) else: diff --git a/esphome/util.py b/esphome/util.py index d0fa4300a9..7c4166c48b 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -1,5 +1,6 @@ import collections from collections.abc import Callable +from dataclasses import dataclass import io import logging from pathlib import Path @@ -376,11 +377,19 @@ def get_picotool_path(cc_path: str) -> Path | None: return None -def detect_rp2040_bootsel(picotool_path: str | Path) -> int: +@dataclass +class BootselResult: + """Result of RP2040 BOOTSEL detection.""" + + device_count: int + permission_error: bool = False + + +def detect_rp2040_bootsel(picotool_path: str | Path) -> BootselResult: """Detect RP2040/RP2350 devices in BOOTSEL mode using picotool. - Returns the number of devices found (by counting 'type:' lines in output), - matching PlatformIO's detection approach. + Returns a BootselResult with the number of devices found (by counting + 'type:' lines in output), and whether a permission error was detected. """ try: result = subprocess.run( @@ -389,9 +398,17 @@ def detect_rp2040_bootsel(picotool_path: str | Path) -> int: timeout=10, check=False, ) - return result.stdout.count(b"type:") + device_count = result.stdout.count(b"type:") + if device_count > 0: + return BootselResult(device_count) + # Check stderr for permission issues — picotool can see the device + # on the USB bus but can't connect without proper permissions + combined = result.stderr + result.stdout + if b"unable to connect" in combined or b"LIBUSB_ERROR_ACCESS" in combined: + return BootselResult(0, permission_error=True) + return BootselResult(0) except (OSError, subprocess.TimeoutExpired): - return 0 + return BootselResult(0) def get_esp32_arduino_flash_error_help() -> str | None: diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 817a18e2b9..b6f1a28086 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -73,6 +73,7 @@ from esphome.const import ( PLATFORM_RP2040, ) from esphome.core import CORE, EsphomeError +from esphome.util import BootselResult def strip_ansi_codes(text: str) -> str: @@ -872,7 +873,7 @@ def test_choose_upload_log_host_no_defaults_with_rp2040_bootsel( patch( "esphome.__main__._find_picotool", return_value=Path("/usr/bin/picotool") ), - patch("esphome.__main__.detect_rp2040_bootsel", return_value=1), + patch("esphome.__main__.detect_rp2040_bootsel", return_value=BootselResult(1)), ): result = choose_upload_log_host( default=None, @@ -895,7 +896,7 @@ def test_choose_upload_log_host_rp2040_no_device_shows_bootsel_help() -> None: patch( "esphome.__main__._find_picotool", return_value=Path("/usr/bin/picotool") ), - patch("esphome.__main__.detect_rp2040_bootsel", return_value=0), + patch("esphome.__main__.detect_rp2040_bootsel", return_value=BootselResult(0)), pytest.raises(EsphomeError, match="BOOTSEL"), ): choose_upload_log_host( @@ -920,7 +921,7 @@ def test_choose_upload_log_host_rp2040_bootsel_tip_with_ota( patch( "esphome.__main__._find_picotool", return_value=Path("/usr/bin/picotool") ), - patch("esphome.__main__.detect_rp2040_bootsel", return_value=0), + patch("esphome.__main__.detect_rp2040_bootsel", return_value=BootselResult(0)), patch( "esphome.__main__.choose_prompt", return_value="192.168.1.100", @@ -949,7 +950,7 @@ def test_choose_upload_log_host_rp2040_bootsel_tip_with_serial_ports( "esphome.__main__._find_picotool", return_value=Path("/usr/bin/picotool"), ), - patch("esphome.__main__.detect_rp2040_bootsel", return_value=0), + patch("esphome.__main__.detect_rp2040_bootsel", return_value=BootselResult(0)), caplog.at_level(logging.INFO, logger="esphome.__main__"), ): choose_upload_log_host( @@ -960,6 +961,69 @@ def test_choose_upload_log_host_rp2040_bootsel_tip_with_serial_ports( assert "BOOTSEL" in caplog.text +@pytest.mark.usefixtures("mock_no_serial_ports") +def test_choose_upload_log_host_rp2040_permission_error_no_options( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test permission warning shown when BOOTSEL device found but not accessible.""" + setup_core(platform=PLATFORM_RP2040) + + with ( + patch( + "esphome.__main__._find_picotool", return_value=Path("/usr/bin/picotool") + ), + patch( + "esphome.__main__.detect_rp2040_bootsel", + return_value=BootselResult(0, permission_error=True), + ), + patch("esphome.__main__.sys.platform", "linux"), + pytest.raises(EsphomeError, match="BOOTSEL"), + caplog.at_level(logging.WARNING, logger="esphome.__main__"), + ): + choose_upload_log_host( + default=None, + check_default=None, + purpose=Purpose.UPLOADING, + ) + + assert "USB permissions" in caplog.text + assert "udev" in caplog.text + + +@pytest.mark.usefixtures("mock_no_serial_ports") +def test_choose_upload_log_host_rp2040_permission_error_with_ota( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test permission warning shown with OTA fallback available.""" + setup_core( + platform=PLATFORM_RP2040, + config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, + address="192.168.1.100", + ) + + with ( + patch( + "esphome.__main__._find_picotool", return_value=Path("/usr/bin/picotool") + ), + patch( + "esphome.__main__.detect_rp2040_bootsel", + return_value=BootselResult(0, permission_error=True), + ), + patch( + "esphome.__main__.choose_prompt", + return_value="192.168.1.100", + ), + caplog.at_level(logging.WARNING, logger="esphome.__main__"), + ): + choose_upload_log_host( + default=None, + check_default=None, + purpose=Purpose.UPLOADING, + ) + + assert "USB permissions" in caplog.text + + def test_choose_upload_log_host_no_bootsel_for_non_rp2040( mock_no_serial_ports: Mock, ) -> None: @@ -997,7 +1061,7 @@ def test_choose_upload_log_host_rp2040_serial_and_bootsel( patch( "esphome.__main__._find_picotool", return_value=Path("/usr/bin/picotool") ), - patch("esphome.__main__.detect_rp2040_bootsel", return_value=1), + patch("esphome.__main__.detect_rp2040_bootsel", return_value=BootselResult(1)), ): choose_upload_log_host( default=None, diff --git a/tests/unit_tests/test_util.py b/tests/unit_tests/test_util.py index 73fd6b34e2..ca3fd9b78a 100644 --- a/tests/unit_tests/test_util.py +++ b/tests/unit_tests/test_util.py @@ -461,8 +461,9 @@ def test_detect_rp2040_bootsel_found() -> None: 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 + result = util.detect_rp2040_bootsel("/usr/bin/picotool") + assert result.device_count == 1 + assert result.permission_error is False def test_detect_rp2040_bootsel_multiple() -> None: @@ -470,8 +471,9 @@ def test_detect_rp2040_bootsel_multiple() -> None: 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 + result = util.detect_rp2040_bootsel("/usr/bin/picotool") + assert result.device_count == 2 + assert result.permission_error is False def test_detect_rp2040_bootsel_none() -> None: @@ -480,16 +482,47 @@ def test_detect_rp2040_bootsel_none() -> None: mock_result.stdout = ( b"No accessible RP2040/RP2350 devices in BOOTSEL mode were found.\n" ) + mock_result.stderr = b"" with patch("esphome.util.subprocess.run", return_value=mock_result): - count = util.detect_rp2040_bootsel("/usr/bin/picotool") - assert count == 0 + result = util.detect_rp2040_bootsel("/usr/bin/picotool") + assert result.device_count == 0 + assert result.permission_error is False + + +def test_detect_rp2040_bootsel_permission_error() -> None: + """Test BOOTSEL detection with device found but not accessible.""" + mock_result = MagicMock() + mock_result.stdout = ( + b"No accessible RP-series devices in BOOTSEL mode were found.\n" + ) + mock_result.stderr = ( + b"RP2040 device at bus 5, address 24 appears to be in BOOTSEL mode, " + b"but picotool was unable to connect. " + b"Maybe try 'sudo' or check your permissions.\n" + ) + with patch("esphome.util.subprocess.run", return_value=mock_result): + result = util.detect_rp2040_bootsel("/usr/bin/picotool") + assert result.device_count == 0 + assert result.permission_error is True + + +def test_detect_rp2040_bootsel_libusb_access_error() -> None: + """Test BOOTSEL detection with LIBUSB_ERROR_ACCESS.""" + mock_result = MagicMock() + mock_result.stdout = b"" + mock_result.stderr = b"LIBUSB_ERROR_ACCESS\n" + with patch("esphome.util.subprocess.run", return_value=mock_result): + result = util.detect_rp2040_bootsel("/usr/bin/picotool") + assert result.device_count == 0 + assert result.permission_error is True 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 + result = util.detect_rp2040_bootsel("/usr/bin/picotool") + assert result.device_count == 0 + assert result.permission_error is False def test_detect_rp2040_bootsel_timeout() -> None: @@ -498,5 +531,6 @@ def test_detect_rp2040_bootsel_timeout() -> None: "esphome.util.subprocess.run", side_effect=subprocess.TimeoutExpired("picotool", 10), ): - count = util.detect_rp2040_bootsel("/usr/bin/picotool") - assert count == 0 + result = util.detect_rp2040_bootsel("/usr/bin/picotool") + assert result.device_count == 0 + assert result.permission_error is False