Skip to content

Commit 89aefc6

Browse files
authored
Merge pull request #41 from Josverl/copilot/fix-32
Implement mpremote rm -r :/ fallback for UF2 erase on non-rp2 ports (execute after firmware flash)
2 parents c78eeb0 + cfb03d4 commit 89aefc6

5 files changed

Lines changed: 110 additions & 13 deletions

File tree

mpflash/flash/uf2/__init__.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,17 @@
44

55
import shutil
66
import sys
7-
from pathlib import Path
87
import time
8+
from pathlib import Path
99
from typing import Optional
1010

1111
import tenacity
1212
from loguru import logger as log
13-
1413
from tenacity import stop_after_attempt, wait_fixed
1514

15+
from mpflash.common import PORT_FWTYPES
1616
from mpflash.mpremoteboard import MPRemoteBoard
1717

18-
from mpflash.common import PORT_FWTYPES
1918
from .boardid import get_board_id
2019
from .linux import dismount_uf2_linux, wait_for_UF2_linux
2120
from .macos import wait_for_UF2_macos
@@ -38,6 +37,10 @@ def flash_uf2(mcu: MPRemoteBoard, fw_file: Path, erase: bool) -> Optional[MPRemo
3837
if ".uf2" not in PORT_FWTYPES[mcu.port]:
3938
log.error(f"UF2 not supported on {mcu.board} on {mcu.serialport}")
4039
return None
40+
41+
# For non-rp2 ports, remember if we need to erase filesystem after flashing
42+
erase_filesystem_after_flash = erase and mcu.port != "rp2"
43+
4144
if erase:
4245
if mcu.port == "rp2":
4346
rp2_erase =Path(__file__).parent.joinpath("../../vendor/pico-universal-flash-nuke/universal_flash_nuke.uf2").resolve()
@@ -52,8 +55,6 @@ def flash_uf2(mcu: MPRemoteBoard, fw_file: Path, erase: bool) -> Optional[MPRemo
5255
dismount_uf2_linux()
5356
# allow for MCU restart after erase
5457
time.sleep(0.5)
55-
else:
56-
log.warning(f"Erase not (yet) supported on .UF2, for port {mcu.port}, skipping erase.")
5758

5859
destination = waitfor_uf2(board_id=mcu.port.upper())
5960

@@ -75,6 +76,16 @@ def flash_uf2(mcu: MPRemoteBoard, fw_file: Path, erase: bool) -> Optional[MPRemo
7576
dismount_uf2_linux()
7677

7778
mcu.wait_for_restart()
79+
80+
# For non-rp2 UF2 ports (like SAMD), erase filesystem after flash and restart
81+
if erase_filesystem_after_flash:
82+
# allow for MCU restart after erase
83+
time.sleep(0.5)
84+
log.info(f"Erasing {mcu.port} filesystem using mpremote rm -r :/")
85+
try:
86+
rc, result = mcu.run_command(["rm", "-r", ":/"], timeout=30, resume=True)
87+
except Exception as e:
88+
log.warning(f"Failed to erase filesystem on {mcu.port}: {e}")
7889
return mcu
7990

8091

mpflash/mpremoteboard/runner.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from dataclasses import dataclass
77
from threading import Timer
88
from typing import List, Optional, Tuple
9+
from unittest.mock import DEFAULT
910

1011
from loguru import logger as log
1112

@@ -31,6 +32,13 @@ class LogTags:
3132
"rst:0x10 (RTCWDT_RTC_RESET)",
3233
]
3334

35+
DEFAULT_ERROR_TAGS = ["Traceback ", "Error: ", "Exception: ", "ERROR :", "CRIT :"]
36+
DEFAULT_WARNING_TAGS = ["WARN :", "TRACE :"]
37+
DEFAULT_SUCCESS_TAGS = ["Done", "File saved", "File removed", "File renamed"]
38+
DEFAULT_IGNORE_TAGS = [
39+
' File "<stdin>",',
40+
"mpremote: rm -r: cannot remove :/ Operation not permitted",
41+
]
3442

3543
def run(
3644
cmd: List[str],
@@ -70,13 +78,13 @@ def run(
7078
if not reset_tags:
7179
reset_tags = DEFAULT_RESET_TAGS
7280
if not error_tags:
73-
error_tags = ["Traceback ", "Error: ", "Exception: ", "ERROR :", "CRIT :"]
81+
error_tags = DEFAULT_ERROR_TAGS
7482
if not warning_tags:
75-
warning_tags = ["WARN :", "TRACE :"]
83+
warning_tags = DEFAULT_WARNING_TAGS
7684
if not success_tags:
77-
success_tags = []
85+
success_tags = DEFAULT_SUCCESS_TAGS
7886
if not ignore_tags:
79-
ignore_tags = [' File "<stdin>",']
87+
ignore_tags = DEFAULT_IGNORE_TAGS
8088

8189
replace_tags = ["\x1b[1A"]
8290

@@ -132,6 +140,8 @@ def timed_out():
132140
log.info(line)
133141
if proc.stderr and log_errors:
134142
for line in proc.stderr:
143+
if any(tag in line for tag in ignore_tags):
144+
continue
135145
log.warning(line)
136146
except UnicodeDecodeError as e:
137147
log.error(f"Failed to decode output: {e}")

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,3 +256,8 @@ line-ending = "auto"
256256
[tool.ruff.lint]
257257
exclude = [".*", "__*", "dist", "repos", "mpflash\\vendor"]
258258
ignore = ["F821"]
259+
260+
[dependency-groups]
261+
dev = [
262+
"pytest>=8.4.1",
263+
]

tests/flash/test_flash_uf2_A.py

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
import pytest
2-
from unittest import mock
31
from pathlib import Path
4-
from mpflash.mpremoteboard import MPRemoteBoard
2+
from unittest import mock
3+
4+
import pytest
5+
56
from mpflash.flash.uf2 import flash_uf2
7+
from mpflash.mpremoteboard import MPRemoteBoard
68

79

810
@pytest.fixture
@@ -11,6 +13,8 @@ def mock_mcu():
1113
mcu.port = "rp2"
1214
mcu.board = "test_board"
1315
mcu.serialport = "COM3"
16+
mcu.run_command = mock.Mock() # Add run_command method
17+
mcu.wait_for_restart = mock.Mock() # Add wait_for_restart method
1418
return mcu
1519

1620

@@ -28,7 +32,10 @@ def mock_erase_file():
2832
def mock_destination():
2933
destination = mock.Mock(spec=Path)
3034
destination.exists.return_value = True
31-
# (destination / "INFO_UF2.TXT").exists.return_value = True
35+
# Mock the path operation (destination / "INFO_UF2.TXT").exists()
36+
info_file = mock.Mock()
37+
info_file.exists.return_value = True
38+
destination.__truediv__ = mock.Mock(return_value=info_file)
3239
return destination
3340

3441

@@ -64,9 +71,64 @@ def test_flash_uf2_board_not_in_bootloader(mock_mcu, mock_fw_file):
6471
# assert result == mock_mcu
6572

6673

74+
def test_flash_uf2_erase_fallback_samd(mock_mcu, mock_fw_file, mock_destination):
75+
"""Test that SAMD port uses mpremote rm -r :/ for erase after flashing"""
76+
mock_mcu.port = "samd"
77+
mock_mcu.run_command.return_value = (0, [""]) # Successful erase
78+
79+
with (
80+
mock.patch("mpflash.flash.uf2.waitfor_uf2", return_value=mock_destination),
81+
mock.patch("mpflash.flash.uf2.copy_firmware_to_uf2"),
82+
mock.patch("mpflash.flash.uf2.dismount_uf2_linux"),
83+
mock.patch("mpflash.flash.uf2.get_board_id", return_value="test_board_id"),
84+
):
85+
result = flash_uf2(mock_mcu, mock_fw_file, erase=True)
86+
87+
# Verify that run_command was called with rm -r :/ after flashing
88+
mock_mcu.run_command.assert_called_with(["rm", "-r", ":/"], timeout=30, resume=True)
89+
assert result == mock_mcu
90+
91+
92+
def test_flash_uf2_erase_fallback_failed(mock_mcu, mock_fw_file, mock_destination):
93+
"""Test that failed mpremote erase is logged but doesn't stop flashing"""
94+
mock_mcu.port = "samd"
95+
mock_mcu.run_command.return_value = (1, ["Error message"]) # Failed erase
96+
97+
with (
98+
mock.patch("mpflash.flash.uf2.waitfor_uf2", return_value=mock_destination),
99+
mock.patch("mpflash.flash.uf2.copy_firmware_to_uf2"),
100+
mock.patch("mpflash.flash.uf2.dismount_uf2_linux"),
101+
mock.patch("mpflash.flash.uf2.get_board_id", return_value="test_board_id"),
102+
):
103+
result = flash_uf2(mock_mcu, mock_fw_file, erase=True)
104+
105+
# Verify that run_command was called with rm -r :/ after flashing
106+
mock_mcu.run_command.assert_called_with(["rm", "-r", ":/"], timeout=30, resume=True)
107+
# Should still complete flashing even if erase failed
108+
assert result == mock_mcu
109+
110+
67111
def test_flash_uf2_erase_not_supported(mock_mcu, mock_fw_file):
68112
mock_mcu.port = "unsupported_erase_port"
69113
with mock.patch("mpflash.flash.uf2.waitfor_uf2", return_value=None):
70114
with pytest.raises(KeyError):
71115
result = flash_uf2(mock_mcu, mock_fw_file, erase=True)
72116
assert result is None
117+
118+
119+
def test_flash_uf2_no_erase_command_when_erase_false(mock_mcu, mock_fw_file, mock_destination):
120+
"""Test that mpremote rm command is not called when erase=False"""
121+
mock_mcu.port = "samd"
122+
mock_mcu.run_command = mock.Mock()
123+
124+
with (
125+
mock.patch("mpflash.flash.uf2.waitfor_uf2", return_value=mock_destination),
126+
mock.patch("mpflash.flash.uf2.copy_firmware_to_uf2"),
127+
mock.patch("mpflash.flash.uf2.dismount_uf2_linux"),
128+
mock.patch("mpflash.flash.uf2.get_board_id", return_value="test_board_id"),
129+
):
130+
result = flash_uf2(mock_mcu, mock_fw_file, erase=False)
131+
132+
# Verify that run_command was NOT called
133+
mock_mcu.run_command.assert_not_called()
134+
assert result == mock_mcu

uv.lock

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)