bossac-wrapper 0.1.0__py3-none-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bossac/__init__.py +919 -0
- bossac/py.typed +0 -0
- bossac_wrapper-0.1.0.data/scripts/bossac.exe +0 -0
- bossac_wrapper-0.1.0.dist-info/METADATA +125 -0
- bossac_wrapper-0.1.0.dist-info/RECORD +8 -0
- bossac_wrapper-0.1.0.dist-info/WHEEL +4 -0
- bossac_wrapper-0.1.0.dist-info/entry_points.txt +4 -0
- bossac_wrapper-0.1.0.dist-info/licenses/LICENSE +21 -0
bossac/__init__.py
ADDED
|
@@ -0,0 +1,919 @@
|
|
|
1
|
+
"""A Python wrapper for bossac."""
|
|
2
|
+
|
|
3
|
+
import errno
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Iterable, Iterator
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from functools import cache, partial
|
|
13
|
+
from importlib.metadata import PackageNotFoundError, files
|
|
14
|
+
from os import PathLike, strerror
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from subprocess import PIPE, CalledProcessError, Popen
|
|
17
|
+
from tempfile import TemporaryDirectory
|
|
18
|
+
from threading import Event, Thread
|
|
19
|
+
from typing import IO, NamedTuple, TypedDict
|
|
20
|
+
|
|
21
|
+
import serial
|
|
22
|
+
from serial.tools.list_ports import comports
|
|
23
|
+
from serial.tools.list_ports_common import ListPortInfo
|
|
24
|
+
|
|
25
|
+
log = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
__version__ = '0.1.0'
|
|
28
|
+
"""Version of bossac-wrapper."""
|
|
29
|
+
_BOSSAC_VERSION = '1.9.1'
|
|
30
|
+
"""Version of the bundled bossac binary."""
|
|
31
|
+
_BOSSAC_COMMIT = '4bd2e659ad7255592923c5544f683a0ab9e46774'
|
|
32
|
+
"""Commit of BOSSA's release tag that the bundled bossac binary is built from."""
|
|
33
|
+
_BOSSAC_NAME = 'bossac.exe' if sys.platform == 'win32' else 'bossac'
|
|
34
|
+
"""File name of the bossac binary."""
|
|
35
|
+
_VALID_FIRMWARE_EXT = ('.bin', '.hex')
|
|
36
|
+
"""File extensions of supported firmware files."""
|
|
37
|
+
_RE_PROGRESS = re.compile(r'^\[[= ]*\] \d+% \(\d+/\d+ pages\)$')
|
|
38
|
+
"""Matches bossac's progress bar."""
|
|
39
|
+
_RE_VERSION = re.compile(r'\bVersion (\S+)')
|
|
40
|
+
"""Matches the version in bossac's help text."""
|
|
41
|
+
_RE_NO_BOOTLOADER = re.compile(
|
|
42
|
+
r'^(No device found on |SAM-BA operation failed)', re.MULTILINE
|
|
43
|
+
)
|
|
44
|
+
"""Matches bossac's errors when no SAM-BA bootloader answers."""
|
|
45
|
+
_HEX_PAYLOAD_SIZES = {0x00: None, 0x01: 0, 0x02: 2, 0x03: 4, 0x04: 2, 0x05: 4}
|
|
46
|
+
"""Payload sizes of the Intel HEX record types, None for any size."""
|
|
47
|
+
_MAX_HEX_SPAN = 16 * 1024 * 1024
|
|
48
|
+
"""Largest address range of an Intel HEX file that is converted to a binary.
|
|
49
|
+
|
|
50
|
+
Guards against allocating a huge buffer for a sparse file, before the device's actual
|
|
51
|
+
flash size is known.
|
|
52
|
+
"""
|
|
53
|
+
_TOUCH_SETTLE = 2.0
|
|
54
|
+
"""Seconds after which a port that never disappeared is taken to be the bootloader's."""
|
|
55
|
+
_TOUCH_TIMEOUT = 10.0
|
|
56
|
+
"""Seconds to wait for the bootloader's port to appear after a 1200 baud touch."""
|
|
57
|
+
_TOUCH_POLL = 0.25
|
|
58
|
+
"""Seconds between checks for the bootloader's port after a 1200 baud touch."""
|
|
59
|
+
_BOSSAC_TIMEOUT = 30.0
|
|
60
|
+
"""Seconds without any output after which bossac is taken to be stuck and killed."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class BossacError(CalledProcessError):
|
|
64
|
+
"""Raised when bossac exits with an error."""
|
|
65
|
+
|
|
66
|
+
hint: str | None = None
|
|
67
|
+
"""A likely cause of the error, or :py:obj:`None` if there is none."""
|
|
68
|
+
|
|
69
|
+
def __str__(self) -> str:
|
|
70
|
+
"""Return bossac's error message or the exit status, followed by the hint."""
|
|
71
|
+
lines = (self.stderr or '').splitlines()
|
|
72
|
+
message = '\n'.join(line.strip() for line in lines).strip()
|
|
73
|
+
message = message or super().__str__()
|
|
74
|
+
return f'{message}\n{self.hint}' if self.hint else message
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class DeviceInfo(TypedDict):
|
|
78
|
+
"""A device's details as reported by bossac."""
|
|
79
|
+
|
|
80
|
+
device: str
|
|
81
|
+
"""Name of the device, e.g. ``'ATSAMD21x18'``."""
|
|
82
|
+
version: str
|
|
83
|
+
"""Version of the SAM-BA bootloader."""
|
|
84
|
+
address: int
|
|
85
|
+
"""Address of the start of the flash."""
|
|
86
|
+
pages: int
|
|
87
|
+
"""Number of flash pages."""
|
|
88
|
+
page_size: int
|
|
89
|
+
"""Size of a flash page in bytes."""
|
|
90
|
+
total_size: int
|
|
91
|
+
"""Size of the flash in bytes."""
|
|
92
|
+
planes: int
|
|
93
|
+
"""Number of flash planes."""
|
|
94
|
+
lock_regions: int
|
|
95
|
+
"""Number of lock regions."""
|
|
96
|
+
locked: list[int]
|
|
97
|
+
"""Indices of the locked regions."""
|
|
98
|
+
security: bool
|
|
99
|
+
"""Whether the flash security bit is set."""
|
|
100
|
+
boot_flash: bool | None
|
|
101
|
+
"""Whether the device boots from flash, or :py:obj:`None` if not supported."""
|
|
102
|
+
brownout_detection: bool | None
|
|
103
|
+
"""Whether brownout detection is on, or :py:obj:`None` if not supported."""
|
|
104
|
+
brownout_reset: bool | None
|
|
105
|
+
"""Whether brownout reset is on, or :py:obj:`None` if not supported."""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class _Firmware(NamedTuple):
|
|
109
|
+
"""A firmware file and its contents as a binary."""
|
|
110
|
+
|
|
111
|
+
path: Path
|
|
112
|
+
"""Path of the file."""
|
|
113
|
+
data: bytes
|
|
114
|
+
"""Contents as a binary."""
|
|
115
|
+
start: int | None
|
|
116
|
+
"""Start address of an Intel HEX file, None for a binary file."""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def upload(
|
|
120
|
+
filename: PathLike | str,
|
|
121
|
+
*,
|
|
122
|
+
port: str,
|
|
123
|
+
offset: int | None = None,
|
|
124
|
+
erase: bool = True,
|
|
125
|
+
verify: bool = True,
|
|
126
|
+
boot: bool = True,
|
|
127
|
+
reset: bool = True,
|
|
128
|
+
touch: bool = True,
|
|
129
|
+
usb: bool | None = None,
|
|
130
|
+
log_level: int = logging.INFO,
|
|
131
|
+
) -> None:
|
|
132
|
+
"""
|
|
133
|
+
Upload firmware to a device.
|
|
134
|
+
|
|
135
|
+
The firmware file is checked first. Then, unless ``touch`` is False, the device is
|
|
136
|
+
put into its SAM-BA bootloader with a 1200 baud touch. The flash is then erased,
|
|
137
|
+
written and verified, the device is set to boot from flash and finally reset to
|
|
138
|
+
start the firmware.
|
|
139
|
+
|
|
140
|
+
Parameters
|
|
141
|
+
----------
|
|
142
|
+
filename : PathLike | str
|
|
143
|
+
Path to the firmware file: a raw binary (``.bin``) or an Intel HEX file
|
|
144
|
+
(``.hex``).
|
|
145
|
+
port : str
|
|
146
|
+
Port of the device.
|
|
147
|
+
offset : int, optional
|
|
148
|
+
Flash offset at which to write a binary file - required for one. Must be aligned
|
|
149
|
+
to a flash page, and must not overlap the bootloader - see the notes below. For
|
|
150
|
+
an Intel HEX file, the offset is determined from the file's addresses and the
|
|
151
|
+
device's flash address - specifying it raises a :py:exc:`ValueError`.
|
|
152
|
+
erase : bool, default: True
|
|
153
|
+
Erase the flash before writing.
|
|
154
|
+
verify : bool, default: True
|
|
155
|
+
Verify the flash's contents after writing.
|
|
156
|
+
boot : bool, default: True
|
|
157
|
+
Set the device to boot from flash, if supported.
|
|
158
|
+
reset : bool, default: True
|
|
159
|
+
Reset the device after the upload, if supported.
|
|
160
|
+
touch : bool, default: True
|
|
161
|
+
Put the device into its bootloader with a 1200 baud touch first.
|
|
162
|
+
usb : bool, optional
|
|
163
|
+
Whether the port is a native USB port (True) or a USB-to-serial bridge (False),
|
|
164
|
+
e.g. the Arduino Due's programming port. Detected by bossac if not given.
|
|
165
|
+
log_level : int, default: :py:data:`logging.INFO`
|
|
166
|
+
Log level for bossac's status messages, :py:data:`logging.NOTSET` to disable.
|
|
167
|
+
Errors are always logged as warnings.
|
|
168
|
+
|
|
169
|
+
Raises
|
|
170
|
+
------
|
|
171
|
+
FileNotFoundError
|
|
172
|
+
If the firmware file does not exist, or if ``touch`` is True and the port can't
|
|
173
|
+
be found.
|
|
174
|
+
IsADirectoryError
|
|
175
|
+
If the firmware path is a directory.
|
|
176
|
+
ValueError
|
|
177
|
+
If the firmware file has an unsupported extension, can't be loaded or doesn't
|
|
178
|
+
fit into the flash, or if the offset is missing or invalid.
|
|
179
|
+
TimeoutError
|
|
180
|
+
If the bootloader's port doesn't appear after the 1200 baud touch, or if bossac
|
|
181
|
+
stops responding, e.g. because the device isn't in its bootloader.
|
|
182
|
+
BossacError
|
|
183
|
+
If bossac fails, e.g. if the verification fails.
|
|
184
|
+
|
|
185
|
+
Notes
|
|
186
|
+
-----
|
|
187
|
+
A binary file doesn't record where in the flash it belongs, so its offset has to be
|
|
188
|
+
given, matching the board's bootloader. Offsets are relative to the start of the
|
|
189
|
+
flash:
|
|
190
|
+
|
|
191
|
+
- Arduino Due and other SAM3/SAM4 boards: 0, as their bootloader is in ROM
|
|
192
|
+
- SAMD21 boards with an Arduino-style bootloader (Arduino Zero/MKR, SparkFun,
|
|
193
|
+
Adafruit M0): ``0x2000``, after the 8 KiB bootloader
|
|
194
|
+
- SAMD51 boards with a UF2 bootloader (Adafruit M4): ``0x4000``, after the
|
|
195
|
+
16 KiB bootloader
|
|
196
|
+
|
|
197
|
+
An offset of 0 on a board whose bootloader is in flash overwrites the bootloader.
|
|
198
|
+
Intel HEX files carry absolute addresses, so they are flashed at the right offset
|
|
199
|
+
on any board - prefer them where a build produces them.
|
|
200
|
+
|
|
201
|
+
Examples
|
|
202
|
+
--------
|
|
203
|
+
Upload an Intel HEX file, which is flashed at the offset of its addresses:
|
|
204
|
+
|
|
205
|
+
>>> bossac.upload('blink.hex', port='/dev/ttyACM0')
|
|
206
|
+
|
|
207
|
+
Upload a binary file to a SAMD21 board, after its 8 KiB bootloader:
|
|
208
|
+
|
|
209
|
+
>>> bossac.upload('blink.bin', port='/dev/ttyACM0', offset=0x2000)
|
|
210
|
+
|
|
211
|
+
Upload a binary file via the programming port of an Arduino Due:
|
|
212
|
+
|
|
213
|
+
>>> bossac.upload('blink.bin', port='/dev/ttyACM0', offset=0, usb=False)
|
|
214
|
+
"""
|
|
215
|
+
firmware = _load_firmware(filename, offset) # before the touch can harm the device
|
|
216
|
+
if touch:
|
|
217
|
+
port = _touch_1200(port)
|
|
218
|
+
with _flashable(firmware, offset, port=port, usb=usb) as (path, flash_offset):
|
|
219
|
+
args = ['--erase'] if erase else []
|
|
220
|
+
args.append('--write')
|
|
221
|
+
if verify:
|
|
222
|
+
args.append('--verify')
|
|
223
|
+
if boot:
|
|
224
|
+
args.append('--boot=1')
|
|
225
|
+
if reset:
|
|
226
|
+
args.append('--reset')
|
|
227
|
+
args.extend([f'--offset={flash_offset}', str(path)])
|
|
228
|
+
_call_bossac(args, port=port, usb=usb, log_level=log_level)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def verify(
|
|
232
|
+
filename: PathLike | str,
|
|
233
|
+
*,
|
|
234
|
+
port: str,
|
|
235
|
+
offset: int | None = None,
|
|
236
|
+
usb: bool | None = None,
|
|
237
|
+
log_level: int = logging.INFO,
|
|
238
|
+
) -> None:
|
|
239
|
+
"""
|
|
240
|
+
Verify that a device's flash matches a firmware file.
|
|
241
|
+
|
|
242
|
+
The device must already be in its SAM-BA bootloader. There is deliberately no
|
|
243
|
+
``touch`` option: a 1200 baud touch usually destroys the very firmware that is to be
|
|
244
|
+
checked. Ways into the bootloader that keep the firmware intact:
|
|
245
|
+
|
|
246
|
+
- double-tapping the reset button, on SAMD boards with an Arduino-style bootloader
|
|
247
|
+
- a command of the firmware itself that resets into the bootloader without erasing
|
|
248
|
+
anything, where the firmware offers one
|
|
249
|
+
- none on the Arduino Due, whose ROM bootloader is only reached by erasing the flash
|
|
250
|
+
|
|
251
|
+
Parameters
|
|
252
|
+
----------
|
|
253
|
+
filename : PathLike | str
|
|
254
|
+
Path to the firmware file: a raw binary (``.bin``) or an Intel HEX file
|
|
255
|
+
(``.hex``).
|
|
256
|
+
port : str
|
|
257
|
+
Port of the device.
|
|
258
|
+
offset : int, optional
|
|
259
|
+
Flash offset of a binary file - required for one, see :func:`upload` for
|
|
260
|
+
typical values. For an Intel HEX file, the offset is determined from the file's
|
|
261
|
+
addresses and the device's flash address - specifying it raises a
|
|
262
|
+
:py:exc:`ValueError`.
|
|
263
|
+
usb : bool, optional
|
|
264
|
+
Whether the port is a native USB port (True) or a USB-to-serial bridge (False).
|
|
265
|
+
Detected by bossac if not given.
|
|
266
|
+
log_level : int, default: :py:data:`logging.INFO`
|
|
267
|
+
Log level for bossac's status messages, :py:data:`logging.NOTSET` to disable.
|
|
268
|
+
Errors are always logged as warnings.
|
|
269
|
+
|
|
270
|
+
Raises
|
|
271
|
+
------
|
|
272
|
+
FileNotFoundError
|
|
273
|
+
If the firmware file does not exist.
|
|
274
|
+
IsADirectoryError
|
|
275
|
+
If the firmware path is a directory.
|
|
276
|
+
ValueError
|
|
277
|
+
If the firmware file has an unsupported extension, can't be loaded or doesn't
|
|
278
|
+
fit into the flash, or if the offset is missing or invalid.
|
|
279
|
+
TimeoutError
|
|
280
|
+
If bossac stops responding, e.g. because the device isn't in its bootloader.
|
|
281
|
+
BossacError
|
|
282
|
+
If bossac fails or the flash doesn't match the firmware file.
|
|
283
|
+
|
|
284
|
+
Examples
|
|
285
|
+
--------
|
|
286
|
+
Check the firmware of the device on a specific port:
|
|
287
|
+
|
|
288
|
+
>>> bossac.verify('blink.bin', port='/dev/ttyACM0')
|
|
289
|
+
"""
|
|
290
|
+
firmware = _load_firmware(filename, offset)
|
|
291
|
+
with _flashable(firmware, offset, port=port, usb=usb) as (path, flash_offset):
|
|
292
|
+
args = ['--verify', f'--offset={flash_offset}', str(path)]
|
|
293
|
+
_call_bossac(args, port=port, usb=usb, log_level=log_level)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def read(
|
|
297
|
+
filename: PathLike | str,
|
|
298
|
+
*,
|
|
299
|
+
port: str,
|
|
300
|
+
size: int | None = None,
|
|
301
|
+
offset: int = 0,
|
|
302
|
+
usb: bool | None = None,
|
|
303
|
+
log_level: int = logging.INFO,
|
|
304
|
+
) -> None:
|
|
305
|
+
"""
|
|
306
|
+
Read a device's flash into a binary file.
|
|
307
|
+
|
|
308
|
+
An existing file is overwritten.
|
|
309
|
+
|
|
310
|
+
The device must already be in its SAM-BA bootloader. There is deliberately no
|
|
311
|
+
``touch`` option: a 1200 baud touch usually destroys the very firmware that is to be
|
|
312
|
+
read. Ways into the bootloader that keep the firmware intact:
|
|
313
|
+
|
|
314
|
+
- double-tapping the reset button, on SAMD boards with an Arduino-style bootloader
|
|
315
|
+
- a command of the firmware itself that resets into the bootloader without erasing
|
|
316
|
+
anything, where the firmware offers one
|
|
317
|
+
- none on the Arduino Due, whose ROM bootloader is only reached by erasing the flash
|
|
318
|
+
|
|
319
|
+
Parameters
|
|
320
|
+
----------
|
|
321
|
+
filename : PathLike | str
|
|
322
|
+
Path of the binary file (``.bin``) to write.
|
|
323
|
+
port : str
|
|
324
|
+
Port of the device.
|
|
325
|
+
size : int, optional
|
|
326
|
+
Number of bytes to read, the entire flash if not given.
|
|
327
|
+
offset : int, default: 0
|
|
328
|
+
Flash offset at which to start reading. Must be aligned to a flash page.
|
|
329
|
+
usb : bool, optional
|
|
330
|
+
Whether the port is a native USB port (True) or a USB-to-serial bridge (False).
|
|
331
|
+
Detected by bossac if not given.
|
|
332
|
+
log_level : int, default: :py:data:`logging.INFO`
|
|
333
|
+
Log level for bossac's status messages, :py:data:`logging.NOTSET` to disable.
|
|
334
|
+
Errors are always logged as warnings.
|
|
335
|
+
|
|
336
|
+
Raises
|
|
337
|
+
------
|
|
338
|
+
FileNotFoundError
|
|
339
|
+
If the file's directory does not exist.
|
|
340
|
+
IsADirectoryError
|
|
341
|
+
If the path is a directory.
|
|
342
|
+
ValueError
|
|
343
|
+
If the file doesn't have the extension ``.bin``.
|
|
344
|
+
TimeoutError
|
|
345
|
+
If bossac stops responding, e.g. because the device isn't in its bootloader.
|
|
346
|
+
BossacError
|
|
347
|
+
If bossac fails.
|
|
348
|
+
|
|
349
|
+
Examples
|
|
350
|
+
--------
|
|
351
|
+
Back up the first 64 KiB of the device's flash:
|
|
352
|
+
|
|
353
|
+
>>> bossac.read('backup.bin', port='/dev/ttyACM0', size=0x10000)
|
|
354
|
+
"""
|
|
355
|
+
filepath = Path(filename).absolute()
|
|
356
|
+
if filepath.is_dir():
|
|
357
|
+
raise IsADirectoryError(errno.EISDIR, strerror(errno.EISDIR), str(filepath))
|
|
358
|
+
if not filepath.parent.is_dir():
|
|
359
|
+
raise FileNotFoundError(
|
|
360
|
+
errno.ENOENT, strerror(errno.ENOENT), str(filepath.parent)
|
|
361
|
+
)
|
|
362
|
+
if filepath.suffix.lower() != '.bin':
|
|
363
|
+
raise ValueError(f"'{filepath.name}' must have the extension .bin")
|
|
364
|
+
args = [
|
|
365
|
+
'--read' if size is None else f'--read={size}',
|
|
366
|
+
f'--offset={offset}',
|
|
367
|
+
str(filepath),
|
|
368
|
+
]
|
|
369
|
+
_call_bossac(args, port=port, usb=usb, log_level=log_level)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def erase(
|
|
373
|
+
*,
|
|
374
|
+
port: str,
|
|
375
|
+
offset: int,
|
|
376
|
+
touch: bool = False,
|
|
377
|
+
usb: bool | None = None,
|
|
378
|
+
log_level: int = logging.INFO,
|
|
379
|
+
) -> None:
|
|
380
|
+
"""
|
|
381
|
+
Erase a device's flash.
|
|
382
|
+
|
|
383
|
+
The device must be in its SAM-BA bootloader, unless ``touch`` is True.
|
|
384
|
+
|
|
385
|
+
Parameters
|
|
386
|
+
----------
|
|
387
|
+
port : str
|
|
388
|
+
Port of the device.
|
|
389
|
+
offset : int
|
|
390
|
+
Flash offset at which to start erasing. Must be aligned to a flash page. On
|
|
391
|
+
boards whose bootloader is in flash, it must not overlap the bootloader - see
|
|
392
|
+
the notes of :func:`upload` for typical values.
|
|
393
|
+
touch : bool, default: False
|
|
394
|
+
Put the device into its bootloader with a 1200 baud touch first.
|
|
395
|
+
usb : bool, optional
|
|
396
|
+
Whether the port is a native USB port (True) or a USB-to-serial bridge (False).
|
|
397
|
+
Detected by bossac if not given.
|
|
398
|
+
log_level : int, default: :py:data:`logging.INFO`
|
|
399
|
+
Log level for bossac's status messages, :py:data:`logging.NOTSET` to disable.
|
|
400
|
+
Errors are always logged as warnings.
|
|
401
|
+
|
|
402
|
+
Raises
|
|
403
|
+
------
|
|
404
|
+
FileNotFoundError
|
|
405
|
+
If ``touch`` is True and the port can't be found.
|
|
406
|
+
TimeoutError
|
|
407
|
+
If the bootloader's port doesn't appear after the 1200 baud touch, or if bossac
|
|
408
|
+
stops responding, e.g. because the device isn't in its bootloader.
|
|
409
|
+
BossacError
|
|
410
|
+
If bossac fails.
|
|
411
|
+
|
|
412
|
+
Warnings
|
|
413
|
+
--------
|
|
414
|
+
With an offset of 0, the entire flash is erased - including the bootloader of
|
|
415
|
+
boards that keep it in flash, such as SAMD21 and SAMD51 boards. Such a board can't
|
|
416
|
+
be programmed over USB anymore until its bootloader is restored with a debugger.
|
|
417
|
+
|
|
418
|
+
Examples
|
|
419
|
+
--------
|
|
420
|
+
Erase the firmware of a SAMD21 board, keeping its 8 KiB bootloader:
|
|
421
|
+
|
|
422
|
+
>>> bossac.erase(port='/dev/ttyACM0', offset=0x2000, touch=True)
|
|
423
|
+
|
|
424
|
+
Erase the entire flash of an Arduino Due, whose bootloader is in ROM:
|
|
425
|
+
|
|
426
|
+
>>> bossac.erase(port='/dev/ttyACM0', offset=0, touch=True)
|
|
427
|
+
"""
|
|
428
|
+
if touch:
|
|
429
|
+
port = _touch_1200(port)
|
|
430
|
+
_call_bossac(
|
|
431
|
+
['--erase', f'--offset={offset}'], port=port, usb=usb, log_level=log_level
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def reset(
|
|
436
|
+
*,
|
|
437
|
+
port: str,
|
|
438
|
+
usb: bool | None = None,
|
|
439
|
+
log_level: int = logging.INFO,
|
|
440
|
+
) -> None:
|
|
441
|
+
"""
|
|
442
|
+
Reset a device to start its firmware.
|
|
443
|
+
|
|
444
|
+
The device must be in its SAM-BA bootloader.
|
|
445
|
+
|
|
446
|
+
Parameters
|
|
447
|
+
----------
|
|
448
|
+
port : str
|
|
449
|
+
Port of the device.
|
|
450
|
+
usb : bool, optional
|
|
451
|
+
Whether the port is a native USB port (True) or a USB-to-serial bridge (False).
|
|
452
|
+
Detected by bossac if not given.
|
|
453
|
+
log_level : int, default: :py:data:`logging.INFO`
|
|
454
|
+
Log level for bossac's status messages, :py:data:`logging.NOTSET` to disable.
|
|
455
|
+
Errors are always logged as warnings.
|
|
456
|
+
|
|
457
|
+
Raises
|
|
458
|
+
------
|
|
459
|
+
TimeoutError
|
|
460
|
+
If bossac stops responding, e.g. because the device isn't in its bootloader.
|
|
461
|
+
BossacError
|
|
462
|
+
If bossac fails.
|
|
463
|
+
|
|
464
|
+
Examples
|
|
465
|
+
--------
|
|
466
|
+
Leave the bootloader of a device:
|
|
467
|
+
|
|
468
|
+
>>> bossac.reset(port='/dev/ttyACM0')
|
|
469
|
+
"""
|
|
470
|
+
_call_bossac(['--reset'], port=port, usb=usb, log_level=log_level)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def info(
|
|
474
|
+
*,
|
|
475
|
+
port: str,
|
|
476
|
+
touch: bool = False,
|
|
477
|
+
usb: bool | None = None,
|
|
478
|
+
) -> DeviceInfo:
|
|
479
|
+
"""
|
|
480
|
+
Return a device's details.
|
|
481
|
+
|
|
482
|
+
The device must be in its SAM-BA bootloader, unless ``touch`` is True. See
|
|
483
|
+
:class:`DeviceInfo` for the details reported.
|
|
484
|
+
|
|
485
|
+
Parameters
|
|
486
|
+
----------
|
|
487
|
+
port : str
|
|
488
|
+
Port of the device.
|
|
489
|
+
touch : bool, default: False
|
|
490
|
+
Put the device into its bootloader with a 1200 baud touch first. This
|
|
491
|
+
usually destroys the firmware, which then has to be uploaded again: the
|
|
492
|
+
Arduino Due erases its flash, and SAMD boards running Arduino firmware erase
|
|
493
|
+
its first flash row.
|
|
494
|
+
usb : bool, optional
|
|
495
|
+
Whether the port is a native USB port (True) or a USB-to-serial bridge (False).
|
|
496
|
+
Detected by bossac if not given.
|
|
497
|
+
|
|
498
|
+
Returns
|
|
499
|
+
-------
|
|
500
|
+
DeviceInfo
|
|
501
|
+
Details of the device.
|
|
502
|
+
|
|
503
|
+
Raises
|
|
504
|
+
------
|
|
505
|
+
FileNotFoundError
|
|
506
|
+
If ``touch`` is True and the port can't be found.
|
|
507
|
+
TimeoutError
|
|
508
|
+
If the bootloader's port doesn't appear after the 1200 baud touch, or if bossac
|
|
509
|
+
stops responding, e.g. because the device isn't in its bootloader.
|
|
510
|
+
RuntimeError
|
|
511
|
+
If bossac's output can't be parsed.
|
|
512
|
+
BossacError
|
|
513
|
+
If bossac fails.
|
|
514
|
+
|
|
515
|
+
Examples
|
|
516
|
+
--------
|
|
517
|
+
Get the flash size of a device in its bootloader:
|
|
518
|
+
|
|
519
|
+
>>> bossac.info(port='/dev/ttyACM0')['total_size']
|
|
520
|
+
524288
|
|
521
|
+
"""
|
|
522
|
+
if touch:
|
|
523
|
+
port = _touch_1200(port)
|
|
524
|
+
output = _call_bossac(['--info'], port=port, usb=usb)
|
|
525
|
+
return _parse_info(output)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def version() -> str:
|
|
529
|
+
"""
|
|
530
|
+
Return the version of the bundled bossac binary.
|
|
531
|
+
|
|
532
|
+
Returns
|
|
533
|
+
-------
|
|
534
|
+
str
|
|
535
|
+
Version of bossac, e.g. ``'1.9.1'``.
|
|
536
|
+
|
|
537
|
+
Raises
|
|
538
|
+
------
|
|
539
|
+
RuntimeError
|
|
540
|
+
If the version can't be determined from bossac's output.
|
|
541
|
+
TimeoutError
|
|
542
|
+
If bossac stops responding, e.g. because the device isn't in its bootloader.
|
|
543
|
+
BossacError
|
|
544
|
+
If bossac fails.
|
|
545
|
+
|
|
546
|
+
Examples
|
|
547
|
+
--------
|
|
548
|
+
Check the version of the bundled bossac binary:
|
|
549
|
+
|
|
550
|
+
>>> bossac.version()
|
|
551
|
+
'1.9.1'
|
|
552
|
+
"""
|
|
553
|
+
# bossac has no --version option - its help text names the version, exit status 1
|
|
554
|
+
output = _call_bossac(['--help'], returncodes=(0, 1))
|
|
555
|
+
if (match := _RE_VERSION.search(output)) is None:
|
|
556
|
+
raise RuntimeError('Could not determine the version of bossac')
|
|
557
|
+
return match.group(1)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _parse_info(output: str) -> DeviceInfo:
|
|
561
|
+
"""Return the details printed by ``bossac --info``."""
|
|
562
|
+
fields = {}
|
|
563
|
+
for line in output.splitlines():
|
|
564
|
+
key, sep, value = line.partition(':')
|
|
565
|
+
if sep:
|
|
566
|
+
fields[key.strip().lower()] = value.strip()
|
|
567
|
+
try:
|
|
568
|
+
locked = fields['locked']
|
|
569
|
+
return DeviceInfo(
|
|
570
|
+
device=fields['device'],
|
|
571
|
+
version=fields['version'],
|
|
572
|
+
address=int(fields['address'], 16),
|
|
573
|
+
pages=int(fields['pages']),
|
|
574
|
+
page_size=int(fields['page size'].removesuffix('bytes')),
|
|
575
|
+
total_size=int(fields['total size'].removesuffix('KB')) * 1024,
|
|
576
|
+
planes=int(fields['planes']),
|
|
577
|
+
lock_regions=int(fields['lock regions']),
|
|
578
|
+
locked=[] if locked == 'none' else [int(r) for r in locked.split(',')],
|
|
579
|
+
security=fields['security'] == 'true',
|
|
580
|
+
boot_flash=_parse_optional_bool(fields.get('boot flash')),
|
|
581
|
+
brownout_detection=_parse_optional_bool(fields.get('bod')),
|
|
582
|
+
brownout_reset=_parse_optional_bool(fields.get('bor')),
|
|
583
|
+
)
|
|
584
|
+
except (KeyError, ValueError) as e:
|
|
585
|
+
raise RuntimeError("Could not parse bossac's device information") from e
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _parse_optional_bool(value: str | None) -> bool | None:
|
|
589
|
+
"""Return a boolean printed by bossac, or None if it wasn't printed."""
|
|
590
|
+
return None if value is None else value == 'true'
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _parse_firmware_file(filename: PathLike | str) -> Path:
|
|
594
|
+
"""Return the absolute path of a firmware file, or raise if it's not usable."""
|
|
595
|
+
filepath = Path(filename).absolute()
|
|
596
|
+
if not filepath.exists():
|
|
597
|
+
raise FileNotFoundError(errno.ENOENT, strerror(errno.ENOENT), str(filepath))
|
|
598
|
+
if filepath.is_dir():
|
|
599
|
+
raise IsADirectoryError(errno.EISDIR, strerror(errno.EISDIR), str(filepath))
|
|
600
|
+
if filepath.suffix.lower() not in _VALID_FIRMWARE_EXT:
|
|
601
|
+
raise ValueError(
|
|
602
|
+
f"'{filepath.name}' has unrecognized extension "
|
|
603
|
+
f'(supported: {", ".join(_VALID_FIRMWARE_EXT)})'
|
|
604
|
+
)
|
|
605
|
+
return filepath
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _load_firmware(filename: PathLike | str, offset: int | None) -> _Firmware:
|
|
609
|
+
"""Load a firmware file, or raise if it's not usable - before touching a device."""
|
|
610
|
+
filepath = _parse_firmware_file(filename)
|
|
611
|
+
if filepath.suffix.lower() == '.bin':
|
|
612
|
+
if offset is None:
|
|
613
|
+
raise ValueError(
|
|
614
|
+
f"'{filepath.name}' doesn't record where in the flash it belongs - "
|
|
615
|
+
'pass offset, e.g. 0x2000 after an 8 KiB Arduino SAMD21 bootloader, '
|
|
616
|
+
'or 0 on an Arduino Due'
|
|
617
|
+
)
|
|
618
|
+
return _Firmware(filepath, filepath.read_bytes(), start=None)
|
|
619
|
+
if offset is not None:
|
|
620
|
+
raise ValueError('offset is determined from the addresses of an Intel HEX file')
|
|
621
|
+
return _Firmware(filepath, *_read_hex(filepath))
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
@contextmanager
|
|
625
|
+
def _flashable(
|
|
626
|
+
firmware: _Firmware, offset: int | None, *, port: str, usb: bool | None
|
|
627
|
+
) -> Iterator[tuple[Path, int]]:
|
|
628
|
+
"""Yield the path of a firmware file as a binary, and the offset to flash it at."""
|
|
629
|
+
# bossac's offsets are relative to the start of the device's flash, and it checks
|
|
630
|
+
# an image's size without its offset - after it has erased the flash
|
|
631
|
+
device = info(port=port, usb=usb)
|
|
632
|
+
name = firmware.path.name
|
|
633
|
+
if offset is None: # an Intel HEX file, whose addresses are absolute
|
|
634
|
+
assert firmware.start is not None
|
|
635
|
+
offset = firmware.start - device['address']
|
|
636
|
+
address = device['address'] + offset
|
|
637
|
+
if offset < 0:
|
|
638
|
+
raise ValueError(
|
|
639
|
+
f"'{name}' starts at 0x{address:x}, "
|
|
640
|
+
f'before the flash at 0x{device["address"]:x}'
|
|
641
|
+
)
|
|
642
|
+
if offset % device['page_size']:
|
|
643
|
+
raise ValueError(
|
|
644
|
+
f"'{name}' starts at 0x{address:x}, which isn't aligned to a flash "
|
|
645
|
+
f'page of {device["page_size"]} bytes'
|
|
646
|
+
)
|
|
647
|
+
if offset + len(firmware.data) > device['total_size']:
|
|
648
|
+
raise ValueError(
|
|
649
|
+
f"'{name}' doesn't fit into the flash: {len(firmware.data)} bytes at "
|
|
650
|
+
f'offset 0x{offset:x}, but the flash holds {device["total_size"]} bytes'
|
|
651
|
+
)
|
|
652
|
+
if firmware.start is None:
|
|
653
|
+
yield firmware.path, offset
|
|
654
|
+
return
|
|
655
|
+
with TemporaryDirectory() as tmp:
|
|
656
|
+
binary = Path(tmp, firmware.path.with_suffix('.bin').name)
|
|
657
|
+
binary.write_bytes(firmware.data)
|
|
658
|
+
yield binary, offset
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _read_hex(filepath: Path) -> tuple[bytes, int]:
|
|
662
|
+
"""Return the contents of an Intel HEX file as a binary, and its start address."""
|
|
663
|
+
chunks: dict[int, bytes] = {}
|
|
664
|
+
base = 0
|
|
665
|
+
with filepath.open() as f:
|
|
666
|
+
for number, line in enumerate(f, start=1):
|
|
667
|
+
if not (line := line.strip()):
|
|
668
|
+
continue
|
|
669
|
+
try:
|
|
670
|
+
if not line.startswith(':'):
|
|
671
|
+
raise ValueError('missing start code')
|
|
672
|
+
record = bytes.fromhex(line[1:])
|
|
673
|
+
if len(record) < 5 or len(record) != record[0] + 5:
|
|
674
|
+
raise ValueError('invalid length')
|
|
675
|
+
if sum(record) & 0xFF:
|
|
676
|
+
raise ValueError('invalid checksum')
|
|
677
|
+
if record[3] not in _HEX_PAYLOAD_SIZES:
|
|
678
|
+
raise ValueError(f'unknown record type 0x{record[3]:02x}')
|
|
679
|
+
size = _HEX_PAYLOAD_SIZES[record[3]]
|
|
680
|
+
if size is not None and record[0] != size:
|
|
681
|
+
raise ValueError(
|
|
682
|
+
f'invalid length for record type 0x{record[3]:02x}'
|
|
683
|
+
)
|
|
684
|
+
except ValueError as e:
|
|
685
|
+
raise ValueError(
|
|
686
|
+
f"Invalid record in '{filepath.name}', line {number}: {e}"
|
|
687
|
+
) from e
|
|
688
|
+
address = int.from_bytes(record[1:3], 'big')
|
|
689
|
+
record_type, payload = record[3], record[4:-1]
|
|
690
|
+
if record_type == 0x00: # data
|
|
691
|
+
chunks[base + address] = payload
|
|
692
|
+
elif record_type == 0x01: # end of file
|
|
693
|
+
break
|
|
694
|
+
elif record_type == 0x02: # extended segment address
|
|
695
|
+
base = int.from_bytes(payload, 'big') << 4
|
|
696
|
+
elif record_type == 0x04: # extended linear address
|
|
697
|
+
base = int.from_bytes(payload, 'big') << 16
|
|
698
|
+
# 0x03 & 0x05 (start addresses) don't affect the flash's contents
|
|
699
|
+
if not chunks:
|
|
700
|
+
raise ValueError(f"'{filepath.name}' contains no data")
|
|
701
|
+
|
|
702
|
+
start = min(chunks)
|
|
703
|
+
end = max(address + len(payload) for address, payload in chunks.items())
|
|
704
|
+
if end - start > _MAX_HEX_SPAN:
|
|
705
|
+
raise ValueError(
|
|
706
|
+
f"'{filepath.name}' spans 0x{start:x} to 0x{end:x}, too much to be flashed"
|
|
707
|
+
)
|
|
708
|
+
data = bytearray(b'\xff') * (end - start) # gaps are left as erased flash
|
|
709
|
+
for address, payload in chunks.items():
|
|
710
|
+
data[address - start : address - start + len(payload)] = payload
|
|
711
|
+
return bytes(data), start
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _touch_1200(port: str) -> str:
|
|
715
|
+
"""Put a device into its bootloader and return the bootloader's port."""
|
|
716
|
+
ports = comports()
|
|
717
|
+
if (device := _find_port(port, ports)) is None:
|
|
718
|
+
raise FileNotFoundError(f'Could not find serial port {port}')
|
|
719
|
+
before = {p.device for p in ports}
|
|
720
|
+
|
|
721
|
+
# opening the port at 1200 baud and dropping DTR resets the device into its
|
|
722
|
+
# bootloader - and usually destroys its firmware: the Arduino Due erases its flash,
|
|
723
|
+
# Arduino firmware on SAMD boards erases its own first flash row
|
|
724
|
+
log.debug('Touching %s at 1200 baud', device.device)
|
|
725
|
+
with serial.Serial(device.device, 1200) as connection:
|
|
726
|
+
connection.dtr = False
|
|
727
|
+
|
|
728
|
+
# a native USB port disappears, the bootloader's then appears at the same USB
|
|
729
|
+
# location - possibly with a different name. The port of a USB-to-serial bridge
|
|
730
|
+
# stays where it is.
|
|
731
|
+
start = time.monotonic()
|
|
732
|
+
disappeared = False
|
|
733
|
+
while (elapsed := time.monotonic() - start) < _TOUCH_TIMEOUT:
|
|
734
|
+
time.sleep(_TOUCH_POLL)
|
|
735
|
+
listed = {p.device: p for p in comports()}
|
|
736
|
+
if new := [
|
|
737
|
+
name
|
|
738
|
+
for name, p in sorted(listed.items())
|
|
739
|
+
if name not in before and _same_device(p, device)
|
|
740
|
+
]:
|
|
741
|
+
log.debug('Bootloader appeared on %s', new[0])
|
|
742
|
+
return new[0]
|
|
743
|
+
before &= set(listed) # so that a port reappearing under its old name is new
|
|
744
|
+
if device.device not in listed:
|
|
745
|
+
disappeared = True
|
|
746
|
+
elif not disappeared and elapsed >= _TOUCH_SETTLE:
|
|
747
|
+
return device.device
|
|
748
|
+
raise TimeoutError(f'No bootloader appeared after touching {port} at 1200 baud')
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _find_port(port: str, ports: Iterable[ListPortInfo]) -> ListPortInfo | None:
|
|
752
|
+
"""Return the port with the given name or an alias of it."""
|
|
753
|
+
key = _port_key(port)
|
|
754
|
+
return next((p for p in ports if _port_key(p.device) == key), None)
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def _same_device(port: ListPortInfo, device: ListPortInfo) -> bool:
|
|
758
|
+
"""Return whether a port belongs to a device - by USB location, else by name."""
|
|
759
|
+
if (location := _usb_location(device)) is not None:
|
|
760
|
+
return _usb_location(port) == location
|
|
761
|
+
return port.device == device.device
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _port_key(port: str) -> str:
|
|
765
|
+
"""Return a port's name as listed by pyserial, with aliases resolved."""
|
|
766
|
+
if sys.platform == 'win32':
|
|
767
|
+
return port.upper().removeprefix('\\\\.\\')
|
|
768
|
+
port = os.path.realpath(port) # e.g. /dev/serial/by-id/...
|
|
769
|
+
if sys.platform == 'darwin':
|
|
770
|
+
port = port.replace('/dev/tty.', '/dev/cu.', 1) # listed as call-out devices
|
|
771
|
+
return port
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def _usb_location(port: ListPortInfo) -> str | None:
|
|
775
|
+
"""Return the USB location of a port's device, without its interface."""
|
|
776
|
+
return port.location.partition(':')[0] if port.location else None
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def _call_bossac(
|
|
780
|
+
args: list[str],
|
|
781
|
+
*,
|
|
782
|
+
port: str | None = None,
|
|
783
|
+
usb: bool | None = None,
|
|
784
|
+
log_level: int = logging.NOTSET,
|
|
785
|
+
returncodes: tuple[int, ...] = (0,),
|
|
786
|
+
) -> str:
|
|
787
|
+
"""Run bossac, log its output and return its stdout."""
|
|
788
|
+
args = _assemble_args(args, port=port, usb=usb)
|
|
789
|
+
if log.isEnabledFor(logging.DEBUG):
|
|
790
|
+
log.debug('Calling subprocess: %s', shlex.join(args))
|
|
791
|
+
|
|
792
|
+
# stdout (status messages) is logged at log_level, stderr (errors) at WARNING -
|
|
793
|
+
# stderr is read in a thread, so neither pipe can fill up and block bossac.
|
|
794
|
+
# Progress bars are separated by carriage returns, which text mode turns into
|
|
795
|
+
# line breaks.
|
|
796
|
+
stdout_lines: list[str] = []
|
|
797
|
+
stderr_lines: list[str] = []
|
|
798
|
+
stderr_errors: list[Exception] = []
|
|
799
|
+
finished, timed_out = Event(), Event()
|
|
800
|
+
with Popen(args, stdout=PIPE, stderr=PIPE, encoding='utf-8', errors='replace') as p:
|
|
801
|
+
assert p.stdout is not None
|
|
802
|
+
assert p.stderr is not None
|
|
803
|
+
stderr_thread = Thread(
|
|
804
|
+
target=partial(_consume_stderr, p, p.stderr, stderr_lines, stderr_errors),
|
|
805
|
+
daemon=True, # in case joining it after an error is interrupted, too
|
|
806
|
+
)
|
|
807
|
+
watchdog = Thread(
|
|
808
|
+
target=partial(
|
|
809
|
+
_watch, p, (stdout_lines, stderr_lines), finished, timed_out
|
|
810
|
+
),
|
|
811
|
+
daemon=True,
|
|
812
|
+
)
|
|
813
|
+
stderr_thread.start()
|
|
814
|
+
watchdog.start()
|
|
815
|
+
try:
|
|
816
|
+
_consume_pipe(p.stdout, stdout_lines, log_level)
|
|
817
|
+
stderr_thread.join() # before the with-block closes the pipes
|
|
818
|
+
except BaseException:
|
|
819
|
+
p.kill() # bossac may take a while to give up on an unresponsive device
|
|
820
|
+
stderr_thread.join()
|
|
821
|
+
raise
|
|
822
|
+
finally:
|
|
823
|
+
finished.set()
|
|
824
|
+
watchdog.join()
|
|
825
|
+
if timed_out.is_set():
|
|
826
|
+
raise TimeoutError(
|
|
827
|
+
f'bossac stopped responding for {_BOSSAC_TIMEOUT:g} s and was killed - the '
|
|
828
|
+
'device may not be in its SAM-BA bootloader, or may have been reset or '
|
|
829
|
+
'disconnected'
|
|
830
|
+
)
|
|
831
|
+
if stderr_errors:
|
|
832
|
+
raise stderr_errors[0]
|
|
833
|
+
stdout = '\n'.join(stdout_lines)
|
|
834
|
+
stderr = '\n'.join(stderr_lines)
|
|
835
|
+
|
|
836
|
+
if p.returncode not in returncodes:
|
|
837
|
+
if not stderr and 'Verify failed' in stdout:
|
|
838
|
+
# bossac reports a failed verification on stdout
|
|
839
|
+
stderr = stdout[stdout.index('Verify failed') :]
|
|
840
|
+
error = BossacError(p.returncode, p.args, output=stdout, stderr=stderr)
|
|
841
|
+
if port is not None and _RE_NO_BOOTLOADER.search(stderr):
|
|
842
|
+
error.hint = (
|
|
843
|
+
f'The device on {port} may not be in its SAM-BA bootloader - put it '
|
|
844
|
+
'there first, e.g. with a 1200 baud touch (touch=True) or by '
|
|
845
|
+
'double-tapping its reset button.'
|
|
846
|
+
)
|
|
847
|
+
raise error
|
|
848
|
+
return stdout
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def _watch(
|
|
852
|
+
process: Popen[str],
|
|
853
|
+
outputs: tuple[list[str], ...],
|
|
854
|
+
finished: Event,
|
|
855
|
+
timed_out: Event,
|
|
856
|
+
) -> None:
|
|
857
|
+
"""Kill bossac if its outputs stop growing - bossac itself never times out."""
|
|
858
|
+
count, since = -1, time.monotonic()
|
|
859
|
+
while not finished.wait(min(_BOSSAC_TIMEOUT / 10, 1.0)):
|
|
860
|
+
if (current := sum(map(len, outputs))) != count:
|
|
861
|
+
count, since = current, time.monotonic()
|
|
862
|
+
elif time.monotonic() - since > _BOSSAC_TIMEOUT:
|
|
863
|
+
timed_out.set()
|
|
864
|
+
process.kill()
|
|
865
|
+
return
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def _consume_pipe(stream: IO[str], lines: list[str], log_level: int) -> None:
|
|
869
|
+
"""Collect each line as is and log it - progress bars at DEBUG, if at all."""
|
|
870
|
+
for line in stream:
|
|
871
|
+
lines.append(line := line.rstrip('\n'))
|
|
872
|
+
if (message := line.strip()) and log_level > logging.NOTSET:
|
|
873
|
+
level = logging.DEBUG if _RE_PROGRESS.match(message) else log_level
|
|
874
|
+
log.log(level, message)
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def _consume_stderr(
|
|
878
|
+
process: Popen[str], stream: IO[str], lines: list[str], errors: list[Exception]
|
|
879
|
+
) -> None:
|
|
880
|
+
"""Consume stderr in a thread - on failure, kill bossac and keep the error."""
|
|
881
|
+
try:
|
|
882
|
+
_consume_pipe(stream, lines, logging.WARNING)
|
|
883
|
+
except Exception as e: # noqa: BLE001 - re-raised by _call_bossac
|
|
884
|
+
errors.append(e)
|
|
885
|
+
process.kill() # otherwise, bossac might block on a full stderr pipe
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
@cache
|
|
889
|
+
def _resolve_bossac() -> str:
|
|
890
|
+
"""Return the path of the bundled bossac binary."""
|
|
891
|
+
try:
|
|
892
|
+
record = files('bossac-wrapper') or []
|
|
893
|
+
except PackageNotFoundError:
|
|
894
|
+
record = []
|
|
895
|
+
for file in record:
|
|
896
|
+
if (
|
|
897
|
+
file.name == _BOSSAC_NAME
|
|
898
|
+
and (path := Path(file.locate()).resolve()).is_file()
|
|
899
|
+
):
|
|
900
|
+
return str(path)
|
|
901
|
+
raise FileNotFoundError(
|
|
902
|
+
f'Could not find the {_BOSSAC_NAME} binary bundled with bossac-wrapper - '
|
|
903
|
+
'try reinstalling the package'
|
|
904
|
+
)
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def _assemble_args(
|
|
908
|
+
args: list[str],
|
|
909
|
+
*,
|
|
910
|
+
port: str | None = None,
|
|
911
|
+
usb: bool | None = None,
|
|
912
|
+
) -> list[str]:
|
|
913
|
+
"""Return the bossac command line, with the port's options if a port is given."""
|
|
914
|
+
output = [_resolve_bossac()]
|
|
915
|
+
if port is not None:
|
|
916
|
+
output.append(f'--port={port}')
|
|
917
|
+
if usb is not None:
|
|
918
|
+
output.append(f'--usb-port={int(usb)}')
|
|
919
|
+
return [*output, *args]
|
bossac/py.typed
ADDED
|
File without changes
|
|
Binary file
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: bossac-wrapper
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Python wrapper for bossac
|
|
5
|
+
Keywords: bossa,bossac,sam-ba,atmel,arduino,firmware
|
|
6
|
+
Author-Email: Florian Rau <bimac@users.noreply.github.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/int-brain-lab/bossac-wrapper/
|
|
9
|
+
Project-URL: Documentation, https://int-brain-lab.github.io/bossac-wrapper/
|
|
10
|
+
Project-URL: Repository, https://github.com/int-brain-lab/bossac-wrapper.git
|
|
11
|
+
Project-URL: Issues, https://github.com/int-brain-lab/bossac-wrapper/issues
|
|
12
|
+
Project-URL: Changelog, https://github.com/int-brain-lab/bossac-wrapper/blob/main/CHANGELOG.md
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Requires-Dist: pyserial>=3.5
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
bossac-wrapper
|
|
18
|
+
==============
|
|
19
|
+
|
|
20
|
+
A thin Python wrapper for [bossac](https://github.com/shumatech/BOSSA) by
|
|
21
|
+
[ShumaTech](http://www.shumatech.com) - a flash programmer for Atmel SAM microcontrollers with
|
|
22
|
+
a SAM-BA bootloader, such as the Arduino Due, Arduino Zero and MKR boards.
|
|
23
|
+
|
|
24
|
+
Installation
|
|
25
|
+
------------
|
|
26
|
+
|
|
27
|
+
```shell
|
|
28
|
+
pip install bossac-wrapper
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
This will install bossac-wrapper along with the necessary bossac binary for your system.
|
|
32
|
+
|
|
33
|
+
bossac-wrapper requires Python 3.10 or later. Supported platforms are:
|
|
34
|
+
|
|
35
|
+
- Linux (x86_64, arm64)
|
|
36
|
+
- macOS (Intel, Apple Silicon)
|
|
37
|
+
- Windows (x86_64)
|
|
38
|
+
|
|
39
|
+
Examples
|
|
40
|
+
--------
|
|
41
|
+
|
|
42
|
+
### Uploading a firmware file
|
|
43
|
+
|
|
44
|
+
To upload a firmware file to a device, use the `upload()` function. Both Intel HEX files (`.hex`)
|
|
45
|
+
and raw binaries (`.bin`) are supported.
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
import bossac
|
|
49
|
+
import logging
|
|
50
|
+
|
|
51
|
+
logging.basicConfig(level=logging.INFO)
|
|
52
|
+
bossac.upload('blink.hex', port='/dev/ttyACM0')
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
By default, the device is first put into its bootloader with a 1200 baud touch. Its flash is
|
|
56
|
+
then erased, written and verified, and the device is reset to start the new firmware. The
|
|
57
|
+
progress will be logged:
|
|
58
|
+
```
|
|
59
|
+
INFO:bossac:Erase flash
|
|
60
|
+
INFO:bossac:Done in 0.887 seconds
|
|
61
|
+
INFO:bossac:Write 10288 bytes to flash (161 pages)
|
|
62
|
+
INFO:bossac:Done in 0.067 seconds
|
|
63
|
+
INFO:bossac:Verify 10288 bytes of flash
|
|
64
|
+
INFO:bossac:Verify successful
|
|
65
|
+
INFO:bossac:Done in 0.276 seconds
|
|
66
|
+
INFO:bossac:Set boot flash true
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
An Intel HEX file carries its addresses, so it is flashed at the right offset on any board. A
|
|
70
|
+
binary file doesn't, so its offset has to be given - it depends on the board's bootloader, e.g.
|
|
71
|
+
`0x2000` on SAMD21 boards with an Arduino-style bootloader, or 0 on the Arduino Due, whose
|
|
72
|
+
bootloader is in ROM. On a board whose bootloader is in flash, an offset of 0 would overwrite it.
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
bossac.upload('blink.bin', port='/dev/ttyACM0', offset=0x2000) # SAMD21
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
When uploading via a USB-to-serial bridge, such as the programming port of the Arduino Due, pass
|
|
79
|
+
`usb=False`:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
bossac.upload('blink.bin', port='/dev/ttyACM0', offset=0, usb=False)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Getting device information
|
|
86
|
+
|
|
87
|
+
To get the details of a device, use the `info()` function. The device must be in its bootloader,
|
|
88
|
+
e.g. after double-tapping its reset button. `touch=True` puts it there, too - but that usually
|
|
89
|
+
destroys the firmware, which then has to be uploaded again: the Arduino Due erases its flash, and
|
|
90
|
+
SAMD boards running Arduino firmware erase its first flash row.
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
import bossac
|
|
94
|
+
|
|
95
|
+
bossac.info(port='/dev/ttyACM0')
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
For a SparkFun SAMD21 Mini, this returns:
|
|
99
|
+
```python
|
|
100
|
+
{
|
|
101
|
+
'device': 'ATSAMD21x18',
|
|
102
|
+
'version': 'v1.1 [Arduino:XYZ] Feb 20 2019 10:43:51',
|
|
103
|
+
'address': 0,
|
|
104
|
+
'pages': 4096,
|
|
105
|
+
'page_size': 64,
|
|
106
|
+
'total_size': 262144,
|
|
107
|
+
'planes': 1,
|
|
108
|
+
'lock_regions': 16,
|
|
109
|
+
'locked': [],
|
|
110
|
+
'security': False,
|
|
111
|
+
'boot_flash': None,
|
|
112
|
+
'brownout_detection': True,
|
|
113
|
+
'brownout_reset': True,
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Full Documentation
|
|
118
|
+
------------------
|
|
119
|
+
|
|
120
|
+
The full API documentation is available [here](https://int-brain-lab.github.io/bossac-wrapper).
|
|
121
|
+
|
|
122
|
+

|
|
123
|
+
[](https://coveralls.io/github/int-brain-lab/bossac-wrapper)
|
|
124
|
+
[](https://github.com/int-brain-lab/bossac-wrapper/actions)
|
|
125
|
+
[](https://pypi.org/project/bossac-wrapper/)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
bossac/__init__.py,sha256=mdgMlapY0Wgbp0nv_pyxpWswBMIrMWZxHdc43zp6Dt0,35108
|
|
2
|
+
bossac/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
bossac_wrapper-0.1.0.data/scripts/bossac.exe,sha256=X0CTv8betM8sxC-4HlFXteoZl4EUz68hr0H4l8rLoEw,288768
|
|
4
|
+
bossac_wrapper-0.1.0.dist-info/METADATA,sha256=wQW_onINbZUyprKwwI--Mtv-ld7d2aUEEFCC4kdhLiw,4239
|
|
5
|
+
bossac_wrapper-0.1.0.dist-info/WHEEL,sha256=Y5Wwfz_kwlVjF8_vNdNbWTRtg8EAOwevuqlndZ0AzCA,97
|
|
6
|
+
bossac_wrapper-0.1.0.dist-info/entry_points.txt,sha256=6OYgBcLyFCUgeqLgnvMyOJxPCWzgy7se4rLPKtNonMs,34
|
|
7
|
+
bossac_wrapper-0.1.0.dist-info/licenses/LICENSE,sha256=btkVQWqJzPt_Lt949HhwB1gZlc5c4MZ5yRd27BMYXRg,1108
|
|
8
|
+
bossac_wrapper-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 International Brain Laboratory
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|