diff --git a/.flake8 b/.flake8
new file mode 100644
index 0000000..24ba8f9
--- /dev/null
+++ b/.flake8
@@ -0,0 +1,25 @@
+[flake8]
+max-line-length = 98
+max-doc-length = 78
+exclude =
+ .git,
+ .idea,
+ __pycache__,
+ build,
+ dist,
+ *.egg-info
+
+# be compatible to black
+# Missing trailing comma
+# Whitespace before ':'
+# line break before binary operator
+ignore =
+ C812,
+ E203,
+ W503,
+
+# Missing docstring in public function
+# .next() is not a thing in Python 3
+per-file-ignores =
+ tests/*:D103,
+ stm32loader/bootloader.py:B305,
diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml
new file mode 100644
index 0000000..1beac03
--- /dev/null
+++ b/.github/workflows/lint.yaml
@@ -0,0 +1,61 @@
+name: Lint
+
+on:
+ - push
+ - pull_request
+
+defaults:
+ run:
+ shell: bash
+
+jobs:
+ black:
+ strategy:
+ matrix:
+ os:
+ - ubuntu-latest
+ python-version:
+ - "3.11"
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@v2
+ - uses: psf/black@stable
+ with:
+ options: "--check --verbose"
+ src: "./stm32loader"
+ flake8:
+ strategy:
+ fail-fast: true
+ matrix:
+ os:
+ - ubuntu-latest
+ python-version:
+ - "3.11"
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@v2
+ - uses: actions/setup-python@v2
+ with:
+ python-version: ${{ matrix.python-version }}
+ - name: Install lint dependencies
+ run: pip install flake8 pyserial intelhex
+ - name: Run flake8
+ run: flake8 stm32loader
+ pylint:
+ strategy:
+ fail-fast: true
+ matrix:
+ os:
+ - ubuntu-latest
+ python-version:
+ - "3.11"
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@v2
+ - uses: actions/setup-python@v2
+ with:
+ python-version: ${{ matrix.python-version }}
+ - name: Install lint dependencies
+ run: pip install pylint pyserial intelhex
+ - name: Run pylint
+ run: pylint stm32loader
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
new file mode 100644
index 0000000..5c23e58
--- /dev/null
+++ b/.github/workflows/test.yaml
@@ -0,0 +1,33 @@
+name: Test
+
+on:
+ - push
+ - pull_request
+
+defaults:
+ run:
+ shell: bash
+
+jobs:
+ pytest:
+ strategy:
+ fail-fast: true
+ matrix:
+ os:
+ - ubuntu-latest
+ - windows-latest
+ python-version:
+ - "3.9"
+ - "3.10"
+ - "3.11"
+ - "pypy-3.9"
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@v2
+ - uses: actions/setup-python@v2
+ with:
+ python-version: ${{ matrix.python-version }}
+ - name: Install test dependencies
+ run: pip install tox tox-gh-actions pyserial pytest intelhex
+ - name: Run setup and tests as defined in tox.ini
+ run: tox
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3493742
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+build/
+dist/
+*.egg-info/
+.tox/
+
+__pycache__/
+*.py[cod]
+.nox/
diff --git a/.pylintrc b/.pylintrc
new file mode 100644
index 0000000..0e18dc7
--- /dev/null
+++ b/.pylintrc
@@ -0,0 +1,16 @@
+[FORMAT]
+max-line-length=98
+
+[MESSAGES CONTROL]
+disable=
+ fixme, # TO DOs are not errors.
+ consider-using-f-string, # We're not on Python >= 3.6 yet.
+
+[REPORT]
+score=no
+
+[BASIC]
+good-names=
+ i,
+ e,
+ namespace,
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 0000000..ad3d1ee
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,32 @@
+language: python
+
+matrix:
+ include:
+ # Lint using nox on native Python 3.6
+ - python: "3.6"
+ env: NOXSESSION="lint"
+
+ # Test using nox on native Python 3.5/3.6/3.7/3.8
+ - python: "3.5"
+ env: NOXSESSION="tests-3.5"
+ - python: "3.6"
+ env: NOXSESSION="tests-3.6"
+ - python: "3.7"
+ env: NOXSESSION="tests-3.7"
+ dist: xenial # necessary for Python 3.7
+ sudo: required # necessary for Python 3.7
+ - python: "3.8"
+ env: NOXSESSION="tests-3.8"
+ dist: xenial # necessary for Python 3.8
+ sudo: required # necessary for Python 3.8?
+
+ # Test using nox on non-native Python version 3.6
+ # (nox does not natively run on 3.4)
+ - python: "3.6"
+ env: NOXSESSION="tests-3.4"
+
+install:
+ - pip install --upgrade pip setuptools nox pyserial progress
+ - pip install .
+
+script: nox --session "$NOXSESSION"
diff --git a/ALTERNATIVES.md b/ALTERNATIVES.md
new file mode 100644
index 0000000..3f5b790
--- /dev/null
+++ b/ALTERNATIVES.md
@@ -0,0 +1,58 @@
+
+# Alternative tools
+
+
+## Stm32CubeProgrammer
+
+Official cross-platform tool by ST, with GUI and CLI versions.
+
+Supports all bootloader protocols (UART, I2C, SPI, USB DFU, CAN).
+Supports OTP memory and option bytes.
+
+https://www.st.com/en/development-tools/stm32cubeprog.html
+
+
+## STM32 Flash loader demonstrator (Flasher-stm32)
+
+Windows tool by ST.
+
+It comes with a command-line version and source code.
+
+https://www.st.com/en/development-tools/flasher-stm32.html
+
+
+## STM32 ST-Link utility
+
+This is replaced by Stm32CubProgrammer (see above).
+
+https://www.st.com/en/development-tools/stsw-link004.html
+
+
+## stm32flash
+
+Open source flashing tool in C.
+
+Supports UART and SPI, and covers many STM32 devices.
+This is probably a good choice if you're looking for speed.
+
+https://sourceforge.net/projects/stm32flash/
+
+
+## ST-flash
+
+This is open re-implementation of the ST-Link tools.
+
+https://github.com/stlink-org/stlink st-flash
+
+
+## stm32flash-lib
+
+Java library allowing to flash STM32 microcontrollers over UART.
+
+https://github.com/grevaillot/stm32flash-lib
+
+
+## GigaDevice tools
+
+GigaDevice offers the GD-Link Programmer to work with the GD-Link debug adapter,
+and GigaDevice MCU ISP Programmer.
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..356b002
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,137 @@
+
+# Changelog
+What changed in which version.
+
+
+## [0.7.0] - 2023-10-12
+
+### Added
+* Support ST BlueNRG-1 and BlueNRG-2 devices.
+* Support ST STM32H7 series devices.
+* Allow to erase specific pages of flash memory.
+* Add command-line switch to protect flash against readout.
+* Support Intel hex file format.
+* Adopt `flit` as build system.
+* Adopt `bump-my-version` as version bumper.
+
+### Fixed
+* Erasing was impossible due to --length not being supplied.
+
+### Cleaned
+* Move argument-parsing code to separate file.
+* Use long-form argument names in help text and error messages.
+* Use IntEnum for commands and responses.
+
+
+
+## [0.6.0] - 2023-10-09
+
+Yanked on 2023-10-12 due to bug when erasing. Use 0.7.0 instead.
+
+### Added
+* `#59` Continuous Integration: start running tests and linters on GitHub Actions.
+* `#42` `#43` Find flash size for non-standard MCUs (F4, L0).
+* Support STM32H7 series.
+* Packaging: auto-generate the help output using `cog`.
+* Support STM32WL.
+* Support Python 3.9 - 3.11.
+
+### Changed
+* `#46` `#48` Flush the UART read buffer after MCU reset.
+* Use argparse instead of optparse.
+* Drop support for Python 2, 3.4 - 3.8.
+
+### Fixed
+* `#44` Support flash page size higher than 255.
+* `#64` Properly parse address and length given as hexadecimal value.
+* `#62` Properly pass device family argument.
+
+### Documented
+* `#13` Describe how to extend Stm32Loader.
+* `#52` Describe alternative ways to execute the module.
+* `#58` Add a list of similar tools.
+
+
+## [0.5.1] - 2019-12-31
+* `#25` Fix bug: Mass memory erase by byq77.
+* `#28` Add support for STM32L4 by rdaforno.
+* `#29` Add support for more STM32F0 ids by stawiski .
+* `#30` Add support for STM32F3 by float32.
+* `#32` Add support for STM32G0x1 by AlexKlimaj.
+* `#33` More robust bootloader activation by hiviah.
+* `#35` Support Python 3.8
+* `#20` Add a 'read flash' example to README
+* `#34` Add --version argument
+
+
+## [0.5.0] - 2019-05-02
+* `#17` Add support for STM32F03xx4/6 by omerk.
+* Drop support for Python 3.2 and 3.3.
+
+
+## [0.4.0] - 2019-04-19
+* `#8`: Add support for STM32F7 mcus. By sam-bristow.
+* `#9`: Support data writes smaller than 256 bytes. By NINI1988.
+* `#10`: Make stm32loader useful as a library.
+* `#4`: Bring back support for progress bar.
+* `#12`: Allow to supply the serial port as an environment variable.
+* `#11`: Support paged erase in extended (two-byte addressing) erase mode.
+ Note: this is not yet tested on hardware.
+* Start using code linting and unit tests.
+* Start using Continuous Integration (Travis CI).
+
+
+## [0.3.3] - 2018-08-08
+* Bugfix: write data, not [data]. By Atokulus.
+
+
+## [0.3.2] - 2018-07-31
+* Publish on Python Package Index.
+* Make stm32loader executable as a module.
+* Expose stm32loader as a console script (stm32loader.exe on Windows).
+
+
+## [0.3.1] -- 2018-07-31
+* Make stm32loader installable and importable as a package.
+* Make write_memory faster (by Atokulus, see `#1`).
+
+
+## [0.3.0] - 2018-04-27
+* Add version number.
+* Add this changelog.
+* Improve documentation.
+* Support ST BlueNRG devices (configurable parity).
+* Add Wiznet W7500 / SweetPeas bootloader chip ID.
+* Fix ack-related bugs in (un)protect methods.
+* Add 'unprotect' command-line option.
+* Read device UID.
+* Read device flash size.
+* Refactor __main__ functionality into methods.
+
+
+## 2018-05
+* Make RTS/DTR (boot0/reset) configurable (polarity, swap).
+
+
+## 2018-04
+* Restore Python 2 compatibility.
+
+
+## 2018-03
+* Add support for Python 3.
+* Remove Psyco and progressbar support.
+* Fix checksum calculation bug for paged erase.
+
+
+## 2014-04
+* Add `-g
` (GO command).
+* Add known chip IDs.
+* Implement extended erase for STM32 F2/F4.
+
+
+## 2013-10
+* Add Windows compatibility.
+
+
+## 2009-04
+* Add GPL license.
diff --git a/DEVELOP.md b/DEVELOP.md
new file mode 100644
index 0000000..03cbc2a
--- /dev/null
+++ b/DEVELOP.md
@@ -0,0 +1,56 @@
+
+# How to contribute
+
+
+## Installation
+
+Checkout the latest master.
+
+ git clone https://github.com/florisla/stm32loader.git
+
+Install in editable mode with development tools (preferable in a virtual
+environment).
+
+ python -m venv .venv
+ .\.venv\bin\activate
+ pip uninstall stm32loader
+ pip install --editable .[dev]
+
+
+## Testing
+
+Run pytest.
+
+ pytest .
+
+
+## Linting
+
+Run flake8, pylint and black.
+
+ flake8 stm32loader
+ pylint stm32loader
+ black --check stm32loader
+
+
+## Commit messages
+
+I try to follow the 'conventional commits' commit message style;
+see https://www.conventionalcommits.org/ .
+
+
+## Bump the version number
+
+ bumpversion --new-version 1.0.8-dev bogus-part
+
+
+## Tag a release
+
+First, bump the version number to a release version.
+Then create the git tag.
+
+ git tag -a "v1.0.9" -m "release: Tag version v1.0.9"
+
+Also push it to upstream.
+
+ git push origin v1.0.9
diff --git a/EXTEND.md b/EXTEND.md
new file mode 100644
index 0000000..059d503
--- /dev/null
+++ b/EXTEND.md
@@ -0,0 +1,64 @@
+
+# Extending stm32loader
+
+You can create your own extensions on top of stm32loader's classes.
+
+
+## Example: Use Raspberry Pi GPIO pins to toggle `BOOT0` and `RESET`
+
+Subclass the `SerialConnection` and override `enable_reset` and `enable_boot0`.
+
+```python3
+
+from RPi import GPIO
+from stm32loader.uart import SerialConnection
+
+
+class RaspberrySerialWithGpio(SerialConnection):
+ # Configure which GPIO pins are connected to the STM32's BOOT0 and RESET pins.
+ BOOT0_PIN = 2
+ RESET_PIN = 3
+
+ def __init__(self, serial_port, baud_rate, parity):
+ super().__init__(serial_port, baude_rate, parity)
+
+ GPIO.setup(self.BOOT0_PIN, GPIO.OUT, initial=GPIO.LOW)
+ GPIO.setup(self.RESET_PIN, GPIO.OUT, initial=GPIO.HIGH)
+
+ def enable_reset(self, enable=True):
+ """Enable or disable the reset IO line."""
+ # Reset is active low.
+ # To enter reset, write a 0.
+ level = 1 - int(enable)
+
+ GPIO.output(self.RESET_PIN, level)
+
+ def enable_boot0(self, enable=True):
+ """Enable or disable the boot0 IO line."""
+ level = int(enable)
+
+ GPIO.output(self.BOOT0_PIN, level)
+```
+
+Connect to the UART and instantiate a Bootloader object.
+
+```python3
+
+from stm32loader.bootloader import Stm32Bootloader
+
+from raspberrystm32 import RaspberrySerialWithGpio
+
+
+connection = RaspberrySerialWithGpio("/dev/cu.usbserial-A5XK3RJT")
+connection.connect()
+stm32 = Stm32Bootloader(connection, device_family="F1")
+```
+
+Now you can use all of the Stm32Bootloader methods.
+
+```python3
+stm32.reset_from_system_memory()
+print(stm32.get_version())
+print(stm32.get_id())
+print(stm32.get_flash_size())
+```
diff --git a/COPYING3 b/LICENSE
similarity index 100%
rename from COPYING3
rename to LICENSE
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..832d124
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,7 @@
+include *.md
+include *.cfg
+include *.toml
+include LICENSE
+include .pylintrc
+include noxfile.py
+recursive-include tests *.py
diff --git a/README b/README
deleted file mode 100644
index ac096dc..0000000
--- a/README
+++ /dev/null
@@ -1,29 +0,0 @@
-STM32Loader
-===========
-
-Python script which will talk to the STM32 bootloader to upload and download firmware.
-
-Original Version by: Ivan A-R
-
-
-Usage: ./stm32loader.py [-hqVewvr] [-l length] [-p port] [-b baud] [-a addr] [file.bin]
- -h This help
- -q Quiet
- -V Verbose
- -e Erase
- -w Write
- -v Verify
- -r Read
- -l length Length of read
- -p port Serial port (default: /dev/tty.usbserial-ftCYPMYJ)
- -b baud Baud speed (default: 115200)
- -a addr Target address
-
- ./stm32loader.py -e -w -v example/main.bin
-
-
-Example:
-stm32loader.py -e -w -v somefile.bin
-
-This will pre-erase flash, write somefile.bin to the flash on the device, and then perform a verification after writing is finished.
-
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..7638924
--- /dev/null
+++ b/README.md
@@ -0,0 +1,198 @@
+# STM32Loader
+
+[![PyPI package](https://badge.fury.io/py/stm32loader.svg)](https://badge.fury.io/py/stm32loader)
+[![GitHub Actions](https://img.shields.io/github/workflow/status/florisla/stm32loader/Test?label=tests)](https://github.com/florisla/stm32loader/actions/workflows/test.yaml)
+[![GitHub Actions](https://img.shields.io/github/workflow/status/florisla/stm32loader/Lint?label=lint)](https://github.com/florisla/stm32loader/actions/workflows/lint.yaml)
+[![License](https://img.shields.io/pypi/l/stm32loader.svg)](https://pypi.org/project/stm32loader/)
+[![Downloads](https://pepy.tech/badge/stm32loader)](https://pepy.tech/project/stm32loader)
+
+Python module to upload or download firmware to / from
+ST Microelectronics STM32 microcontrollers over UART.
+
+Also supports ST BlueNRG devices, and the SweetPeas bootloader
+for Wiznet W7500.
+
+Compatible with Python version 3.9 to 3.11 and PyPy 3.9.
+
+
+## Installation
+
+ pip install stm32loader
+
+To install the latest development version:
+
+ pip install git+https://github.com/florisla/stm32loader.git
+
+
+## Usage
+
+
+```
+usage: stm32loader [-h] [-e] [-u] [-x] [-w] [-v] [-r] [-l LENGTH] -p PORT [-b BAUD] [-a ADDRESS] [-g ADDRESS] [-f FAMILY]
+ [-V] [-q] [-s] [-R] [-B] [-n] [-P {even,none}] [--version]
+ [FILE.BIN]
+
+Flash firmware to STM32 microcontrollers.
+
+positional arguments:
+ FILE.BIN File to read from or store to flash.
+
+options:
+ -h, --help show this help message and exit
+ -e, --erase Erase (note: this is required on previously written memory).
+ -u, --unprotect Unprotect flash from readout.
+ -x, --protect Protect flash against readout.
+ -w, --write Write file content to flash.
+ -v, --verify Verify flash content versus local file (recommended).
+ -r, --read Read from flash and store in local file.
+ -l LENGTH, --length LENGTH
+ Length of read or erase.
+ -p PORT, --port PORT Serial port (default: $STM32LOADER_SERIAL_PORT).
+ -b BAUD, --baud BAUD Baudrate. (default: 115200)
+ -a ADDRESS, --address ADDRESS
+ Target address for read, write or erase. (default: 134217728)
+ -g ADDRESS, --go-address ADDRESS
+ Start executing from address (0x08000000, usually).
+ -f FAMILY, --family FAMILY
+ Device family to read out device UID and flash size; e.g F1 for STM32F1xx. Possible values: F0,
+ F1, F3, F4, F7, H7, L4, L0, G0, NRG. (default: $STM32LOADER_FAMILY).
+ -V, --verbose Verbose mode.
+ -q, --quiet Quiet mode.
+ -s, --swap-rts-dtr Swap RTS and DTR: use RTS for reset and DTR for boot0.
+ -R, --reset-active-high
+ Make RESET active high.
+ -B, --boot0-active-low
+ Make BOOT0 active low.
+ -n, --no-progress Don't show progress bar.
+ -P {even,none}, --parity {even,none}
+ Parity: "even" for STM32, "none" for BlueNRG. (default: even)
+ --version show program's version number and exit
+
+examples:
+ stm32loader --port COM7 --family F1
+ stm32loader --erase --write --verify example/main.bin
+```
+
+
+## Command-line example
+
+```
+stm32loader --port /dev/tty.usbserial-ftCYPMYJ --erase --write --verify somefile.bin
+```
+
+This will pre-erase flash, write `somefile.bin` to the flash on the device, and then
+perform a verification after writing is finished.
+
+You can skip the `--port` option by configuring environment variable
+`STM32LOADER_SERIAL_PORT`.
+Similarly, `--family` may be supplied through `STM32LOADER_FAMILY`.
+
+To read out firmware and store it in a file:
+
+```
+stm32loader --read --port /dev/cu.usbserial-A5XK3RJT --family F1 --length 0x10000 --address 0x08000000 dump.bin
+```
+
+
+To erase the full device:
+
+```
+stm32loader --erase --port /dev/cu.usbserial-A5XK3RJT
+```
+
+Or erase only a specific region of the flash:
+
+```
+stm32loader --erase --address 0x08000000 --length 0x2000 --port /dev/cu.usbserial-A5XK3RJT
+```
+
+
+
+## Reference documents
+
+* ST `AN2606`: STM32 microcontroller system memory boot mode
+* ST `AN3155`: USART protocol used in the STM32 bootloader
+* ST `AN4872`: BlueNRG-1 and BlueNRG-2 UART bootloader protocol
+
+
+## Acknowledgement
+
+Original Version by Ivan A-R (tuxotronic.org).
+Contributions by Domen Puncer, James Snyder, Floris Lambrechts,
+Atokulus, sam-bristow, NINI1988, Omer Kilic, Szymon Szantula, rdaforno,
+Mikolaj Stawiski, Tyler Coy, Alex Klimaj, Ondrej Mikle, denniszollo,
+emilzay, michsens, blueskull, Mattia Maldini, etrommer, jadeaffenjaeger,
+tosmaz.
+
+Inspiration for features from:
+
+* Configurable RTS/DTR and polarity, extended erase with pages:
+ https://github.com/pazzarpj/stm32loader
+
+* Memory unprotect
+ https://github.com/3drobotics/stm32loader
+
+* Correct checksum calculation for paged erase:
+ https://github.com/jsnyder/stm32loader/pull/4
+
+* ST BlueNRG chip support
+ https://github.com/lchish/stm32loader
+
+* Wiznet W7500 chip / SweetPeas custom bootloader support
+ https://github.com/Sweet-Peas/WiznetLoader
+
+
+## Alternatives
+
+If you don't need the flexibility of a Python tool, you can take
+a look at other similar tools in `ALTERNATIVES.md`.
+
+
+## Electrically
+
+The below assumes you are connecting an STM32F10x.
+For other chips, the serial pins and/or the BOOT0 / BOOT1 values
+may differ.
+
+Make the following connections:
+
+- Serial adapter `GND` to MCU `GND`.
+- Serial adapter power to MCU power or vice versa (either 3.3 or 5 Volt).
+- Note if you're using 5 Volt signaling or 3V3 on the serial adapter.
+- Serial `TX` to MCU `RX` (`PA10`).
+- Serial `RX` to MCU `TX` (`PA9`).
+- Serial `DTR` to MCU `RESET`.
+- Serial `RTS` to MCU `BOOT0` (or `BOOT0` to 3.3V).
+- MCU `BOOT1` to `GND`.
+
+If either `RTS` or `DTR` are not available on your serial adapter, you'll have to
+manually push buttons or work with jumpers.
+When given a choice, set `BOOT0` manually high and drive `RESET` through the serial
+adapter (it needs to toggle, whereas `BOOT0` does not).
+
+
+## Not currently supported
+
+* Command-line argument for write protection/unprotection.
+* STM8 devices (ST `UM0560`).
+* Other bootloader protocols (e.g. I2C, HEX -> implemented in `stm32flash`).
+
+
+## Future work
+
+* Use f-strings.
+* Use proper logging instead of print statements.
diff --git a/RUN.md b/RUN.md
new file mode 100644
index 0000000..f33ec11
--- /dev/null
+++ b/RUN.md
@@ -0,0 +1,52 @@
+
+# Running stm32loader
+
+
+## Execute as a module
+
+After installing stm32loader with `pip`, it's available as a Python module.
+
+You can execute this with `python -m [modulename]`.
+
+```shell
+python3 -m stm32loader
+```
+
+
+## Execute as a module without installing
+
+You can also run `stm32loader` without installing it. You do need `pyserial` though.
+
+Make sure you are in the root of the repository, or the repository is in `PYTHONPATH`.
+
+```shell
+python3 -m pip install pyserial --user
+python3 -m stm32loader
+```
+
+
+## Execute main.py directly
+
+The file `main.py` also runs the `stm32loader` program when executed.
+Make sure the module can be found; add the folder of the repository to `PYTHONPATH`.
+
+```shell
+PYTHONPATH=. python3 stm32loader/main.py
+```
+
+
+## Use from Python
+
+You can use the classes of `stm32loader` from a Python script.
+
+Example:
+
+```python
+from stm32loader.main import Stm32Loader
+
+loader = Stm32Loader()
+loader.configuration.port = "/dev/cu.usbserial-A5XK3RJT"
+loader.connect()
+loader.stm32.readout_unprotect()
+loader.disconnect()
+```
diff --git a/firmware/generic_boot20_pc13.binary.bin b/firmware/generic_boot20_pc13.binary.bin
new file mode 100644
index 0000000..e69de29
diff --git a/noxfile.py b/noxfile.py
new file mode 100644
index 0000000..4165c20
--- /dev/null
+++ b/noxfile.py
@@ -0,0 +1,49 @@
+"""Run unit tests in a fresh virtualenv using nox."""
+
+from shutil import rmtree
+
+import nox
+
+
+DEFAULT_PYTHON_VERSION = "3.11"
+ALL_PYTHON_VERSIONS = ["3.9", "3.10", "3.11"]
+
+
+@nox.session(python=ALL_PYTHON_VERSIONS)
+def tests(session):
+ """
+ Install stm32loader package and execute unit tests.
+
+ Use chdir to move off of the current folder, so that
+ 'import stm32loader' imports the *installed* package, not
+ the local one from the repo.
+ """
+ # setuptools does not like multiple .whl packages being present
+ # see https://github.com/pypa/setuptools/issues/1671
+ rmtree("./dist", ignore_errors=True)
+ session.install(".")
+ session.install("intelhex")
+ session.install("pytest")
+ session.chdir("tests")
+ session.run("pytest", "./")
+
+
+@nox.session(python=DEFAULT_PYTHON_VERSION)
+def lint(session):
+ """
+ Run code verification tools flake8, pylint and black.
+
+ Do this in order of expected failures for performance reasons.
+ """
+ session.install("black")
+ session.run("black", "--check", "stm32loader")
+
+ session.install("pylint")
+ # pyserial for avoiding a complaint by pylint
+ session.install("pyserial")
+ session.install("intelhex")
+ session.run("pylint", "stm32loader")
+
+ session.install("flake8", "flake8-isort")
+ # not sure why this needs an explicit --config
+ session.run("flake8", "stm32loader", "--config=setup.cfg")
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..1aa6eaf
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,116 @@
+[build-system]
+requires = ["flit_core >=3.2,<4"]
+build-backend = "flit_core.buildapi"
+
+[project]
+name = "stm32loader"
+authors = [
+ {name = "jsnyder"},
+ {name = "Floris Lambrechts", email = "florisla@gmail.com"},
+]
+readme = "README.md"
+description = "Flash firmware to STM32 microcontrollers using Python."
+license = {file = "LICENSE"}
+requires-python = ">=3.9"
+dependencies = [
+ "pyserial",
+ "progress",
+]
+classifiers = [
+ "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
+ "Programming Language :: Python",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.9",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: Implementation :: CPython",
+ "Programming Language :: Python :: Implementation :: PyPy",
+ "Natural Language :: English",
+ "Operating System :: OS Independent",
+]
+dynamic = ["version"]
+
+[project.optional-dependencies]
+hex = [
+ "intelhex",
+]
+dev = [
+ "wheel",
+ "twine",
+ "pylint",
+ "flake8",
+ "flake8-isort",
+ "black",
+ "bump-my-version",
+ "nox",
+ "cogapp",
+]
+
+[project.scripts]
+stm32loader = "stm32loader.__main__:main"
+
+[project.urls]
+Home = "https://github.com/florisla/stm32loader"
+BugTracker = "https://github.com/florisla/stm32loader/issues"
+SourceCode = "https://github.com/florisla/stm32loader"
+
+
+[tool.bumpversion]
+current_version = "0.7.1-dev0"
+commit = true
+tag = true
+message = "release: Bump version number from v{current_version} to v{new_version}"
+parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(-(?P[^\\d]+)(?P\\d+))?"
+serialize = [
+ "{major}.{minor}.{patch}-{release}{devrelease}",
+ "{major}.{minor}.{patch}",
+]
+
+[tool.bumpversion.parts.release]
+optional_value = "release"
+values = [
+ "dev",
+ "release",
+]
+
+[[tool.bumpversion.files]]
+filename = "stm32loader/__init__.py"
+parse = "\\((?P\\d+),\\s(?P\\d+),\\s(?P\\d+)(\\s*,\\s*\"(?P[^\"]+)\"\\s*,\\s*(?P\\d+))?\\)"
+serialize = [
+ "({major}, {minor}, {patch}, \"{release}\", {devrelease})",
+ "({major}, {minor}, {patch})",
+]
+search = "__version_info__ = {current_version}"
+replace = "__version_info__ = {new_version}"
+
+
+[tool.black]
+line-length = 98
+target-version = [
+ "py39",
+ "py310",
+ "py311",
+]
+exclude = '''
+/(
+ \.git
+ | \.idea
+ | __pycache__
+ | build
+ | dist
+ | .*\.egg-info
+)/
+'''
+
+
+[tool.pytest.ini_options]
+addopts = "--strict-markers -m 'not (hardware or hardware_missing)'"
+markers = [
+ "hardware",
+ "missing_hardware",
+]
+
+
+[tool.isort]
+line_length = 98
+multi_line_output = 2
diff --git a/stm32loader.py b/stm32loader.py
deleted file mode 100755
index 95adc05..0000000
--- a/stm32loader.py
+++ /dev/null
@@ -1,475 +0,0 @@
-#!/usr/bin/env python
-
-# -*- coding: utf-8 -*-
-# vim: sw=4:ts=4:si:et:enc=utf-8
-
-# Author: Ivan A-R
-# Project page: http://tuxotronic.org/wiki/projects/stm32loader
-#
-# This file is part of stm32loader.
-#
-# stm32loader is free software; you can redistribute it and/or modify it under
-# the terms of the GNU General Public License as published by the Free
-# Software Foundation; either version 3, or (at your option) any later
-# version.
-#
-# stm32loader is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or
-# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
-# for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with stm32loader; see the file COPYING3. If not see
-# .
-
-import sys, getopt
-import serial
-import time
-
-try:
- from progressbar import *
- usepbar = 1
-except:
- usepbar = 0
-
-# Verbose level
-QUIET = 20
-
-# these come from AN2606
-chip_ids = {
- 0x412: "STM32 Low-density",
- 0x410: "STM32 Medium-density",
- 0x414: "STM32 High-density",
- 0x420: "STM32 Medium-density value line",
- 0x428: "STM32 High-density value line",
- 0x430: "STM32 XL-density",
- 0x416: "STM32 Medium-density ultralow power line",
- 0x411: "STM32F2xx",
- 0x413: "STM32F4xx",
-}
-
-def mdebug(level, message):
- if(QUIET >= level):
- print >> sys.stderr , message
-
-
-class CmdException(Exception):
- pass
-
-class CommandInterface:
- extended_erase = 0
-
- def open(self, aport='/dev/tty.usbserial-ftCYPMYJ', abaudrate=115200) :
- self.sp = serial.Serial(
- port=aport,
- baudrate=abaudrate, # baudrate
- bytesize=8, # number of databits
- parity=serial.PARITY_EVEN,
- stopbits=1,
- xonxoff=0, # don't enable software flow control
- rtscts=0, # don't enable RTS/CTS flow control
- timeout=5 # set a timeout value, None for waiting forever
- )
-
-
- def _wait_for_ask(self, info = ""):
- # wait for ask
- try:
- ask = ord(self.sp.read())
- except:
- raise CmdException("Can't read port or timeout")
- else:
- if ask == 0x79:
- # ACK
- return 1
- else:
- if ask == 0x1F:
- # NACK
- raise CmdException("NACK "+info)
- else:
- # Unknown responce
- raise CmdException("Unknown response. "+info+": "+hex(ask))
-
-
- def reset(self):
- self.sp.setDTR(0)
- time.sleep(0.1)
- self.sp.setDTR(1)
- time.sleep(0.5)
-
- def initChip(self):
- # Set boot
- self.sp.setRTS(0)
- self.reset()
-
- self.sp.write("\x7F") # Syncro
- return self._wait_for_ask("Syncro")
-
- def releaseChip(self):
- self.sp.setRTS(1)
- self.reset()
-
- def cmdGeneric(self, cmd):
- self.sp.write(chr(cmd))
- self.sp.write(chr(cmd ^ 0xFF)) # Control byte
- return self._wait_for_ask(hex(cmd))
-
- def cmdGet(self):
- if self.cmdGeneric(0x00):
- mdebug(10, "*** Get command");
- len = ord(self.sp.read())
- version = ord(self.sp.read())
- mdebug(10, " Bootloader version: "+hex(version))
- dat = map(lambda c: hex(ord(c)), self.sp.read(len))
- if '0x44' in dat:
- self.extended_erase = 1
- mdebug(10, " Available commands: "+", ".join(dat))
- self._wait_for_ask("0x00 end")
- return version
- else:
- raise CmdException("Get (0x00) failed")
-
- def cmdGetVersion(self):
- if self.cmdGeneric(0x01):
- mdebug(10, "*** GetVersion command")
- version = ord(self.sp.read())
- self.sp.read(2)
- self._wait_for_ask("0x01 end")
- mdebug(10, " Bootloader version: "+hex(version))
- return version
- else:
- raise CmdException("GetVersion (0x01) failed")
-
- def cmdGetID(self):
- if self.cmdGeneric(0x02):
- mdebug(10, "*** GetID command")
- len = ord(self.sp.read())
- id = self.sp.read(len+1)
- self._wait_for_ask("0x02 end")
- return reduce(lambda x, y: x*0x100+y, map(ord, id))
- else:
- raise CmdException("GetID (0x02) failed")
-
-
- def _encode_addr(self, addr):
- byte3 = (addr >> 0) & 0xFF
- byte2 = (addr >> 8) & 0xFF
- byte1 = (addr >> 16) & 0xFF
- byte0 = (addr >> 24) & 0xFF
- crc = byte0 ^ byte1 ^ byte2 ^ byte3
- return (chr(byte0) + chr(byte1) + chr(byte2) + chr(byte3) + chr(crc))
-
-
- def cmdReadMemory(self, addr, lng):
- assert(lng <= 256)
- if self.cmdGeneric(0x11):
- mdebug(10, "*** ReadMemory command")
- self.sp.write(self._encode_addr(addr))
- self._wait_for_ask("0x11 address failed")
- N = (lng - 1) & 0xFF
- crc = N ^ 0xFF
- self.sp.write(chr(N) + chr(crc))
- self._wait_for_ask("0x11 length failed")
- return map(lambda c: ord(c), self.sp.read(lng))
- else:
- raise CmdException("ReadMemory (0x11) failed")
-
-
- def cmdGo(self, addr):
- if self.cmdGeneric(0x21):
- mdebug(10, "*** Go command")
- self.sp.write(self._encode_addr(addr))
- self._wait_for_ask("0x21 go failed")
- else:
- raise CmdException("Go (0x21) failed")
-
-
- def cmdWriteMemory(self, addr, data):
- assert(len(data) <= 256)
- if self.cmdGeneric(0x31):
- mdebug(10, "*** Write memory command")
- self.sp.write(self._encode_addr(addr))
- self._wait_for_ask("0x31 address failed")
- #map(lambda c: hex(ord(c)), data)
- lng = (len(data)-1) & 0xFF
- mdebug(10, " %s bytes to write" % [lng+1]);
- self.sp.write(chr(lng)) # len really
- crc = 0xFF
- for c in data:
- crc = crc ^ c
- self.sp.write(chr(c))
- self.sp.write(chr(crc))
- self._wait_for_ask("0x31 programming failed")
- mdebug(10, " Write memory done")
- else:
- raise CmdException("Write memory (0x31) failed")
-
-
- def cmdEraseMemory(self, sectors = None):
- if self.extended_erase:
- return cmd.cmdExtendedEraseMemory()
-
- if self.cmdGeneric(0x43):
- mdebug(10, "*** Erase memory command")
- if sectors is None:
- # Global erase
- self.sp.write(chr(0xFF))
- self.sp.write(chr(0x00))
- else:
- # Sectors erase
- self.sp.write(chr((len(sectors)-1) & 0xFF))
- crc = 0xFF
- for c in sectors:
- crc = crc ^ c
- self.sp.write(chr(c))
- self.sp.write(chr(crc))
- self._wait_for_ask("0x43 erasing failed")
- mdebug(10, " Erase memory done")
- else:
- raise CmdException("Erase memory (0x43) failed")
-
- def cmdExtendedEraseMemory(self):
- if self.cmdGeneric(0x44):
- mdebug(10, "*** Extended Erase memory command")
- # Global mass erase
- self.sp.write(chr(0xFF))
- self.sp.write(chr(0xFF))
- # Checksum
- self.sp.write(chr(0x00))
- tmp = self.sp.timeout
- self.sp.timeout = 30
- print "Extended erase (0x44), this can take ten seconds or more"
- self._wait_for_ask("0x44 erasing failed")
- self.sp.timeout = tmp
- mdebug(10, " Extended Erase memory done")
- else:
- raise CmdException("Extended Erase memory (0x44) failed")
-
- def cmdWriteProtect(self, sectors):
- if self.cmdGeneric(0x63):
- mdebug(10, "*** Write protect command")
- self.sp.write(chr((len(sectors)-1) & 0xFF))
- crc = 0xFF
- for c in sectors:
- crc = crc ^ c
- self.sp.write(chr(c))
- self.sp.write(chr(crc))
- self._wait_for_ask("0x63 write protect failed")
- mdebug(10, " Write protect done")
- else:
- raise CmdException("Write Protect memory (0x63) failed")
-
- def cmdWriteUnprotect(self):
- if self.cmdGeneric(0x73):
- mdebug(10, "*** Write Unprotect command")
- self._wait_for_ask("0x73 write unprotect failed")
- self._wait_for_ask("0x73 write unprotect 2 failed")
- mdebug(10, " Write Unprotect done")
- else:
- raise CmdException("Write Unprotect (0x73) failed")
-
- def cmdReadoutProtect(self):
- if self.cmdGeneric(0x82):
- mdebug(10, "*** Readout protect command")
- self._wait_for_ask("0x82 readout protect failed")
- self._wait_for_ask("0x82 readout protect 2 failed")
- mdebug(10, " Read protect done")
- else:
- raise CmdException("Readout protect (0x82) failed")
-
- def cmdReadoutUnprotect(self):
- if self.cmdGeneric(0x92):
- mdebug(10, "*** Readout Unprotect command")
- self._wait_for_ask("0x92 readout unprotect failed")
- self._wait_for_ask("0x92 readout unprotect 2 failed")
- mdebug(10, " Read Unprotect done")
- else:
- raise CmdException("Readout unprotect (0x92) failed")
-
-
-# Complex commands section
-
- def readMemory(self, addr, lng):
- data = []
- if usepbar:
- widgets = ['Reading: ', Percentage(),', ', ETA(), ' ', Bar()]
- pbar = ProgressBar(widgets=widgets,maxval=lng, term_width=79).start()
-
- while lng > 256:
- if usepbar:
- pbar.update(pbar.maxval-lng)
- else:
- mdebug(5, "Read %(len)d bytes at 0x%(addr)X" % {'addr': addr, 'len': 256})
- data = data + self.cmdReadMemory(addr, 256)
- addr = addr + 256
- lng = lng - 256
- if usepbar:
- pbar.update(pbar.maxval-lng)
- pbar.finish()
- else:
- mdebug(5, "Read %(len)d bytes at 0x%(addr)X" % {'addr': addr, 'len': 256})
- data = data + self.cmdReadMemory(addr, lng)
- return data
-
- def writeMemory(self, addr, data):
- lng = len(data)
- if usepbar:
- widgets = ['Writing: ', Percentage(),' ', ETA(), ' ', Bar()]
- pbar = ProgressBar(widgets=widgets, maxval=lng, term_width=79).start()
-
- offs = 0
- while lng > 256:
- if usepbar:
- pbar.update(pbar.maxval-lng)
- else:
- mdebug(5, "Write %(len)d bytes at 0x%(addr)X" % {'addr': addr, 'len': 256})
- self.cmdWriteMemory(addr, data[offs:offs+256])
- offs = offs + 256
- addr = addr + 256
- lng = lng - 256
- if usepbar:
- pbar.update(pbar.maxval-lng)
- pbar.finish()
- else:
- mdebug(5, "Write %(len)d bytes at 0x%(addr)X" % {'addr': addr, 'len': 256})
- self.cmdWriteMemory(addr, data[offs:offs+lng] + ([0xFF] * (256-lng)) )
-
-
-
-
- def __init__(self) :
- pass
-
-
-def usage():
- print """Usage: %s [-hqVewvr] [-l length] [-p port] [-b baud] [-a addr] [-g addr] [file.bin]
- -h This help
- -q Quiet
- -V Verbose
- -e Erase
- -w Write
- -v Verify
- -r Read
- -l length Length of read
- -p port Serial port (default: /dev/tty.usbserial-ftCYPMYJ)
- -b baud Baud speed (default: 115200)
- -a addr Target address
- -g addr Address to start running at (0x08000000, usually)
-
- ./stm32loader.py -e -w -v example/main.bin
-
- """ % sys.argv[0]
-
-
-if __name__ == "__main__":
-
- # Import Psyco if available
- try:
- import psyco
- psyco.full()
- print "Using Psyco..."
- except ImportError:
- pass
-
- conf = {
- 'port': '/dev/tty.usbserial-ftCYPMYJ',
- 'baud': 115200,
- 'address': 0x08000000,
- 'erase': 0,
- 'write': 0,
- 'verify': 0,
- 'read': 0,
- 'go_addr':-1,
- }
-
-# http://www.python.org/doc/2.5.2/lib/module-getopt.html
-
- try:
- opts, args = getopt.getopt(sys.argv[1:], "hqVewvrp:b:a:l:g:")
- except getopt.GetoptError, err:
- # print help information and exit:
- print str(err) # will print something like "option -a not recognized"
- usage()
- sys.exit(2)
-
- QUIET = 5
-
- for o, a in opts:
- if o == '-V':
- QUIET = 10
- elif o == '-q':
- QUIET = 0
- elif o == '-h':
- usage()
- sys.exit(0)
- elif o == '-e':
- conf['erase'] = 1
- elif o == '-w':
- conf['write'] = 1
- elif o == '-v':
- conf['verify'] = 1
- elif o == '-r':
- conf['read'] = 1
- elif o == '-p':
- conf['port'] = a
- elif o == '-b':
- conf['baud'] = eval(a)
- elif o == '-a':
- conf['address'] = eval(a)
- elif o == '-g':
- conf['go_addr'] = eval(a)
- elif o == '-l':
- conf['len'] = eval(a)
- else:
- assert False, "unhandled option"
-
- cmd = CommandInterface()
- cmd.open(conf['port'], conf['baud'])
- mdebug(10, "Open port %(port)s, baud %(baud)d" % {'port':conf['port'], 'baud':conf['baud']})
- try:
- try:
- cmd.initChip()
- except:
- print "Can't init. Ensure that BOOT0 is enabled and reset device"
-
-
- bootversion = cmd.cmdGet()
- mdebug(0, "Bootloader version %X" % bootversion)
- id = cmd.cmdGetID()
- mdebug(0, "Chip id: 0x%x (%s)" % (id, chip_ids.get(id, "Unknown")))
-# cmd.cmdGetVersion()
-# cmd.cmdGetID()
-# cmd.cmdReadoutUnprotect()
-# cmd.cmdWriteUnprotect()
-# cmd.cmdWriteProtect([0, 1])
-
- if (conf['write'] or conf['verify']):
- data = map(lambda c: ord(c), file(args[0], 'rb').read())
-
- if conf['erase']:
- cmd.cmdEraseMemory()
-
- if conf['write']:
- cmd.writeMemory(conf['address'], data)
-
- if conf['verify']:
- verify = cmd.readMemory(conf['address'], len(data))
- if(data == verify):
- print "Verification OK"
- else:
- print "Verification FAILED"
- print str(len(data)) + ' vs ' + str(len(verify))
- for i in xrange(0, len(data)):
- if data[i] != verify[i]:
- print hex(i) + ': ' + hex(data[i]) + ' vs ' + hex(verify[i])
-
- if not conf['write'] and conf['read']:
- rdata = cmd.readMemory(conf['address'], conf['len'])
- file(args[0], 'wb').write(''.join(map(chr,rdata)))
-
- if conf['go_addr'] != -1:
- cmd.cmdGo(conf['go_addr'])
-
- finally:
- cmd.releaseChip()
-
diff --git a/stm32loader/.gitignore b/stm32loader/.gitignore
new file mode 100644
index 0000000..6937180
--- /dev/null
+++ b/stm32loader/.gitignore
@@ -0,0 +1 @@
+.vscode/settings.json
\ No newline at end of file
diff --git a/stm32loader/__init__.py b/stm32loader/__init__.py
new file mode 100644
index 0000000..37bb766
--- /dev/null
+++ b/stm32loader/__init__.py
@@ -0,0 +1,4 @@
+"""Flash firmware to STM32 microcontrollers over a serial connection."""
+
+__version_info__ = (0, 7, 1, "dev", 0)
+__version__ = "-".join(str(part) for part in __version_info__).replace("-", ".", 2)
diff --git a/stm32loader/__main__.py b/stm32loader/__main__.py
new file mode 100644
index 0000000..664665d
--- /dev/null
+++ b/stm32loader/__main__.py
@@ -0,0 +1,41 @@
+# Author: Floris Lambrechts
+# GitHub repository: https://github.com/florisla/stm32loader
+#
+# This file is part of stm32loader.
+#
+# stm32loader is free software; you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free
+# Software Foundation; either version 3, or (at your option) any later
+# version.
+#
+# stm32loader is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+# for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with stm32loader; see the file LICENSE. If not see
+# .
+"""
+Execute stm32loader as a module.
+
+This does exactly the same as manually calling 'python stm32loader.py'.
+"""
+
+import sys
+
+from stm32loader.main import main as stm32loader_main
+
+
+def main():
+ """
+ Separate main method, different from stm32loader.main.
+
+ This way it it can be used as an entry point for a console script.
+ :return None:
+ """
+ stm32loader_main(*sys.argv[1:])
+
+
+if __name__ == "__main__":
+ main()
diff --git a/stm32loader/args.py b/stm32loader/args.py
new file mode 100644
index 0000000..893e3f5
--- /dev/null
+++ b/stm32loader/args.py
@@ -0,0 +1,232 @@
+"""Parse command-line arguments."""
+
+import argparse
+import atexit
+import copy
+import os
+import sys
+
+from stm32loader import __version__
+
+
+DEFAULT_VERBOSITY = 5
+
+
+class HelpFormatter(argparse.RawDescriptionHelpFormatter, argparse.ArgumentDefaultsHelpFormatter):
+ """Custom help formatter -- don't print confusing default values."""
+
+ def _get_help_string(self, action):
+ action = copy.copy(action)
+ # Don't show "(default: None)" for arguments without defaults,
+ # or "(default: False)" for boolean flags, and hide the
+ # (default: 5) from --verbose's help because it's confusing.
+ if not action.default or action.dest == "verbosity":
+ action.default = argparse.SUPPRESS
+ return super()._get_help_string(action)
+
+ def _format_actions_usage(self, actions, groups):
+ # Always treat -p/--port as required. See the note about the
+ # argparse hack in Stm32Loader.parse_arguments for why.
+ def tweak_action(action):
+ action = copy.copy(action)
+ if action.dest == "port":
+ action.required = True
+ return action
+
+ return super()._format_actions_usage(map(tweak_action, actions), groups)
+
+
+def _auto_int(x):
+ """Convert to int with automatic base detection."""
+ # This supports 0x10 == 16 and 10 == 10
+ return int(x, 0)
+
+
+def parse_arguments(arguments):
+ """Parse the given command-line arguments and return the configuration."""
+
+ parser = argparse.ArgumentParser(
+ prog="stm32loader",
+ description="Flash firmware to STM32 microcontrollers.",
+ epilog="\n".join(
+ [
+ "examples:",
+ " %(prog)s --port COM7 --family F1",
+ " %(prog)s --erase --write --verify example/main.bin",
+ ]
+ ),
+ formatter_class=HelpFormatter,
+ )
+
+ data_file_arg = parser.add_argument(
+ "data_file",
+ metavar="FILE.BIN",
+ type=str,
+ nargs="?",
+ help="File to read from or store to flash.",
+ )
+
+ parser.add_argument(
+ "-e",
+ "--erase",
+ action="store_true",
+ help=(
+ "Erase the full flash memory or a specific region (support --address and --length)."
+ " Note: this is required on previously written memory."
+ ),
+ )
+
+ parser.add_argument(
+ "-u", "--unprotect", action="store_true", help="Unprotect flash from readout."
+ )
+
+ parser.add_argument(
+ "-x", "--protect", action="store_true", help="Protect flash against readout."
+ )
+
+ parser.add_argument("-w", "--write", action="store_true", help="Write file content to flash.")
+
+ parser.add_argument(
+ "-v",
+ "--verify",
+ action="store_true",
+ help="Verify flash content versus local file (recommended).",
+ )
+
+ parser.add_argument(
+ "-r", "--read", action="store_true", help="Read from flash and store in local file."
+ )
+
+ length_arg = parser.add_argument(
+ "-l", "--length", action="store", type=_auto_int, help="Length of read or erase."
+ )
+
+ default_port = os.environ.get("STM32LOADER_SERIAL_PORT")
+ port_arg = parser.add_argument(
+ "-p",
+ "--port",
+ action="store",
+ type=str, # morally required=True
+ default=default_port,
+ help=("Serial port" + ("." if default_port else " (default: $STM32LOADER_SERIAL_PORT).")),
+ )
+
+ parser.add_argument(
+ "-b", "--baud", action="store", type=int, default=115200, help="Baudrate."
+ )
+
+ address_arg = parser.add_argument(
+ "-a",
+ "--address",
+ action="store",
+ type=_auto_int,
+ default=0x08000000,
+ help=(
+ "Target address for read or write. For erase, this is used when you supply --length."
+ ),
+ )
+
+ parser.add_argument(
+ "-g",
+ "--go-address",
+ action="store",
+ type=_auto_int,
+ metavar="ADDRESS",
+ help="Start executing from address (0x08000000, usually).",
+ )
+
+ default_family = os.environ.get("STM32LOADER_FAMILY")
+ parser.add_argument(
+ "-f",
+ "--family",
+ action="store",
+ type=str,
+ default=default_family,
+ help=(
+ "Device family to read out device UID and flash size; "
+ "e.g F1 for STM32F1xx. Possible values: F0, F1, F3, F4, F7, H7, L4, L0, G0, NRG."
+ + ("." if default_family else " (default: $STM32LOADER_FAMILY).")
+ ),
+ )
+
+ parser.add_argument(
+ "-V",
+ "--verbose",
+ dest="verbosity",
+ action="store_const",
+ const=10,
+ default=DEFAULT_VERBOSITY,
+ help="Verbose mode.",
+ )
+
+ parser.add_argument(
+ "-q", "--quiet", dest="verbosity", action="store_const", const=0, help="Quiet mode."
+ )
+
+ parser.add_argument(
+ "-s",
+ "--swap-rts-dtr",
+ action="store_true",
+ help="Swap RTS and DTR: use RTS for reset and DTR for boot0.",
+ )
+
+ parser.add_argument(
+ "-R", "--reset-active-high", action="store_true", help="Make RESET active high."
+ )
+
+ parser.add_argument(
+ "-B", "--boot0-active-low", action="store_true", help="Make BOOT0 active low."
+ )
+
+ parser.add_argument(
+ "-n", "--no-progress", action="store_true", help="Don't show progress bar."
+ )
+
+ parser.add_argument(
+ "-P",
+ "--parity",
+ action="store",
+ type=str,
+ default="even",
+ choices=["even", "none"],
+ help='Parity: "even" for STM32, "none" for BlueNRG.',
+ )
+
+ parser.add_argument("--version", action="version", version=__version__)
+
+ # Hack: We want certain arguments to be required when one
+ # of -rwv is specified, but argparse doesn't support
+ # conditional dependencies like that. Instead, we add the
+ # requirements post-facto and re-run the parse to get the error
+ # messages we want. A better solution would be to use
+ # subcommands instead of options for -rwv, but this would
+ # change the command-line interface.
+ #
+ # We also use this gross hack to provide a hint about the
+ # STM32LOADER_SERIAL_PORT environment variable when -p
+ # is omitted; we only set --port as required after the first
+ # parse so we can hook in a custom error message.
+
+ configuration = parser.parse_args(arguments)
+
+ if not configuration.port:
+ port_arg.required = True
+ atexit.register(
+ lambda: print(
+ "{}: note: you can also set the environment "
+ "variable STM32LOADER_SERIAL_PORT".format(parser.prog),
+ file=sys.stderr,
+ )
+ )
+
+ if configuration.read or configuration.write or configuration.verify:
+ data_file_arg.nargs = None
+ data_file_arg.required = True
+
+ if configuration.read:
+ length_arg.required = True
+ address_arg.required = True
+
+ parser.parse_args(arguments)
+
+ return configuration
diff --git a/stm32loader/bootloader.py b/stm32loader/bootloader.py
new file mode 100644
index 0000000..fa9cc01
--- /dev/null
+++ b/stm32loader/bootloader.py
@@ -0,0 +1,848 @@
+# Authors: Ivan A-R, Floris Lambrechts
+# GitHub repository: https://github.com/florisla/stm32loader
+#
+# This file is part of stm32loader.
+#
+# stm32loader is free software; you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free
+# Software Foundation; either version 3, or (at your option) any later
+# version.
+#
+# stm32loader is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+# for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with stm32loader; see the file LICENSE. If not see
+# .
+
+"""Talk to an STM32 native bootloader (see ST AN3155)."""
+
+
+import enum
+import math
+import operator
+import struct
+import sys
+import time
+from functools import reduce
+
+CHIP_IDS = {
+ # see ST AN2606 Table 136 Bootloader device-dependent parameters
+ # 16 to 32 KiB
+ 0x412: "STM32F10x Low-density",
+ 0x444: "STM32F03xx4/6",
+ # 64 to 128 KiB
+ 0x410: "STM32F10x Medium-density",
+ 0x420: "STM32F10x Medium-density value line",
+ 0x460: "STM32G0x1",
+ # 256 to 512 KiB (5128 Kbyte is probably a typo?)
+ 0x414: "STM32F10x High-density",
+ 0x428: "STM32F10x High-density value line",
+ # 768 to 1024 KiB
+ 0x430: "STM3210xx XL-density",
+ # flash size to be looked up
+ 0x417: "STM32L05xxx/06xxx",
+ 0x416: "STM32L1xxx6(8/B) Medium-density ultralow power line",
+ 0x411: "STM32F2xxx",
+ 0x433: "STM32F4xxD/E",
+ # STM32F3
+ 0x432: "STM32F373xx/378xx",
+ 0x422: "STM32F302xB(C)/303xB(C)/358xx",
+ 0x439: "STM32F301xx/302x4(6/8)/318xx",
+ 0x438: "STM32F303x4(6/8)/334xx/328xx",
+ 0x446: "STM32F302xD(E)/303xD(E)/398xx",
+ # RM0090 in ( 38.6.1 MCU device ID code )
+ 0x413: "STM32F405xx/07xx and STM32F415xx/17xx",
+ 0x419: "STM32F42xxx and STM32F43xxx",
+ # AN2606
+ 0x452: "STM32F72xxx/73xxx",
+ 0x449: "STM32F74xxx/75xxx",
+ 0x451: "STM32F76xxx/77xxx",
+ 0x483: "STM32H72xxx/73xxx",
+ 0x450: "STM32H74xxx/75xxx",
+ 0x480: "STM32H7A3xx/B3xx",
+ # RM0394 46.6.1 MCU device ID code
+ 0x435: "STM32L4xx",
+ # ST BlueNRG series; see ST AN4872.
+ # Three-byte ID where we mask out byte 1 (metal fix)
+ # and byte 2 (mask set).
+ # Requires parity None.
+ 0x000003: "BlueNRG-1 160kB",
+ 0x00000F: "BlueNRG-1 256kB",
+ 0x000023: "BlueNRG-2 160kB",
+ 0x00002F: "BlueNRG-2 256kB",
+ # STM32F0 RM0091 Table 136. DEV_ID and REV_ID field values
+ 0x440: "STM32F030x8",
+ 0x445: "STM32F070x6",
+ 0x448: "STM32F070xB",
+ 0x442: "STM32F030xC",
+ 0x457: "STM32L01xxx/02xxx",
+ 0x497: "STM32WLE5xx/WL55xx",
+ # Cortex-M0 MCU with hardware TCP/IP and MAC
+ # (SweetPeas custom bootloader)
+ 0x801: "Wiznet W7500",
+}
+
+
+class Stm32LoaderError(Exception):
+ """Generic exception type for errors occurring in stm32loader."""
+
+
+class CommandError(Stm32LoaderError, IOError):
+ """Exception: a command in the STM32 native bootloader failed."""
+
+
+class PageIndexError(Stm32LoaderError, ValueError):
+ """Exception: invalid page index given."""
+
+
+class DataLengthError(Stm32LoaderError, ValueError):
+ """Exception: invalid data length given."""
+
+
+class DataMismatchError(Stm32LoaderError):
+ """Exception: data comparison failed."""
+
+
+class MissingDependencyError(Stm32LoaderError):
+ """Exception: required dependency is missing."""
+
+
+class ShowProgress:
+ """
+ Show progress through a progress bar, as a context manager.
+
+ Return the progress bar object on context enter, allowing the
+ caller to to call next().
+
+ Allow to supply the desired progress bar as None, to disable
+ progress bar output.
+ """
+
+ class _NoProgressBar:
+ """
+ Stub to replace a real progress.bar.Bar.
+
+ Use this if you don't want progress bar output, or if
+ there's an ImportError of progress module.
+ """
+
+ def next(self): # noqa
+ """Do nothing; be compatible to progress.bar.Bar."""
+
+ def finish(self):
+ """Do nothing; be compatible to progress.bar.Bar."""
+
+ def __init__(self, progress_bar_type):
+ """
+ Construct the context manager object.
+
+ :param progress_bar_type type: Type of progress bar to use.
+ Set to None if you don't want progress bar output.
+ """
+ self.progress_bar_type = progress_bar_type
+ self.progress_bar = None
+
+ def __call__(self, message, maximum):
+ """
+ Return a context manager for a progress bar.
+
+ :param str message: Message to show next to the progress bar.
+ :param int maximum: Maximum value of the progress bar (value at 100%).
+ E.g. 256.
+ :return ShowProgress: Context manager object.
+ """
+ if not self.progress_bar_type:
+ self.progress_bar = self._NoProgressBar()
+ else:
+ self.progress_bar = self.progress_bar_type(
+ message, max=maximum, suffix="%(index)d/%(max)d"
+ )
+
+ return self
+
+ def __enter__(self):
+ """Enter context: return progress bar to allow calling next()."""
+ return self.progress_bar
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ """Exit context: clean up by finish()ing the progress bar."""
+ self.progress_bar.finish()
+
+
+class Stm32Bootloader:
+ """Talk to the STM32 native bootloader."""
+
+ # pylint: disable=too-many-public-methods
+
+ @enum.unique
+ class Command(enum.IntEnum):
+ """STM32 native bootloader command values."""
+
+ # pylint: disable=too-few-public-methods
+ # FIXME turn into intenum
+
+ # See ST AN3155, AN4872
+ GET = 0x00
+ GET_VERSION = 0x01
+ GET_ID = 0x02
+ READ_MEMORY = 0x11
+ GO = 0x21
+ WRITE_MEMORY = 0x31
+ ERASE = 0x43
+ READOUT_PROTECT = 0x82
+ READOUT_UNPROTECT = 0x92
+ # these not supported on BlueNRG
+ EXTENDED_ERASE = 0x44
+ WRITE_PROTECT = 0x63
+ WRITE_UNPROTECT = 0x73
+
+ # not really listed under commands, but still...
+ # 'wake the bootloader' == 'activate USART' == 'synchronize'
+ SYNCHRONIZE = 0x7F
+
+ @enum.unique
+ class Reply(enum.IntEnum):
+ """STM32 native bootloader reply status codes."""
+
+ # pylint: disable=too-few-public-methods
+ # FIXME turn into intenum
+
+ # See ST AN3155, AN4872
+ ACK = 0x79
+ NACK = 0x1F
+
+ UID_ADDRESS = {
+ # No unique id for these parts
+ "F0": None,
+ # ST RM0008 section 30.1 Unique device ID register
+ # F101, F102, F103, F105, F107
+ "F1": 0x1FFFF7E8,
+ # ST RM0366 section 29.1 Unique device ID register
+ # ST RM0365 section 34.1 Unique device ID register
+ # ST RM0316 section 34.1 Unique device ID register
+ # ST RM0313 section 32.1 Unique device ID register
+ # F303/328/358/398, F301/318, F302, F37x
+ "F3": 0x1FFFF7AC,
+ # ST RM0090 section 39.1 Unique device ID register
+ # F405/415, F407/417, F427/437, F429/439
+ "F4": 0x1FFF7A10,
+ # ST RM0385 section 41.2 Unique device ID register
+ "F7": 0x1FF0F420,
+ # ST RM0433 section 61.1 Unique device ID register
+ "H7": 0x1FF1E800,
+ # ST RM0394 47.1 Unique device ID register (96 bits)
+ "L4": 0x1FFF7590,
+ # ST RM0451 25.2 Unique device ID register (96 bits)
+ "L0": 0x1FF80050,
+ # ST RM0444 section 38.1 Unique device ID register
+ "G0": 0x1FFF7590,
+ # ST RM0453 section 39.1.1 Unique device ID register
+ "WL": 0x1FFF7590,
+ # ST BlueNRG has DIE_ID register with PRODUCT, but no UID.
+ "NRG": None,
+ }
+
+ UID_SWAP = [[1, 0], [3, 2], [7, 6, 5, 4], [11, 10, 9, 8]]
+
+ # Part does not support unique ID feature
+ UID_NOT_SUPPORTED = 0
+ # stm32loader does not know the address for the unique ID
+ UID_ADDRESS_UNKNOWN = -1
+
+ FLASH_SIZE_ADDRESS = {
+ # ST RM0360 section 27.1 Memory size data register
+ # F030x4/x6/x8/xC, F070x6/xB
+ "F0": 0x1FFFF7CC,
+ # ST RM0008 section 30.2 Memory size registers
+ # F101, F102, F103, F105, F107
+ "F1": 0x1FFFF7E0,
+ # ST RM0366 section 29.2 Memory size data register
+ # ST RM0365 section 34.2 Memory size data register
+ # ST RM0316 section 34.2 Memory size data register
+ # ST RM0313 section 32.2 Flash memory size data register
+ # F303/328/358/398, F301/318, F302, F37x
+ "F3": 0x1FFFF7CC,
+ # ST RM0090 section 39.2 Flash size
+ # F405/415, F407/417, F427/437, F429/439
+ "F4": 0x1FFF7A22,
+ # ST RM0385 section 41.2 Flash size
+ "F7": 0x1FF0F442,
+ # ST RM0433 61.2 Flash size
+ "H7": 0x1FF1E880,
+ # ST RM0394
+ "L4": 0x1FFF75E0,
+ # ST RM4510 25.1 Memory size register
+ "L0": 0x1FF8007C,
+ # ST RM0444 section 38.2 Flash memory size data register
+ "G0": 0x1FFF75E0,
+ # ST RM0453 section 39.1.2 Flash size data register
+ "WL": 0x1FFF75E0,
+ # ST BlueNRG-2 datasheet
+ "NRG": 0x40100014,
+ }
+
+ DATA_TRANSFER_SIZE = {
+ # In bytes.
+ "default": 256,
+ "F0": 256,
+ "F1": 256,
+ "F3": 256,
+ "F4": 256,
+ "F7": 256,
+ "L4": 256,
+ "L0": 128,
+ "G0": 256,
+ "WL": 256,
+ "NRG": 256,
+ # ST RM0433 section 4.2 FLASH main features
+ "H7": 256,
+ }
+
+ FLASH_PAGE_SIZE = {
+ # In bytes.
+ "default": 1024,
+ # ST RM0360 section 27.1 Memory size data register
+ # F030x4/x6/x8/xC, F070x6/xB
+ "F0": 1024,
+ # ST RM0008 section 30.2 Memory size registers
+ # F101, F102, F103, F105, F107
+ "F1": 1024,
+ # ST RM0366 section 29.2 Memory size data register
+ # ST RM0365 section 34.2 Memory size data register
+ # ST RM0316 section 34.2 Memory size data register
+ # ST RM0313 section 32.2 Flash memory size data register
+ # F303/328/358/398, F301/318, F302, F37x
+ "F3": 2048,
+ # ST RM0090 section 39.2 Flash size
+ # F405/415, F407/417, F427/437, F429/439
+ "F4": 1024,
+ # ST RM0385 section 41.2 Flash size
+ "F7": 1024,
+ # ST RM0394
+ "L4": 1024,
+ # ST RM4510 25.1 Memory size register
+ "L0": 128,
+ # ST RM0444 section 38.2 Flash memory size data register
+ "G0": 1024,
+ "WL": 1024,
+ # ST BlueNRG-2 data sheet: 128 pages of 8 * 64 * 4 bytes
+ "NRG": 2048,
+ # ST RM0433 section 4.2 FLASH main features
+ "H7": 128 * 1024,
+ }
+
+ SYNCHRONIZE_ATTEMPTS = 2
+
+ def __init__(self, connection, device_family=None, verbosity=5, show_progress=None):
+ """
+ Construct the Stm32Bootloader object.
+
+ The supplied connection can be any object that supports
+ read() and write(). Optionally, it may also offer
+ enable_reset() and enable_boot0(); it should advertise this by
+ setting TOGGLES_RESET and TOGGLES_BOOT0 to True.
+
+ The default implementation is stm32loader.connection.SerialConnection,
+ but a straight pyserial serial.Serial object can also be used.
+
+ :param connection: Object supporting read() and write().
+ E.g. serial.Serial().
+ :param int verbosity: Verbosity level. 0 is quite, 10 is verbose.
+ :param ShowProgress show_progress: ShowProgress context manager.
+ Set to None to disable progress bar output.
+ """
+ self.connection = connection
+ self.verbosity = verbosity
+ self.show_progress = show_progress or ShowProgress(None)
+ self.extended_erase = False
+ self.data_transfer_size = self.DATA_TRANSFER_SIZE.get(device_family or "default")
+ self.flash_page_size = self.FLASH_PAGE_SIZE.get(device_family or "default")
+ self.device_family = device_family or "F1"
+
+ def write(self, *data):
+ """Write the given data to the MCU."""
+ for data_bytes in data:
+ if isinstance(data_bytes, int):
+ data_bytes = struct.pack("B", data_bytes)
+ self.connection.write(data_bytes)
+
+ def write_and_ack(self, message, *data):
+ """Write data to the MCU and wait until it replies with ACK."""
+ # Note: this is a separate method from write() because a keyword
+ # argument after *args was not possible in Python 2
+ self.write(*data)
+ return self._wait_for_ack(message)
+
+ def debug(self, level, message):
+ """Print the given message if its level is low enough."""
+ if self.verbosity >= level:
+ print(message, file=sys.stderr)
+
+ def reset_from_system_memory(self):
+ """Reset the MCU with boot0 enabled to enter the bootloader."""
+ self._enable_boot0(True)
+ self._reset()
+
+ # Flush the input buffer to avoid reading old data.
+ # It's known that the CP2102N at high baudrate fails to flush
+ # its buffer when the port is opened.
+ if hasattr(self.connection, "flush_input_buffer"):
+ self.connection.flush_input_buffer()
+
+ # Try the 0x7F synchronize that selects UART in bootloader mode
+ # (see ST application notes AN3155 and AN2606).
+ # If we are right after reset, it returns ACK, otherwise first
+ # time nothing, then NACK.
+ # This is not documented in STM32 docs fully, but ST official
+ # tools use the same algorithm.
+ # This is likely an artifact/side effect of each command being
+ # 2-bytes and having xor of bytes equal to 0xFF.
+
+ for attempt in range(self.SYNCHRONIZE_ATTEMPTS):
+ if attempt:
+ print("Bootloader activation timeout -- retrying", file=sys.stderr)
+ self.write(self.Command.SYNCHRONIZE)
+ read_data = bytearray(self.connection.read())
+
+ if read_data and read_data[0] in (self.Reply.ACK, self.Reply.NACK):
+ # success
+ return
+
+ # not successful
+ raise CommandError("Bad reply from bootloader")
+
+ def reset_from_flash(self):
+ """Reset the MCU with boot0 disabled."""
+ self._enable_boot0(False)
+ self._reset()
+
+ def command(self, command, description):
+ """
+ Send the given command to the MCU.
+
+ Raise CommandError if there's no ACK replied.
+ """
+ self.debug(10, "*** Command: %s" % description)
+ ack_received = self.write_and_ack("Command", command, command ^ 0xFF)
+ if not ack_received:
+ raise CommandError("%s (%s) failed: no ack" % (description, command))
+
+ def get(self):
+ """Return the bootloader version and remember supported commands."""
+ self.command(self.Command.GET, "Get")
+ length = bytearray(self.connection.read())[0]
+ version = bytearray(self.connection.read())[0]
+ self.debug(10, " Bootloader version: " + hex(version))
+ data = bytearray(self.connection.read(length))
+ if self.Command.EXTENDED_ERASE in data:
+ self.extended_erase = True
+ self.debug(10, " Available commands: " + ", ".join(hex(b) for b in data))
+ self._wait_for_ack("0x00 end")
+ return version
+
+ def get_version(self):
+ """
+ Return the bootloader version.
+
+ Read protection status readout is not yet implemented.
+ """
+ self.command(self.Command.GET_VERSION, "Get version")
+ data = bytearray(self.connection.read(3))
+ version = data[0]
+ option_byte1 = data[1]
+ option_byte2 = data[2]
+ self._wait_for_ack("0x01 end")
+ self.debug(10, " Bootloader version: " + hex(version))
+ self.debug(10, " Option byte 1: " + hex(option_byte1))
+ self.debug(10, " Option byte 2: " + hex(option_byte2))
+ return version
+
+ def get_id(self):
+ """Send the 'Get ID' command and return the device (model) ID."""
+ self.command(self.Command.GET_ID, "Get ID")
+ length = bytearray(self.connection.read())[0]
+ id_data = bytearray(self.connection.read(length + 1))
+ self._wait_for_ack("0x02 end")
+ _device_id = reduce(lambda x, y: x * 0x100 + y, id_data)
+ return _device_id
+
+ def get_flash_size(self):
+ """Return the MCU's flash size in bytes."""
+ flash_size_address = self.FLASH_SIZE_ADDRESS[self.device_family]
+ flash_size_bytes = self.read_memory(flash_size_address, 2)
+ flash_size = flash_size_bytes[0] + (flash_size_bytes[1] << 8)
+ return flash_size
+
+ def get_flash_size_and_uid(self):
+ """
+ Return device_uid and flash_size for L0 and F4 family devices.
+
+ For some reason, F4 (at least, NUCLEO F401RE) can't read the 12 or 2
+ bytes for UID and flash size directly.
+ Reading a whole chunk of 256 bytes at 0x1FFFA700 does work and
+ requires some data extraction.
+ """
+ flash_size_address = self.FLASH_SIZE_ADDRESS[self.device_family]
+ uid_address = self.UID_ADDRESS.get(self.device_family)
+
+ if uid_address is None:
+ return None, None
+
+ # Start address is the start of the 256-byte block
+ # containing uid_address and flash_size_address.
+ data_start_address = uid_address & 0xFFFFFF00
+ flash_size_lsb_address = flash_size_address - data_start_address
+ uid_lsb_address = uid_address - data_start_address
+
+ self.debug(10, "flash_size_address = 0x%X" % flash_size_address)
+ self.debug(10, "uid_address = 0x%X" % uid_address)
+ # self.debug(10, 'data_start_address =0x%X' % data_start_address)
+ # self.debug(10, 'flashsizelsbaddress =0x%X' % flash_size_lsb_address)
+ # self.debug(10, 'uid_lsb_address = 0x%X' % uid_lsb_address)
+
+ data = self.read_memory(data_start_address, self.data_transfer_size)
+ device_uid = data[uid_lsb_address : uid_lsb_address + 12]
+ flash_size = data[flash_size_lsb_address] + (data[flash_size_lsb_address + 1] << 8)
+
+ return flash_size, device_uid
+
+ def get_uid(self):
+ """
+ Send the 'Get UID' command and return the device UID.
+
+ Return UID_NOT_SUPPORTED if the device does not have
+ a UID.
+ Return UIT_ADDRESS_UNKNOWN if the address of the device's
+ UID is not known.
+
+ :return byterary: UID bytes of the device, or 0 or -1 when
+ not available.
+ """
+ uid_address = self.UID_ADDRESS.get(self.device_family, self.UID_ADDRESS_UNKNOWN)
+ if uid_address is None:
+ return self.UID_NOT_SUPPORTED
+ if uid_address == self.UID_ADDRESS_UNKNOWN:
+ return self.UID_ADDRESS_UNKNOWN
+
+ uid = self.read_memory(uid_address, 12)
+ return uid
+
+ @classmethod
+ def format_uid(cls, uid):
+ """Return a readable string from the given UID."""
+ if uid == cls.UID_NOT_SUPPORTED:
+ return "UID not supported in this part"
+ if uid == cls.UID_ADDRESS_UNKNOWN:
+ return "UID address unknown"
+
+ swapped_data = [[uid[b] for b in part] for part in Stm32Bootloader.UID_SWAP]
+ uid_string = "-".join("".join(format(b, "02X") for b in part) for part in swapped_data)
+ return uid_string
+
+ def read_memory(self, address, length):
+ """
+ Return the memory contents of flash at the given address.
+
+ Supports maximum 256 bytes.
+ """
+ if length > self.data_transfer_size:
+ raise DataLengthError("Can not read more than 256 bytes at once.")
+ self.command(self.Command.READ_MEMORY, "Read memory")
+ self.write_and_ack("0x11 address failed", self._encode_address(address))
+ nr_of_bytes = (length - 1) & 0xFF
+ checksum = nr_of_bytes ^ 0xFF
+ self.write_and_ack("0x11 length failed", nr_of_bytes, checksum)
+ return bytearray(self.connection.read(length))
+
+ def go(self, address):
+ """Send the 'Go' command to start execution of firmware."""
+ # pylint: disable=invalid-name
+ self.command(self.Command.GO, "Go")
+ self.write_and_ack("0x21 go failed", self._encode_address(address))
+
+ def write_memory(self, address, data):
+ """
+ Write the given data to flash at the given address.
+
+ Supports maximum 256 bytes.
+ """
+ nr_of_bytes = len(data)
+ if nr_of_bytes == 0:
+ return
+ if nr_of_bytes > self.data_transfer_size:
+ raise DataLengthError("Can not write more than 256 bytes at once.")
+ self.command(self.Command.WRITE_MEMORY, "Write memory")
+ self.write_and_ack("0x31 address failed", self._encode_address(address))
+
+ # pad data length to multiple of 4 bytes
+ if nr_of_bytes % 4 != 0:
+ padding_bytes = 4 - (nr_of_bytes % 4)
+ nr_of_bytes += padding_bytes
+ # append value 0xFF: flash memory value after erase
+ data = bytearray(data)
+ data.extend([0xFF] * padding_bytes)
+
+ self.debug(10, " %s bytes to write" % [nr_of_bytes])
+ checksum = reduce(operator.xor, data, nr_of_bytes - 1)
+ self.write_and_ack("0x31 programming failed", nr_of_bytes - 1, data, checksum)
+ self.debug(10, " Write memory done")
+
+ def erase_memory(self, pages=None):
+ """
+ Erase flash memory at the given pages.
+
+ Set pages to None to erase the full memory ('global erase').
+
+ :param iterable pages: Iterable of integer page addresses, zero-based.
+ Set to None to trigger global mass erase.
+ """
+ if self.extended_erase:
+ # Use erase with two-byte addresses.
+ self.extended_erase_memory(pages)
+ return
+
+ self.command(self.Command.ERASE, "Erase memory")
+
+ if not pages and self.device_family == "L0":
+ # Special case: L0 erase should do each page separately.
+ flash_size, _uid = self.get_flash_size_and_uid()
+ page_count = (flash_size * 1024) // self.flash_page_size
+ if page_count > 255:
+ raise PageIndexError("Can not erase more than 255 pages for L0 family.")
+ pages = range(page_count)
+ if pages:
+ # page erase, see ST AN3155
+ if len(pages) > 255:
+ raise PageIndexError(
+ "Can not erase more than 255 pages at once.\n"
+ "Set pages to None to do global erase or supply fewer pages."
+ )
+ page_count = len(pages) - 1
+ page_numbers = bytearray(pages)
+ checksum = reduce(operator.xor, page_numbers, page_count)
+ self.write(page_count, page_numbers, checksum)
+ else:
+ # global erase: n=255 (page count)
+ self.write(255, 0)
+
+ self._wait_for_ack("0x43 erase failed")
+ self.debug(10, " Erase memory done")
+
+ def extended_erase_memory(self, pages=None):
+ """
+ Erase flash memory using two-byte addressing at the given pages.
+
+ Set pages to None to erase the full memory.
+
+ Not all devices support the extended erase command.
+
+ :param iterable pages: Iterable of integer page addresses, zero-based.
+ Set to None to trigger global mass erase.
+ """
+ if not pages and self.device_family in ("L0",):
+ # L0 devices do not support mass erase.
+ # Instead, erase all pages individually.
+ flash_size, _uid = self.get_flash_size_and_uid()
+ pages = list(range(0, (flash_size * 1024) // self.flash_page_size))
+
+ self.command(self.Command.EXTENDED_ERASE, "Extended erase memory")
+ if pages:
+ # page erase, see ST AN3155
+ if len(pages) > 65535:
+ raise PageIndexError(
+ "Can not erase more than 65535 pages at once.\n"
+ "Set pages to None to do global erase or supply fewer pages."
+ )
+ page_count = (len(pages) & 0xFFFF) - 1
+ page_count_bytes = bytearray(struct.pack(">H", page_count))
+ page_bytes = bytearray(len(pages) * 2)
+ for i, page in enumerate(pages):
+ struct.pack_into(">H", page_bytes, i * 2, page)
+ checksum = reduce(operator.xor, page_count_bytes)
+ checksum = reduce(operator.xor, page_bytes, checksum)
+ self.write(page_count_bytes, page_bytes, checksum)
+ else:
+ # global mass erase: n=0xffff (page count) + checksum
+ # TO DO: support 0xfffe bank 1 erase / 0xfffe bank 2 erase
+ self.write(b"\xff\xff\x00")
+
+ previous_timeout_value = self.connection.timeout
+ self.connection.timeout = 30
+ print("Extended erase (0x44), this can take ten seconds or more", file=sys.stderr)
+ try:
+ self._wait_for_ack("0x44 erasing failed")
+ finally:
+ self.connection.timeout = previous_timeout_value
+ self.debug(10, " Extended Erase memory done")
+
+ def write_protect(self, pages):
+ """Enable write protection on the given flash pages."""
+ self.command(self.Command.WRITE_PROTECT, "Write protect")
+ nr_of_pages = (len(pages) - 1) & 0xFF
+ page_numbers = bytearray(pages)
+ checksum = reduce(operator.xor, page_numbers, nr_of_pages)
+ self.write_and_ack("0x63 write protect failed", nr_of_pages, page_numbers, checksum)
+ self.debug(10, " Write protect done")
+
+ def write_unprotect(self):
+ """Disable write protection of the flash memory."""
+ self.command(self.Command.WRITE_UNPROTECT, "Write unprotect")
+ self._wait_for_ack("0x73 write unprotect failed")
+ self.debug(10, " Write Unprotect done")
+
+ def readout_protect(self):
+ """Enable readout protection of the flash memory."""
+ self.command(self.Command.READOUT_PROTECT, "Readout protect")
+ self._wait_for_ack("0x82 readout protect failed")
+ self.debug(10, " Read protect done")
+
+ def readout_unprotect(self):
+ """
+ Disable readout protection of the flash memory.
+
+ Beware, this will erase the flash content.
+ """
+ self.command(self.Command.READOUT_UNPROTECT, "Readout unprotect")
+ self._wait_for_ack("0x92 readout unprotect failed")
+ self.debug(20, " Mass erase -- this may take a while")
+ time.sleep(20)
+ self.debug(20, " Unprotect / mass erase done")
+ self.debug(20, " Reset after automatic chip reset due to readout unprotect")
+ self.reset_from_system_memory()
+
+ def read_memory_data(self, address, length):
+ """
+ Return flash content from the given address and byte count.
+
+ Length may be more than 256 bytes.
+ """
+ data = bytearray()
+ chunk_count = int(math.ceil(length / float(self.data_transfer_size)))
+ self.debug(5, "Read %d chunks at address 0x%X..." % (chunk_count, address))
+ with self.show_progress("Reading", maximum=chunk_count) as progress_bar:
+ while length:
+ read_length = min(length, self.data_transfer_size)
+ self.debug(
+ 10,
+ "Read %(len)d bytes at 0x%(address)X"
+ % {"address": address, "len": read_length},
+ )
+ data = data + self.read_memory(address, read_length)
+ progress_bar.next()
+ length = length - read_length
+ address = address + read_length
+ return data
+
+ def write_memory_data(self, address, data):
+ """
+ Write the given data to flash.
+
+ Data length may be more than 256 bytes.
+ """
+ length = len(data)
+ chunk_count = int(math.ceil(length / float(self.data_transfer_size)))
+ offset = 0
+ self.debug(5, "Write %d chunks at address 0x%X..." % (chunk_count, address))
+
+ with self.show_progress("Writing", maximum=chunk_count) as progress_bar:
+ while length:
+ write_length = min(length, self.data_transfer_size)
+ self.debug(
+ 10,
+ "Write %(len)d bytes at 0x%(address)X"
+ % {"address": address, "len": write_length},
+ )
+ self.write_memory(address, data[offset : offset + write_length])
+ progress_bar.next()
+ length -= write_length
+ offset += write_length
+ address += write_length
+
+ @staticmethod
+ def verify_data(read_data, reference_data):
+ """
+ Raise an error if the given data does not match its reference.
+
+ Error type is DataMismatchError.
+
+ :param read_data: Data to compare.
+ :param reference_data: Data to compare, as reference.
+ :return None:
+ """
+ if read_data == reference_data:
+ return
+
+ if len(read_data) != len(reference_data):
+ raise DataMismatchError(
+ "Data length does not match: %d bytes vs %d bytes."
+ % (len(read_data), len(reference_data))
+ )
+
+ # data differs; find out where and raise VerifyError
+ for address, data_pair in enumerate(zip(reference_data, read_data)):
+ reference_byte, read_byte = data_pair
+ if reference_byte != read_byte:
+ raise DataMismatchError(
+ "Verification data does not match read data. "
+ "First mismatch at address: 0x%X read 0x%X vs 0x%X expected."
+ % (address, bytearray([read_byte])[0], bytearray([reference_byte])[0])
+ )
+
+ def pages_from_range(self, start, end):
+ """Return page indices for the given memory range."""
+ if start % self.flash_page_size != 0:
+ raise PageIndexError(
+ f"Erase start address should be at a flash page boundary: 0x{start:08X}."
+ )
+ if end % self.flash_page_size != 0:
+ raise PageIndexError(
+ f"Erase end address should be at a flash page boundary: 0x{end:08X}."
+ )
+
+ # Assemble the list of pages to erase.
+ first_page = start // self.flash_page_size
+ last_page = end // self.flash_page_size
+ pages = list(range(first_page, last_page))
+
+ return pages
+
+ def _reset(self):
+ """Enable or disable the reset IO line (if possible)."""
+ if not hasattr(self.connection, "enable_reset"):
+ return
+ self.connection.enable_reset(True)
+ time.sleep(0.1)
+ self.connection.enable_reset(False)
+ time.sleep(0.5)
+
+ def _enable_boot0(self, enable=True):
+ """Enable or disable the boot0 IO line (if possible)."""
+ if not hasattr(self.connection, "enable_boot0"):
+ return
+
+ self.connection.enable_boot0(enable)
+
+ def _wait_for_ack(self, info=""):
+ """Read a byte and raise CommandError if it's not ACK."""
+ read_data = bytearray(self.connection.read())
+ if not read_data:
+ raise CommandError("Can't read port or timeout")
+ reply = read_data[0]
+ if reply == self.Reply.NACK:
+ raise CommandError("NACK " + info)
+ if reply != self.Reply.ACK:
+ raise CommandError("Unknown response. " + info + ": " + hex(reply))
+
+ return 1
+
+ @staticmethod
+ def _encode_address(address):
+ """Return the given address as big-endian bytes with a checksum."""
+ # address in four bytes, big-endian
+ address_bytes = bytearray(struct.pack(">I", address))
+ # checksum as single byte
+ checksum_byte = struct.pack("B", reduce(operator.xor, address_bytes))
+ return address_bytes + checksum_byte
diff --git a/stm32loader/devices.py b/stm32loader/devices.py
new file mode 100644
index 0000000..e69de29
diff --git a/stm32loader/hexfile.py b/stm32loader/hexfile.py
new file mode 100644
index 0000000..298af0c
--- /dev/null
+++ b/stm32loader/hexfile.py
@@ -0,0 +1,30 @@
+"""Load binary data from a file in Intel hex format."""
+
+from stm32loader.bootloader import MissingDependencyError
+
+try:
+ import intelhex
+except ImportError:
+ intelhex = None
+
+
+def load_hex(file_path: str) -> bytes:
+ """
+ Return bytes from the given hex file.
+
+ Addresses should start at zero and always increment.
+ """
+ if intelhex is None:
+ raise MissingDependencyError(
+ "Please install package 'intelhex' in order to read .hex files."
+ )
+
+ hex_content = intelhex.IntelHex()
+ hex_content.loadhex(str(file_path))
+ hex_dict = hex_content.todict()
+
+ addresses = list(hex_dict.keys())
+ assert addresses[0] == 0
+ assert addresses[-1] == len(addresses) - 1
+
+ return bytes(hex_content.todict().values())
diff --git a/stm32loader/main.py b/stm32loader/main.py
new file mode 100644
index 0000000..3b2c754
--- /dev/null
+++ b/stm32loader/main.py
@@ -0,0 +1,257 @@
+#!/usr/bin/env python
+# Authors: Ivan A-R, Floris Lambrechts
+# GitHub repository: https://github.com/florisla/stm32loader
+#
+# This file is part of stm32loader.
+#
+# stm32loader is free software; you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free
+# Software Foundation; either version 3, or (at your option) any later
+# version.
+#
+# stm32loader is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+# for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with stm32loader; see the file LICENSE. If not see
+# .
+
+"""Flash firmware to STM32 microcontrollers over a serial connection."""
+
+
+import sys
+from types import SimpleNamespace
+from pathlib import Path
+
+try:
+ from progress.bar import ChargingBar as progress_bar
+except ImportError:
+ progress_bar = None
+
+from stm32loader import args
+from stm32loader import hexfile
+from stm32loader import bootloader
+from stm32loader.uart import SerialConnection
+
+
+class Stm32Loader:
+ """Main application: parse arguments and handle commands."""
+
+ # serial link bit parity, compatible to pyserial serial.PARTIY_EVEN
+ PARITY = {"even": "E", "none": "N"}
+
+ def __init__(self):
+ """Construct Stm32Loader object with default settings."""
+ self.stm32 = None
+ self.configuration = SimpleNamespace()
+
+ def debug(self, level, message):
+ """Log a message to stderror if its level is low enough."""
+ if self.configuration.verbosity >= level:
+ print(message, file=sys.stderr)
+
+ def parse_arguments(self, arguments):
+ """Parse the list of command-line arguments."""
+ self.configuration = args.parse_arguments(arguments)
+
+ # parse successful, process options further
+ self.configuration.parity = Stm32Loader.PARITY[self.configuration.parity.lower()]
+
+ def connect(self):
+ """Connect to the bootloader UART over an RS-232 serial port."""
+ serial_connection = SerialConnection(
+ self.configuration.port, self.configuration.baud, self.configuration.parity
+ )
+ self.debug(
+ 10,
+ "Open port %(port)s, baud %(baud)d"
+ % {"port": self.configuration.port, "baud": self.configuration.baud},
+ )
+ try:
+ serial_connection.connect()
+ except IOError as e:
+ print(str(e) + "\n", file=sys.stderr)
+ print(
+ "Is the device connected and powered correctly?\n"
+ "Please use the --port option to select the correct serial port. Examples:\n"
+ " --port COM3\n"
+ " --port /dev/ttyS0\n"
+ " --port /dev/ttyUSB0\n"
+ " --port /dev/tty.usbserial-ftCYPMYJ\n",
+ file=sys.stderr,
+ )
+ sys.exit(1)
+
+ serial_connection.swap_rts_dtr = self.configuration.swap_rts_dtr
+ serial_connection.reset_active_high = self.configuration.reset_active_high
+ serial_connection.boot0_active_low = self.configuration.boot0_active_low
+
+ show_progress = self._get_progress_bar(self.configuration.no_progress)
+
+ self.stm32 = bootloader.Stm32Bootloader(
+ serial_connection,
+ verbosity=self.configuration.verbosity,
+ show_progress=show_progress,
+ device_family=self.configuration.family,
+ )
+
+ try:
+ print("Activating bootloader (select UART)")
+ self.stm32.reset_from_system_memory()
+ except bootloader.CommandError:
+ print(
+ "Can't init into bootloader. Ensure that BOOT0 is enabled and reset the device.",
+ file=sys.stderr,
+ )
+ self.stm32.reset_from_flash()
+ sys.exit(1)
+
+ def perform_commands(self):
+ """Run all operations as defined by the configuration."""
+ # pylint: disable=too-many-branches
+ # pylint: disable=too-many-statements
+ binary_data = None
+ if self.configuration.write or self.configuration.verify:
+ data_file_path = Path(self.configuration.data_file)
+ if data_file_path.suffix == ".hex":
+ binary_data = hexfile.load_hex(data_file_path)
+ else:
+ binary_data = data_file_path.read_bytes()
+ if self.configuration.unprotect:
+ try:
+ self.stm32.readout_unprotect()
+ except bootloader.CommandError:
+ self.debug(0, "Flash readout unprotect failed")
+ self.debug(0, "Quit")
+ self.stm32.reset_from_flash()
+ sys.exit(1)
+ if self.configuration.protect:
+ try:
+ self.stm32.readout_protect()
+ except bootloader.CommandError:
+ self.debug(0, "Flash readout protect failed")
+ self.debug(0, "Quit")
+ self.stm32.reset_from_flash()
+ sys.exit(1)
+ if self.configuration.erase:
+ try:
+ if self.configuration.length is None:
+ # Erase full device.
+ self.stm32.erase_memory(pages=None)
+ else:
+ # Erase from address to address + length.
+ start_address = self.configuration.address
+ end_address = self.configuration.address + self.configuration.length
+ pages = self.stm32.pages_from_range(start_address, end_address)
+ self.stm32.erase_memory(pages)
+
+ except bootloader.CommandError:
+ # may be caused by readout protection
+ self.debug(
+ 0,
+ "Erase failed -- probably due to readout protection.\n"
+ "Consider using the --unprotect option.",
+ )
+ self.stm32.reset_from_flash()
+ sys.exit(1)
+ if self.configuration.write:
+ self.stm32.write_memory_data(self.configuration.address, binary_data)
+ if self.configuration.verify:
+ read_data = self.stm32.read_memory_data(self.configuration.address, len(binary_data))
+ try:
+ bootloader.Stm32Bootloader.verify_data(read_data, binary_data)
+ print("Verification OK", file=sys.stderr)
+ except bootloader.DataMismatchError as e:
+ print("Verification FAILED: %s" % e, file=sys.stderr)
+ sys.exit(1)
+ if not self.configuration.write and self.configuration.read:
+ read_data = self.stm32.read_memory_data(
+ self.configuration.address, self.configuration.length
+ )
+ with open(self.configuration.data_file, "wb") as out_file:
+ out_file.write(read_data)
+ if self.configuration.go_address is not None:
+ self.stm32.go(self.configuration.go_address)
+
+ def reset(self):
+ """Reset the microcontroller."""
+ self.stm32.reset_from_flash()
+
+ def read_device_id(self):
+ """Show chip ID and bootloader version."""
+ boot_version = self.stm32.get()
+ self.debug(0, "Bootloader version: 0x%X" % boot_version)
+ device_id = self.stm32.get_id()
+ family = self.configuration.family
+ if family == "NRG":
+ # ST AN4872.
+ # Three bytes encode metal fix, mask set,
+ # BlueNRG-series + flash size.
+ metal_fix = (device_id & 0xFF0000) >> 16
+ mask_set = (device_id & 0x00FF00) >> 8
+ device_id = device_id & 0x0000FF
+ self.debug(0, "Metal fix: 0x%X" % metal_fix)
+ self.debug(0, "Mask set: 0x%X" % mask_set)
+
+ self.debug(
+ 0, "Chip id: 0x%X (%s)" % (device_id, bootloader.CHIP_IDS.get(device_id, "Unknown"))
+ )
+
+ def read_device_uid(self):
+ """Show chip UID and flash size."""
+ family = self.configuration.family
+ if not family:
+ self.debug(0, "Supply --family to see flash size and device UID, e.g: -f F1")
+ return
+
+ try:
+ if family not in ["F4", "L0"]:
+ flash_size = self.stm32.get_flash_size()
+ device_uid = self.stm32.get_uid()
+ else:
+ # special fix for F4 and L0 devices
+ flash_size, device_uid = self.stm32.get_flash_size_and_uid()
+ except bootloader.CommandError as e:
+ self.debug(
+ 0,
+ "Something was wrong with reading chip family data: " + str(e),
+ )
+ return
+
+ device_uid_string = self.stm32.format_uid(device_uid)
+ self.debug(0, "Device UID: %s" % device_uid_string)
+ self.debug(0, "Flash size: %d KiB" % flash_size)
+
+ @staticmethod
+ def _get_progress_bar(no_progress=False):
+ if no_progress or not progress_bar:
+ return None
+
+ return bootloader.ShowProgress(progress_bar)
+
+
+def main(*arguments, **kwargs):
+ """
+ Parse arguments and execute tasks.
+
+ Default usage is to supply *sys.argv[1:].
+ """
+ try:
+ loader = Stm32Loader()
+ loader.parse_arguments(arguments)
+ loader.connect()
+ try:
+ loader.read_device_id()
+ loader.read_device_uid()
+ loader.perform_commands()
+ finally:
+ loader.reset()
+ except SystemExit:
+ if not kwargs.get("avoid_system_exit", False):
+ raise
+
+
+if __name__ == "__main__":
+ main(*sys.argv[1:])
diff --git a/stm32loader/uart.py b/stm32loader/uart.py
new file mode 100644
index 0000000..55de005
--- /dev/null
+++ b/stm32loader/uart.py
@@ -0,0 +1,126 @@
+# Author: Floris Lambrechts
+# GitHub repository: https://github.com/florisla/stm32loader
+#
+# This file is part of stm32loader.
+#
+# stm32loader is free software; you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free
+# Software Foundation; either version 3, or (at your option) any later
+# version.
+#
+# stm32loader is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+# for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with stm32loader; see the file LICENSE. If not see
+# .
+
+"""
+Handle RS-232 serial communication through pyserial.
+
+Offer support for toggling RESET and BOOT0.
+"""
+
+# Note: this file not named 'serial' because that name-clashed in Python 2
+
+
+import serial
+
+
+class SerialConnection:
+ """Wrap a serial.Serial connection and toggle reset and boot0."""
+
+ # pylint: disable=too-many-instance-attributes
+
+ def __init__(self, serial_port, baud_rate=115200, parity="E"):
+ """Construct a SerialConnection (not yet connected)."""
+ self.serial_port = serial_port
+ self.baud_rate = baud_rate
+ self.parity = parity
+
+ self.swap_rts_dtr = False
+ self.reset_active_high = False
+ self.boot0_active_low = False
+
+ # don't connect yet; caller should use connect() separately
+ self.serial_connection = None
+
+ self._timeout = 5
+
+ @property
+ def timeout(self):
+ """Get timeout."""
+ return self._timeout
+
+ @timeout.setter
+ def timeout(self, timeout):
+ """Set timeout."""
+ self._timeout = timeout
+ self.serial_connection.timeout = timeout
+
+ def connect(self):
+ """Connect to the UART serial port."""
+ self.serial_connection = serial.Serial(
+ port=self.serial_port,
+ baudrate=self.baud_rate,
+ # number of write_data bits
+ bytesize=8,
+ parity=self.parity,
+ stopbits=1,
+ # don't enable software flow control
+ xonxoff=0,
+ # don't enable RTS/CTS flow control
+ rtscts=0,
+ # set a timeout value, None for waiting forever
+ timeout=self._timeout,
+ )
+
+ def disconnect(self):
+ """Close the connection."""
+ if not self.serial_connection:
+ return
+
+ self.serial_connection.close()
+ self.serial_connection = None
+
+ def write(self, *args, **kwargs):
+ """Write the given data to the serial connection."""
+ return self.serial_connection.write(*args, **kwargs)
+
+ def read(self, *args, **kwargs):
+ """Read the given amount of bytes from the serial connection."""
+ return self.serial_connection.read(*args, **kwargs)
+
+ def enable_reset(self, enable=True):
+ """Enable or disable the reset IO line."""
+ # reset on the STM32 is active low (0 Volt puts the MCU in reset)
+ # but the RS-232 modem control DTR and RTS signals are active low
+ # themselves, so these get inverted -- writing a logical 1 outputs
+ # a low voltage, i.e. enables reset)
+ level = int(enable)
+ if self.reset_active_high:
+ level = 1 - level
+
+ if self.swap_rts_dtr:
+ self.serial_connection.setRTS(level)
+ else:
+ self.serial_connection.setDTR(level)
+
+ def enable_boot0(self, enable=True):
+ """Enable or disable the boot0 IO line."""
+ level = int(enable)
+
+ # by default, this is active high
+ if not self.boot0_active_low:
+ level = 1 - level
+
+ if self.swap_rts_dtr:
+ self.serial_connection.setDTR(level)
+ else:
+ self.serial_connection.setRTS(level)
+
+ def flush_imput_buffer(self):
+ """Flush the input buffer to remove any stale read data."""
+ self.serial_connection.reset_input_buffer()
diff --git a/tests/data/small.hex b/tests/data/small.hex
new file mode 100644
index 0000000..270a249
--- /dev/null
+++ b/tests/data/small.hex
@@ -0,0 +1,2 @@
+:10000000000102030405060708090A0B0C0D0E0F78
+:00000001FF
diff --git a/tests/emulate/erasewriteverify.py b/tests/emulate/erasewriteverify.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/integration/test_stm32bootloader.py b/tests/integration/test_stm32bootloader.py
new file mode 100644
index 0000000..6cb3b6b
--- /dev/null
+++ b/tests/integration/test_stm32bootloader.py
@@ -0,0 +1,63 @@
+"""
+Tests for the stm32loader.bootloader.
+
+Several of these tests require an actual STM32 microcontroller to be
+connected, and to be programmable (including RESET and BOOT0 toggling).
+
+These hardware tests are disabled by default.
+To enable them, configure the device parameters below and
+supply the following as argument to pytest:
+
+ -m "hardware"
+
+"""
+
+from stm32loader.bootloader import Stm32Bootloader
+from stm32loader.uart import SerialConnection
+
+import pytest
+
+SERIAL_PORT = "COM7"
+BAUD_RATE = 9600
+
+# pylint: disable=missing-docstring, redefined-outer-name
+
+
+@pytest.fixture
+def serial_connection():
+ serial_connection = SerialConnection(SERIAL_PORT, BAUD_RATE)
+ serial_connection.connect()
+ return serial_connection
+
+
+@pytest.fixture
+def stm32(serial_connection):
+ stm32 = Stm32Bootloader(serial_connection)
+ return stm32
+
+
+@pytest.mark.hardware
+def test_erase_with_page_erases_only_that_page(stm32):
+ stm32.reset_from_system_memory()
+ base = 0x08000000
+ before, middle, after = base + 0, base + 1024, base + 2048
+
+ # erase full device and check that it reset data bytes
+ stm32.erase_memory()
+ assert all(byte == 0xFF for byte in stm32.read_memory(before, 16))
+ assert all(byte == 0xFF for byte in stm32.read_memory(middle, 16))
+ assert all(byte == 0xFF for byte in stm32.read_memory(after, 16))
+
+ # write zeros to three pages and verify data has changed
+ stm32.write_memory(before, bytearray([0x00] * 16))
+ stm32.write_memory(middle, bytearray([0x00] * 16))
+ stm32.write_memory(after, bytearray([0x00] * 16))
+ assert all(byte == 0x00 for byte in stm32.read_memory(before, 16))
+ assert all(byte == 0x00 for byte in stm32.read_memory(middle, 16))
+ assert all(byte == 0x00 for byte in stm32.read_memory(after, 16))
+
+ # erase only the middle page and check only that one's bytes are rest
+ stm32.erase_memory(pages=[1])
+ assert all(byte == 0x00 for byte in stm32.read_memory(before, 16))
+ assert all(byte == 0xFF for byte in stm32.read_memory(middle, 256))
+ assert all(byte == 0x00 for byte in stm32.read_memory(after, 16))
diff --git a/tests/integration/test_stm32loader.py b/tests/integration/test_stm32loader.py
new file mode 100644
index 0000000..5127524
--- /dev/null
+++ b/tests/integration/test_stm32loader.py
@@ -0,0 +1,134 @@
+"""
+Tests for the stm32loader executable and main() method.
+
+Several of these tests require an actual STM32 microcontroller to be
+connected, and to be programmable (including RESET and BOOT0 toggling).
+
+These hardware tests are disabled by default.
+To enable them, configure the device parameters below and
+supply the following as argument to pytest:
+
+ -m "hardware"
+
+"""
+
+import os
+import subprocess
+
+import pytest
+
+from stm32loader.main import main
+
+HERE = os.path.split(os.path.abspath(__file__))[0]
+
+# Device dependant details
+# HyTiny on Windows with FTDI adapter
+STM32_CHIP_FAMILY = "F1"
+STM32_CHIP_ID = "0x410"
+STM32_CHIP_TYPE = "STM32F10x Medium-density"
+SERIAL_PORT = "COM7"
+# Flaky cable setup, cheap serial adapter...
+BAUD_RATE = 9600
+KBYTE = 2 ** 10
+SIZE = 32 * KBYTE
+DUMP_FILE = "dump.bin"
+FIRMWARE_FILE = os.path.join(HERE, "../../firmware/generic_boot20_pc13.binary.bin")
+
+# pylint: disable=missing-docstring, redefined-outer-name
+
+
+@pytest.fixture(scope="module")
+def stm32loader():
+ def main_with_default_arguments(*args):
+ main("--port", SERIAL_PORT, "--baud", str(BAUD_RATE), "--quiet", *args, avoid_system_exit=True)
+ return main_with_default_arguments
+
+
+@pytest.fixture
+def dump_file(tmpdir):
+ return os.path.join(str(tmpdir), DUMP_FILE)
+
+
+def test_stm32loader_is_executable():
+ subprocess.call(["stm32loader", "--help"])
+
+
+def test_unexisting_serial_port_prints_readable_error(capsys):
+ main("-p", "COM108", avoid_system_exit=True)
+ captured = capsys.readouterr()
+ assert "could not open port " in captured.err
+ assert ("port 'COM108'" in captured.err or "port COM108" in captured.err)
+ assert "Is the device connected and powered correctly?" in captured.err
+
+
+def test_env_var_stm32loader_serial_port_defines_port(capsys):
+ os.environ['STM32LOADER_SERIAL_PORT'] = "COM109"
+ main(avoid_system_exit=True)
+ captured = capsys.readouterr()
+ assert ("port 'COM109'" in captured.err or "port COM109" in captured.err)
+
+
+def test_argument_port_overrules_env_var_for_serial_port(capsys):
+ os.environ['STM32LOADER_SERIAL_PORT'] = "COM120"
+ main("--port", "COM121", avoid_system_exit=True)
+ captured = capsys.readouterr()
+ assert ("port 'COM121'" in captured.err or "port COM121" in captured.err)
+
+
+@pytest.mark.hardware
+@pytest.mark.missing_hardware
+def test_device_not_connected_prints_readable_error(stm32loader, capsys):
+ stm32loader()
+ captured = capsys.readouterr()
+ assert "Can't init into bootloader." in captured.err
+ assert "Ensure that BOOT0 is enabled and reset the device." in captured.err
+
+
+@pytest.mark.hardware
+def test_argument_family_prints_chip_id_and_device_type(stm32loader, capsys):
+ stm32loader("--family", STM32_CHIP_FAMILY)
+ captured = capsys.readouterr()
+ assert STM32_CHIP_ID in captured.err
+ assert STM32_CHIP_TYPE in captured.err
+
+
+@pytest.mark.hardware
+def test_read_produces_file_of_correct_length(stm32loader, dump_file):
+ stm32loader("--read", "--length", "1024", dump_file)
+ assert os.stat(dump_file).st_size == 1024
+
+
+@pytest.mark.hardware
+def test_erase_resets_memory_to_all_ones(stm32loader, dump_file):
+ # erase
+ stm32loader("--erase")
+ # read all bytes and check if they're 0xFF
+ stm32loader("-r", "-l", "1024", dump_file)
+ read_data = bytearray(open(dump_file, "rb").read())
+ assert all(byte == 0xFF for byte in read_data)
+
+
+@pytest.mark.hardware
+def test_write_saves_correct_data(stm32loader, dump_file):
+ # erase and write
+ stm32loader("--erase", "--write", FIRMWARE_FILE)
+
+ # read and compare data with file on disk
+ stm32loader("--read", "--length", str(SIZE), dump_file)
+ read_data = open(dump_file, "rb").read()
+ original_data = open(FIRMWARE_FILE, "rb").read()
+
+ for address, data in enumerate(zip(read_data, original_data)):
+ read_byte, original_byte = data
+ assert read_byte == original_byte, "Data mismatch at byte %s: %d vs %d" % (
+ address,
+ read_byte,
+ original_byte,
+ )
+
+
+@pytest.mark.hardware
+def test_erase_write_verify_passes(stm32loader):
+ stm32loader("--erase", "--write", "--verify", FIRMWARE_FILE)
+
+
diff --git a/tests/unit/test_arguments.py b/tests/unit/test_arguments.py
new file mode 100644
index 0000000..3a83f4c
--- /dev/null
+++ b/tests/unit/test_arguments.py
@@ -0,0 +1,45 @@
+
+import atexit
+
+import pytest
+
+from stm32loader.main import Stm32Loader
+
+
+@pytest.fixture
+def program():
+ return Stm32Loader()
+
+
+def test_parse_arguments_without_args_raises_typeerror(program):
+ with pytest.raises(TypeError, match="missing.*required.*argument"):
+ program.parse_arguments()
+
+
+def test_parse_arguments_with_standard_args_passes(program):
+ program.parse_arguments(["-p", "port", "-b", "9600", "-q"])
+
+
+@pytest.mark.parametrize(
+ "help_argument", ["-h", "--help"],
+)
+def test_parse_arguments_with_help_raises_systemexit(program, help_argument):
+ with pytest.raises(SystemExit):
+ program.parse_arguments([help_argument])
+
+
+def test_parse_arguments_erase_without_port_complains_about_missing_argument(program, capsys):
+ try:
+ program.parse_arguments(["-e", "-w", "-v", "file.bin"])
+ except SystemExit:
+ pass
+
+ # Also call atexit functions so that the hint about using an env variable
+ # is printed.
+ atexit._run_exitfuncs()
+
+ _output, error_output = capsys.readouterr()
+ if not error_output:
+ pytest.skip("Not sure why nothing is captured in some pytest runs?")
+ assert "arguments are required: -p/--port" in error_output
+ assert "STM32LOADER_SERIAL_PORT" in error_output
diff --git a/tests/unit/test_bootloader.py b/tests/unit/test_bootloader.py
new file mode 100644
index 0000000..8b375c5
--- /dev/null
+++ b/tests/unit/test_bootloader.py
@@ -0,0 +1,278 @@
+"""Unit tests for the Stm32Loader class."""
+
+import pytest
+
+from unittest.mock import MagicMock
+
+from stm32loader import bootloader as Stm32
+from stm32loader.bootloader import Stm32Bootloader, PageIndexError
+
+# pylint: disable=missing-docstring, redefined-outer-name
+
+
+@pytest.fixture
+def connection():
+ connection = MagicMock()
+ connection.read.return_value = [Stm32Bootloader.Reply.ACK]
+ return connection
+
+
+@pytest.fixture
+def write(connection):
+ connection.write.written_data = bytearray()
+
+ def log_written_data(data):
+ connection.write.written_data.extend(data)
+
+ def data_was_written(data):
+ return data in connection.write.written_data
+
+ connection.write.data_was_written = data_was_written
+ connection.write.side_effect = log_written_data
+ return connection.write
+
+
+@pytest.fixture
+def bootloader(connection):
+ return Stm32Bootloader(connection)
+
+
+def test_constructor_with_connection_none_passes():
+ Stm32Bootloader(connection=None)
+
+
+def test_constructor_does_not_use_connection_directly(connection):
+ Stm32Bootloader(connection)
+ assert not connection.method_calls
+
+
+def test_write_without_data_sends_no_bytes(bootloader, write):
+ bootloader.write()
+ assert not write.written_data
+
+
+def test_write_with_bytes_sends_bytes_verbatim(bootloader, write):
+ bootloader.write(b'\x00\x11')
+ assert write.data_was_written(b'\x00\x11')
+
+
+def test_write_with_integers_sends_integers_as_bytes(bootloader, write):
+ bootloader.write(0x03, 0x0a)
+ assert write.data_was_written(b'\x03\x0a')
+
+
+def test_write_and_ack_with_nack_response_raises_commandexception(bootloader):
+ bootloader.connection.read = MagicMock()
+ bootloader.connection.read.return_value = [Stm32Bootloader.Reply.NACK]
+ with pytest.raises(Stm32.CommandError, match="custom message"):
+ bootloader.write_and_ack("custom message", 0x00)
+
+
+def test_write_memory_with_length_higher_than_256_raises_data_length_error(bootloader):
+ with pytest.raises(Stm32.DataLengthError, match=r"Can not write more than 256 bytes at once\."):
+ bootloader.write_memory(0, [1] * 257)
+
+
+def test_write_memory_with_zero_bytes_does_not_send_anything(bootloader, connection):
+ bootloader.write_memory(0, b"")
+ assert not connection.method_calls
+
+
+def test_write_memory_with_single_byte_sends_four_data_bytes_padded_with_0xff(bootloader, write):
+ bootloader.write_memory(0, b"1")
+ assert write.data_was_written(b"1\xff\xff\xff")
+
+
+def test_write_memory_sends_correct_number_of_bytes(bootloader, write):
+ bootloader.write_memory(0, bytearray([0] * 4))
+ # command byte, control byte, 4 address bytes, address checksum,
+ # length byte, 4 data bytes, checksum byte
+ byte_count = 2 + 4 + 1 + 1 + 4 + 1
+ assert len(write.written_data) == byte_count
+
+
+def test_read_memory_with_length_higher_than_256_raises_data_length_error(bootloader):
+ with pytest.raises(Stm32.DataLengthError, match=r"Can not read more than 256 bytes at once\."):
+ bootloader.read_memory(0, length=257)
+
+
+def test_read_memory_sends_address_with_checksum(bootloader, write):
+ bootloader.read_memory(0x0f, 4)
+ assert write.data_was_written(b'\x00\x00\x00\x0f\x0f')
+
+
+def test_read_memory_sends_length_with_checksum(bootloader, write):
+ bootloader.read_memory(0, 0x0f + 1)
+ assert write.data_was_written(b'\x0f\xf0')
+
+
+def test_command_sends_command_and_control_bytes(bootloader, write):
+ bootloader.command(0x01, "bogus command")
+ assert write.data_was_written(b"\x01\xfe")
+
+
+def test_reset_from_system_memory_sends_command_synchronize(bootloader, write):
+ bootloader.reset_from_system_memory()
+ synchro_command = Stm32Bootloader.Command.SYNCHRONIZE
+ assert write.data_was_written(bytearray([synchro_command]))
+
+
+def test_encode_address_returns_correct_bytes_with_checksum():
+ # pylint:disable=protected-access
+ encoded_address = Stm32Bootloader._encode_address(0x04030201)
+ assert bytes(encoded_address) == b"\x04\x03\x02\x01\x04"
+
+
+def test_erase_memory_without_pages_sends_global_erase(bootloader, write):
+ bootloader.erase_memory()
+ assert write.data_was_written(b'\xff\x00')
+
+
+def test_erase_memory_with_pages_sends_sector_count_and_eight_bit_page_indices(bootloader, write):
+ bootloader.erase_memory([0x11, 0x12, 0x13, 0x14])
+ assert write.data_was_written(b'\x03')
+ assert write.data_was_written(b'\x11\x12\x13\x14')
+
+
+def test_extended_erase_memory_with_pages_sends_sector_count_and_sixteen_bit_page_indices(bootloader, write):
+ bootloader.extended_erase_memory([0x11, 0x12, 0x13, 0x14])
+ assert write.data_was_written(b'\x00\x03')
+ assert write.data_was_written(b'\x00\x11\x00\x12\x00\x13\x00\x14')
+
+
+def test_erase_memory_with_pages_sends_sector_addresses_with_checksum(bootloader, write):
+ bootloader.erase_memory([0x01, 0x02, 0x04, 0x08])
+ print(write.written_data)
+ assert write.data_was_written(b'\x01\x02\x04\x08\x0c')
+
+
+def test_erase_memory_with_page_count_higher_than_255_raises_page_index_error(bootloader):
+ with pytest.raises(Stm32.PageIndexError, match="Can not erase more than 255 pages at once."):
+ bootloader.erase_memory([1] * 256)
+
+
+def test_erase_memory_family_l0_without_pages_erases_individual_pages(connection, write):
+ bootloader = Stm32Bootloader(connection, device_family="L0")
+ bootloader.command = MagicMock()
+ bootloader.get_flash_size_and_uid = MagicMock()
+ bootloader.get_flash_size_and_uid.return_value = (16, 0x01)
+ bootloader.erase_memory()
+
+ # Page count - 1.
+ assert write.written_data[0] == 127
+ # Pages.
+ assert write.written_data[1:3] == b'\x00\x01'
+ # Length: command + byte count + page-addresses + CRC
+ assert len(write.written_data) == 130
+
+
+def test_extended_erase_memory_without_pages_sends_global_mass_erase(bootloader, write):
+ bootloader.extended_erase_memory()
+ assert write.data_was_written(b'\xff\xff\x00')
+
+
+def test_extended_erase_memory_with_page_count_higher_than_65535_raises_page_index_error(bootloader):
+ with pytest.raises(Stm32.PageIndexError, match="Can not erase more than 65535 pages at once."):
+ bootloader.extended_erase_memory([1] * 65536)
+
+
+def test_extended_erase_memory_with_pages_sends_two_byte_sector_count(bootloader, write):
+ bootloader.extended_erase_memory([0x11, 0x12, 0x13, 0x14])
+ assert write.data_was_written(b'\x00\x03')
+
+
+def test_extended_erase_memory_with_pages_sends_two_byte_sector_addresses_with_single_byte_checksum(bootloader, write):
+ bootloader.extended_erase_memory([0x01, 0x02, 0x04, 0x0ff0])
+ assert write.data_was_written(b'\x00\x01\x00\x02\x00\x04\x0f\xf0\xfb')
+
+
+def test_write_protect_sends_page_addresses_and_checksum(bootloader, write):
+ bootloader.write_protect([0x01, 0x08])
+ assert write.data_was_written(b'\x01\x08\x08')
+
+
+def test_verify_data_with_identical_data_passes():
+ Stm32Bootloader.verify_data(b'\x05', b'\x05')
+
+
+def test_verify_data_with_different_byte_count_raises_verify_error_complaining_about_length_difference():
+ with pytest.raises(Stm32.DataMismatchError, match=r"Data length does not match.*2.*vs.*1.*bytes"):
+ Stm32Bootloader.verify_data(b'\x05\x06', b'\x01')
+
+
+def test_verify_data_with_non_identical_data_raises_verify_error_complaining_about_mismatched_byte():
+ with pytest.raises(Stm32.DataMismatchError, match=r"Verification data does not match read data.*mismatch.*0x1.*0x6.*0x7"):
+ Stm32Bootloader.verify_data(b'\x05\x06', b'\x05\x07')
+
+
+@pytest.mark.parametrize(
+ "family", ["F1", "F3", "F7"],
+)
+def test_get_uid_for_known_family_reads_at_correct_address(connection, family):
+ bootloader = Stm32Bootloader(connection, device_family=family)
+ bootloader.read_memory = MagicMock()
+ bootloader.get_uid()
+ uid_address = bootloader.UID_ADDRESS[family]
+ assert bootloader.read_memory.called_once_with(uid_address)
+
+
+def test_get_uid_for_family_without_uid_returns_uid_not_supported(connection):
+ bootloader = Stm32Bootloader(connection, device_family="F0")
+ assert bootloader.UID_NOT_SUPPORTED == bootloader.get_uid()
+
+
+def test_get_uid_for_unknown_family_returns_uid_address_unknown(connection):
+ bootloader = Stm32Bootloader(connection, device_family="X")
+ assert bootloader.UID_ADDRESS_UNKNOWN == bootloader.get_uid()
+
+
+@pytest.mark.parametrize(
+ "family", ["F4", "L0"],
+)
+def test_get_flash_size_and_uid_for_exception_families_returns_size_and_uid(connection, family):
+ bootloader = Stm32Bootloader(connection, device_family=family)
+ bootloader.read_memory = MagicMock()
+
+ memory_block = bytearray([0] * 256)
+
+ # Set up the 'UID' value (12 bytes)
+ # and flash_size value (2 bytes).
+ uid_address = bootloader.UID_ADDRESS[family] & 0xFF
+ memory_block[uid_address: uid_address + 12] = b'\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c'
+ flash_size_address = bootloader.FLASH_SIZE_ADDRESS[family] & 0xFF
+ memory_block[flash_size_address: flash_size_address + 2] = b'\x01\x02'
+ bootloader.read_memory.return_value = memory_block
+
+ flash_size, uid = bootloader.get_flash_size_and_uid()
+
+ assert flash_size == 0x0201
+ assert uid == b'\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c'
+
+
+@pytest.mark.parametrize(
+ "uid_string",
+ [
+ (0, "UID not supported in this part"),
+ (-1, "UID address unknown"),
+ (bytearray(b"\x12\x34\x56\x78\x9a\xbc\xde\x01\x12\x34\x56\x78"), "3412-7856-01DEBC9A-78563412"),
+ ],
+)
+def test_format_uid_returns_correct_string(bootloader, uid_string):
+ uid, expected_description = uid_string
+ description = bootloader.format_uid(uid)
+ assert description == expected_description
+
+
+def test_get_pages_from_range_with_invalid_start_address_raises_page_index_error(bootloader):
+ with pytest.raises(PageIndexError, match=".*start address should be at a flash page boundary.*"):
+ bootloader.pages_from_range(10, 1024)
+
+
+def test_get_pages_from_range_with_start_address_zero_returns_single_page(bootloader):
+ pages = bootloader.pages_from_range(0, 1024)
+ assert pages == [0]
+
+
+def test_get_pages_from_large_range_returns_multiple_pages(bootloader):
+ pages = bootloader.pages_from_range(5*1024, 20*1024)
+ assert pages == list(range(5, 20))
diff --git a/tests/unit/test_hexfile.py b/tests/unit/test_hexfile.py
new file mode 100644
index 0000000..4b81d3d
--- /dev/null
+++ b/tests/unit/test_hexfile.py
@@ -0,0 +1,14 @@
+
+from pathlib import Path
+
+HERE = Path(__file__).parent
+DATA = HERE / "../data"
+
+
+from stm32loader.hexfile import load_hex
+
+
+def test_load_hex_delivers_bytes():
+ small_hex_path = DATA / "small.hex"
+ data = load_hex(small_hex_path)
+ assert data == bytes(range(16))
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000..5ff6b13
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,22 @@
+[tox]
+envlist = py39, py310, py311, pypy39
+
+[testenv]
+passenv = HOME
+deps=
+ pytest
+ pyserial
+ intelhex
+commands=
+ pytest -r a [] tests
+
+[pytest]
+minversion= 2.0
+norecursedirs= .git .github .tox .nox build dist tmp* tests/integration
+
+[gh-actions]
+python =
+ 3.9: py39
+ 3.10: py310
+ 3.11: py311
+ pypy-3.9: pypy39