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 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
@@ -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
+ ![License](https://img.shields.io/github/license/int-brain-lab/bossac-wrapper)
123
+ [![Coverage](https://img.shields.io/coverallsCoverage/github/int-brain-lab/bossac-wrapper)](https://coveralls.io/github/int-brain-lab/bossac-wrapper)
124
+ [![CI](https://github.com/int-brain-lab/bossac-wrapper/actions/workflows/testing.yaml/badge.svg)](https://github.com/int-brain-lab/bossac-wrapper/actions)
125
+ [![PyPI](https://img.shields.io/pypi/v/bossac-wrapper)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: pdm-backend (2.5.0)
3
+ Root-Is-Purelib: false
4
+ Tag: py3-none-win_amd64
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+
3
+ [gui_scripts]
4
+
@@ -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.