Skip to content

Commit b1ea228

Browse files
authored
Merge pull request #422 from lucasssvaz/feat/arduino_fast_flash
feat(arduino): Add support for fast flashing with esptool
2 parents 70f4e74 + 7ef3196 commit b1ea228

12 files changed

Lines changed: 277 additions & 16 deletions

File tree

pytest-embedded-arduino/pytest_embedded_arduino/app.py

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import json
22
import logging
33
import os
4+
import re
45

56
from pytest_embedded.app import App
67

8+
_HEX_ADDR_RE = re.compile(r'^0x[0-9a-fA-F]+$')
9+
710

811
class ArduinoApp(App):
912
"""
@@ -14,6 +17,8 @@ class ArduinoApp(App):
1417
fqbn (str): Fully Qualified Board Name.
1518
target (str) : ESPxx chip.
1619
flash_settings (dict[str, str]): Flash settings for the target.
20+
flash_files (list[tuple[str, str]]): ``(address, filepath)`` pairs parsed
21+
from ``flash_args``. Each filepath is absolute.
1722
binary_file (str): Merged binary file path.
1823
elf_file (str): ELF file path.
1924
"""
@@ -29,7 +34,7 @@ def __init__(
2934
self.sketch = self._get_sketch_name(self.binary_path)
3035
self.fqbn = self._get_fqbn(self.binary_path)
3136
self.target = self.fqbn.split(':')[2]
32-
self.flash_settings = self._get_flash_settings()
37+
self.flash_settings, self.flash_files = self._parse_flash_args()
3338
self.binary_file = os.path.realpath(os.path.join(self.binary_path, self.sketch + '.ino.merged.bin'))
3439
self.elf_file = os.path.realpath(os.path.join(self.binary_path, self.sketch + '.ino.elf'))
3540

@@ -38,6 +43,7 @@ def __init__(
3843
logging.debug(f'FQBN: {self.fqbn}')
3944
logging.debug(f'Target: {self.target}')
4045
logging.debug(f'Flash settings: {self.flash_settings}')
46+
logging.debug(f'Flash files: {self.flash_files}')
4147
logging.debug(f'Binary file: {self.binary_file}')
4248
logging.debug(f'ELF file: {self.elf_file}')
4349

@@ -90,18 +96,45 @@ def _get_fqbn(self, build_path: str) -> str:
9096
fqbn = options['fqbn']
9197
return fqbn
9298

93-
def _get_flash_settings(self) -> dict[str, str]:
94-
"""Get flash settings from flash_args file."""
95-
flash_args_file = os.path.realpath(os.path.join(self.binary_path, 'flash_args'))
96-
with open(flash_args_file) as f:
97-
flash_args = f.readline().split(' ')
99+
def _parse_flash_args(self) -> tuple[dict[str, str], list[tuple[str, str]]]:
100+
"""Parse the ``flash_args`` file produced by the Arduino build system.
98101
99-
flash_settings = {}
100-
for i, arg in enumerate(flash_args):
101-
if arg.startswith('--'):
102-
flash_settings[arg[2:].strip()] = flash_args[i + 1].strip()
102+
Returns ``(flash_settings, flash_files)`` where *flash_settings* is a
103+
dict of ``--flag value`` pairs (e.g. ``{'flash-mode': 'dio'}``), and
104+
*flash_files* is a list of ``(hex_address, absolute_path)`` pairs for
105+
each binary that should be flashed.
103106
104-
if flash_settings == {}:
107+
Format of ``flash_args``::
108+
109+
--flash-mode dio --flash-freq 80m --flash-size 4MB
110+
0x0 sketch.ino.bootloader.bin
111+
0x8000 sketch.ino.partitions.bin
112+
0xe000 boot_app0.bin
113+
0x10000 sketch.ino.bin
114+
"""
115+
flash_args_file = os.path.realpath(os.path.join(self.binary_path, 'flash_args'))
116+
with open(flash_args_file) as f:
117+
lines = f.read().splitlines()
118+
119+
flash_settings: dict[str, str] = {}
120+
flash_files: list[tuple[str, str]] = []
121+
122+
for line in lines:
123+
tokens = line.split()
124+
if not tokens:
125+
continue
126+
127+
if tokens[0].startswith('--'):
128+
for i, tok in enumerate(tokens):
129+
if tok.startswith('--') and i + 1 < len(tokens):
130+
flash_settings[tok[2:].strip()] = tokens[i + 1].strip()
131+
elif _HEX_ADDR_RE.match(tokens[0]) and len(tokens) >= 2:
132+
addr = tokens[0]
133+
name = tokens[1]
134+
path = os.path.realpath(os.path.join(self.binary_path, name))
135+
flash_files.append((addr, path))
136+
137+
if not flash_settings:
105138
raise ValueError(f'Flash settings not found in {flash_args_file}')
106139

107-
return flash_settings
140+
return flash_settings, flash_files

pytest-embedded-arduino/pytest_embedded_arduino/serial.py

Lines changed: 101 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,20 @@
11
import logging
2+
import shutil
3+
from pathlib import Path
24

35
import esptool
46
from pytest_embedded_serial_esp.serial import EspSerial
57

68
from .app import ArduinoApp
79

10+
_ALWAYS_FLASH = {'boot_app0.bin'}
11+
"""Binaries that must always be fully flashed (never receive a --diff-with ref).
12+
13+
boot_app0.bin selects the active OTA partition slot. If the user performed an
14+
OTA update since the last flash, the on-chip copy will differ from the reference
15+
even though our local copy has not changed.
16+
"""
17+
818

919
class ArduinoSerial(EspSerial):
1020
"""
@@ -19,14 +29,26 @@ def __init__(
1929
self,
2030
app: ArduinoApp,
2131
target: str | None = None,
32+
fast_flash: bool = True,
2233
**kwargs,
2334
) -> None:
2435
self.app = app
36+
self.fast_flash = fast_flash
2537
super().__init__(
2638
target=target or self.app.target,
2739
**kwargs,
2840
)
2941

42+
def _ref_path(self, binary: str) -> Path:
43+
"""Return the ``*_flashed.bin`` reference path for *binary* inside the build dir."""
44+
p = Path(binary)
45+
return Path(self.app.binary_path) / (p.stem + '_flashed' + p.suffix)
46+
47+
@property
48+
def _ref_binaries(self) -> list[Path]:
49+
"""All potential reference files for the current flash_files list."""
50+
return [self._ref_path(path) for _, path in self.app.flash_files]
51+
3052
def _start(self):
3153
if self.skip_autoflash:
3254
logging.info('Skipping auto flash...')
@@ -37,7 +59,16 @@ def _start(self):
3759
@EspSerial.use_esptool()
3860
def flash(self) -> None:
3961
"""
40-
Flash the binary files to the board.
62+
Flash individual binary files to the board.
63+
64+
Uses esptool's ``--diff-with`` for fast reflashing when reference binaries
65+
from the previous successful flash are available, writing only changed
66+
4 KB sectors. References are saved after each successful flash and
67+
invalidated by :meth:`erase_flash`.
68+
69+
Unlike the merged-binary approach, individual binaries do not overlap
70+
with writable flash regions (NVS, OTA data, etc.), so the post-flash
71+
MD5 verification succeeds and ``--diff-with`` works correctly.
4172
"""
4273

4374
flash_settings = []
@@ -48,17 +79,84 @@ def flash(self) -> None:
4879
if self.esp_flash_force:
4980
flash_settings.append('--force')
5081

82+
addr_file_pairs: list[str] = []
83+
diff_args: list[str] = []
84+
have_any_ref = False
85+
86+
for addr, binary in self.app.flash_files:
87+
addr_file_pairs.extend([addr, binary])
88+
89+
if not self.fast_flash:
90+
continue
91+
92+
name = Path(binary).name
93+
if name in _ALWAYS_FLASH:
94+
diff_args.append('skip')
95+
continue
96+
97+
ref = self._ref_path(binary)
98+
if ref.exists():
99+
diff_args.append(str(ref))
100+
have_any_ref = True
101+
else:
102+
diff_args.append('skip')
103+
104+
if self.fast_flash:
105+
if have_any_ref:
106+
logging.info(
107+
'fast-flash: reflashing with references for %d/%d binaries',
108+
sum(1 for d in diff_args if d != 'skip'),
109+
len(diff_args),
110+
)
111+
else:
112+
diff_args = []
113+
logging.info('fast-flash: no references found, performing full flash')
114+
else:
115+
logging.info('fast-flash: disabled, performing full flash')
116+
117+
diff_with = ['--diff-with', *diff_args] if diff_args else []
118+
51119
try:
52120
esptool.main(
53121
[
54122
'--chip',
55123
self.app.target,
56124
'write-flash',
57-
'0x0', # Merged binary is flashed at offset 0
58-
self.app.binary_file,
125+
*addr_file_pairs,
59126
*flash_settings,
127+
*diff_with,
60128
],
61129
esp=self.esp,
62130
)
63131
except Exception:
64132
raise
133+
else:
134+
# Save copies of each binary as *_flashed.bin references so the
135+
# next invocation of flash() can pass them to --diff-with and only
136+
# write the 4 KB sectors that actually changed.
137+
if self.fast_flash:
138+
for _, binary in self.app.flash_files:
139+
ref = self._ref_path(binary)
140+
try:
141+
if Path(binary).exists():
142+
shutil.copy2(binary, ref)
143+
except OSError as e:
144+
logging.warning(
145+
'fast-flash: could not save reference for %s (%s)',
146+
Path(binary).name,
147+
e,
148+
)
149+
150+
def erase_flash(self, force: bool = False) -> None:
151+
"""
152+
Erase the complete flash and invalidate all fast-flash reference binaries.
153+
"""
154+
super().erase_flash(force=force)
155+
if self.fast_flash:
156+
for ref in self._ref_binaries:
157+
if ref.exists():
158+
try:
159+
ref.unlink()
160+
logging.debug('fast-flash: removed reference %s after erase', ref.name)
161+
except OSError as e:
162+
logging.warning('fast-flash: could not remove reference %s (%s)', ref.name, e)

pytest-embedded-arduino/tests/test_arduino.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,110 @@ def test_arduino_app(app, dut):
4343
)
4444

4545
result.assert_outcomes(passed=1)
46+
47+
48+
def test_fast_flash_saves_refs(testdir):
49+
"""After the first flash, _flashed.bin references must be created."""
50+
testdir.makepyfile(r"""
51+
from pathlib import Path
52+
53+
def test_refs_created(dut):
54+
dut.expect('Hello Arduino!')
55+
build = Path(dut.serial.app.binary_path)
56+
for _, binary in dut.serial.app.flash_files:
57+
p = Path(binary)
58+
ref = build / (p.stem + '_flashed' + p.suffix)
59+
assert ref.exists(), f'{ref.name} should exist after first flash'
60+
""")
61+
62+
result = testdir.runpytest(
63+
'-s',
64+
'--embedded-services',
65+
'arduino,esp',
66+
'--build-dir',
67+
os.path.join(testdir.tmpdir, 'hello_world_arduino', 'build'),
68+
)
69+
70+
result.assert_outcomes(passed=1)
71+
72+
73+
def test_fast_flash_reflash(testdir):
74+
"""A second flash must succeed using --diff-with fast reflashing."""
75+
testdir.makepyfile(r"""
76+
def test_reflash(dut):
77+
dut.expect('Hello Arduino!')
78+
dut.serial.flash()
79+
dut.expect('Hello Arduino!')
80+
""")
81+
82+
result = testdir.runpytest(
83+
'-s',
84+
'--embedded-services',
85+
'arduino,esp',
86+
'--build-dir',
87+
os.path.join(testdir.tmpdir, 'hello_world_arduino', 'build'),
88+
)
89+
90+
result.assert_outcomes(passed=1)
91+
92+
93+
def test_erase_flash_removes_refs(testdir):
94+
"""erase_flash must delete all _flashed.bin references."""
95+
testdir.makepyfile(r"""
96+
from pathlib import Path
97+
98+
def test_erase_refs(dut):
99+
dut.expect('Hello Arduino!')
100+
build = Path(dut.serial.app.binary_path)
101+
refs = [
102+
build / (Path(b).stem + '_flashed' + Path(b).suffix)
103+
for _, b in dut.serial.app.flash_files
104+
]
105+
assert any(r.exists() for r in refs), 'refs should exist after first flash'
106+
107+
dut.serial.erase_flash()
108+
for ref in refs:
109+
assert not ref.exists(), f'{ref.name} should be removed after erase'
110+
111+
dut.serial.flash()
112+
dut.expect('Hello Arduino!')
113+
""")
114+
115+
result = testdir.runpytest(
116+
'-s',
117+
'--embedded-services',
118+
'arduino,esp',
119+
'--build-dir',
120+
os.path.join(testdir.tmpdir, 'hello_world_arduino', 'build'),
121+
)
122+
123+
result.assert_outcomes(passed=1)
124+
125+
126+
def test_no_fast_flash_skips_refs(testdir):
127+
"""--no-fast-flash must not create reference binaries."""
128+
testdir.makepyfile(r"""
129+
from pathlib import Path
130+
131+
def test_no_refs(dut):
132+
dut.expect('Hello Arduino!')
133+
build = Path(dut.serial.app.binary_path)
134+
refs = [
135+
build / (Path(b).stem + '_flashed' + Path(b).suffix)
136+
for _, b in dut.serial.app.flash_files
137+
]
138+
for ref in refs:
139+
assert not ref.exists(), f'{ref.name} should not exist with --no-fast-flash'
140+
""")
141+
142+
result = testdir.runpytest(
143+
'-s',
144+
'--embedded-services',
145+
'arduino,esp',
146+
'--build-dir',
147+
os.path.join(testdir.tmpdir, 'hello_world_arduino', 'build'),
148+
'--no-fast-flash',
149+
'y',
150+
)
151+
152+
result.assert_outcomes(passed=1)

pytest-embedded-serial-esp/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ requires-python = ">=3.10"
3030

3131
dependencies = [
3232
"pytest-embedded-serial~=2.8.0",
33-
"esptool>=5.1,<6",
33+
"esptool>=5.2,<6",
3434
]
3535

3636
[project.urls]

0 commit comments

Comments
 (0)