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