Skip to content

Commit abec8b4

Browse files
committed
feat(arduino): Add support for fast flashing with esptool
1 parent 70f4e74 commit abec8b4

12 files changed

Lines changed: 452 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: 95 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,78 @@ 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('fast-flash: reflashing with references for %d/%d binaries',
107+
sum(1 for d in diff_args if d != 'skip'), len(diff_args))
108+
else:
109+
diff_args = []
110+
logging.info('fast-flash: no references found, performing full flash')
111+
else:
112+
logging.info('fast-flash: disabled, performing full flash')
113+
114+
diff_with = ['--diff-with', *diff_args] if diff_args else []
115+
51116
try:
52117
esptool.main(
53118
[
54119
'--chip',
55120
self.app.target,
56121
'write-flash',
57-
'0x0', # Merged binary is flashed at offset 0
58-
self.app.binary_file,
122+
*addr_file_pairs,
59123
*flash_settings,
124+
*diff_with,
60125
],
61126
esp=self.esp,
62127
)
63128
except Exception:
64129
raise
130+
else:
131+
if self.fast_flash:
132+
for _, binary in self.app.flash_files:
133+
ref = self._ref_path(binary)
134+
try:
135+
if Path(binary).exists():
136+
shutil.copy2(binary, ref)
137+
except OSError as e:
138+
logging.warning(
139+
'fast-flash: could not save reference for %s (%s)',
140+
Path(binary).name,
141+
e,
142+
)
143+
144+
def erase_flash(self, force: bool = False) -> None:
145+
"""
146+
Erase the complete flash and invalidate all fast-flash reference binaries.
147+
"""
148+
super().erase_flash(force=force)
149+
if self.fast_flash:
150+
for ref in self._ref_binaries:
151+
if ref.exists():
152+
try:
153+
ref.unlink()
154+
logging.debug('fast-flash: removed reference %s after erase', ref.name)
155+
except OSError as e:
156+
logging.warning('fast-flash: could not remove reference %s (%s)', ref.name, e)

0 commit comments

Comments
 (0)