make linux error reporting more helpful

This commit is contained in:
J. Nick Koston
2026-03-09 17:45:03 -10:00
parent 399d695a39
commit 4b83de10e5
4 changed files with 154 additions and 23 deletions
+19 -3
View File
@@ -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:
+22 -5
View File
@@ -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:
+69 -5
View File
@@ -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,
+44 -10
View File
@@ -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