├── .editorconfig ├── .github ├── CODEOWNERS └── workflows │ ├── build.yml │ └── publish.yml ├── .gitignore ├── LICENSE ├── README.md ├── micropython_ota.py ├── sdist_upip └── sdist_upip.py ├── setup.py └── tests ├── mocks ├── micropython_ota_mock.py └── urequests_mock.py └── test_micropython_ota.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 4 9 | end_of_line = lf 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | max_line_length = 160 13 | 14 | [{*.yml, *.yaml}] 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @olivergregorius 2 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Python Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags-ignore: 8 | - '**' 9 | pull_request: {} 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout Source Code 16 | uses: actions/checkout@v2 17 | 18 | - name: Setup python 19 | uses: actions/setup-python@v3 20 | with: 21 | python-version: "3.10" 22 | 23 | - name: Test package 24 | run: | 25 | python -m unittest discover tests 26 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout Source Code 13 | uses: actions/checkout@v2 14 | 15 | - name: Setup python 16 | uses: actions/setup-python@v3 17 | with: 18 | python-version: "3.10" 19 | 20 | - name: Install twine 21 | run: | 22 | pip install twine 23 | 24 | - name: Build package 25 | run: | 26 | python -m setup sdist 27 | 28 | - name: Publish package 29 | env: 30 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 31 | TWINE_PASSWORD: ${{ secrets.PYPI_APIKEY }} 32 | run: | 33 | twine upload dist/*.tar.gz 34 | 35 | - name: Install mpy-cross 36 | run: python -m pip install mpy-cross 37 | 38 | - name: Cross compile library 39 | run: python -m mpy_cross -Omax micropython_ota.py 40 | 41 | - name: Upload release asset 42 | uses: actions/upload-release-asset@v1 43 | env: 44 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | with: 46 | upload_url: ${{ github.event.release.upload_url }} 47 | asset_path: ./micropython_ota.mpy 48 | asset_name: micropython_ota.mpy 49 | asset_content_type: application/octet-stream 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | .gradle 3 | build/ 4 | !gradle/wrapper/gradle-wrapper.jar 5 | !**/src/main/**/build/ 6 | !**/src/test/**/build/ 7 | 8 | ### STS ### 9 | .apt_generated 10 | .classpath 11 | .factorypath 12 | .project 13 | .settings 14 | .springBeans 15 | .sts4-cache 16 | bin/ 17 | !**/src/main/**/bin/ 18 | !**/src/test/**/bin/ 19 | 20 | ### IntelliJ IDEA ### 21 | .idea 22 | *.iws 23 | *.iml 24 | *.ipr 25 | out/ 26 | !**/src/main/**/out/ 27 | !**/src/test/**/out/ 28 | 29 | ### NetBeans ### 30 | /nbproject/private/ 31 | /nbbuild/ 32 | /dist/ 33 | /nbdist/ 34 | /.nb-gradle/ 35 | 36 | ### VS Code ### 37 | .vscode/ 38 | 39 | ### Files generated by build process ### 40 | /*.egg-info 41 | MANIFEST 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Oliver Gregorius 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # micropython-ota 2 | 3 | [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/olivergregorius/micropython_ota/build.yml?branch=main&label=Python%20Build&logo=github)](https://github.com/olivergregorius/micropython_ota/actions/workflows/build.yml) 4 | [![Python Versions](https://img.shields.io/pypi/pyversions/micropython-ota?label=Python)](https://pypi.org/project/micropython-ota/) 5 | [![GitHub](https://img.shields.io/github/license/olivergregorius/micropython_ota?label=License)](https://github.com/olivergregorius/micropython_ota/blob/HEAD/LICENSE) 6 | [![PyPI](https://img.shields.io/pypi/v/micropython-ota?label=PyPI)](https://pypi.org/project/micropython-ota/) 7 | 8 | ## Introduction 9 | 10 | Micropython library for upgrading code over-the-air (OTA) 11 | 12 | ## Preparation 13 | 14 | For OTA updates to work an HTTP/HTTPS server like Apache or nGinx is required to be running and accessible by the device. This server can serve multiple devices 15 | and multiple projects at once. There are two supported directory structures of which one must be provided for the OTA updates to work: 16 | 17 | 1. Version as prefix (default) 18 | ``` 19 | server-root/ 20 | |- / 21 | | |- version 22 | | |- _ 23 | | |- _ 24 | | |- ... 25 | |- / 26 | |- version 27 | |- _ 28 | |- _ 29 | |- ... 30 | ``` 31 | 32 | 2. Version as subdirectory (by setting the parameter `use_version_prefix` to `False`, see [Usage](#usage)) 33 | ``` 34 | server-root/ 35 | |- / 36 | | |- version 37 | | |- 38 | | |- 39 | | |- 40 | | |- ... 41 | |- / 42 | |- version 43 | |- 44 | |- 45 | |- 46 | |- ... 47 | ``` 48 | 49 | For each project a directory must exist in the server's document root. Inside this directory a file "version" exists containing the version-tag to be pulled 50 | by the devices, e.g. `v1.0.0`. The source code files to be pulled by the devices are placed either right next to the version-file, prefixed by the version-tag, 51 | or in a subdirectory named with the version-tag. 52 | This structure also provides the ability to do a rollback by simply changing the version-tag in the version-file to an older version-tag, as long as the 53 | relevant source code files still reside in the expected location. 54 | 55 | In the following example two projects "sample" and "big_project" are configured, using the default, version-prefixed directory structure: 56 | 57 | ``` 58 | server-root/ 59 | |- sample/ 60 | | |- version <-- containing v1.0.1 61 | | |- v1.0.0_boot.py 62 | | |- v1.0.0_main.py 63 | | |- v1.0.1 boot.py 64 | | |- v1.0.1 main.py 65 | |- big_project/ 66 | |- version <-- containing v1.0.0 67 | |- v1.0.0_boot.py 68 | |- v1.0.0_main.py 69 | |- v1.0.0_data.py 70 | ``` 71 | 72 | ## Installation 73 | 74 | The library can be installed using [upip](https://docs.micropython.org/en/latest/reference/glossary.html#term-upip) or 75 | [mip](https://docs.micropython.org/en/latest/reference/packages.html). Ensure that the device is connected to the network. 76 | 77 | ### Installation using upip (Micropython < 1.19) 78 | 79 | ```python 80 | import upip 81 | upip.install('micropython-ota') 82 | ``` 83 | 84 | ### Installation using mip (Micropython >= 1.19) 85 | 86 | #### Py-file 87 | 88 | ```python 89 | import mip 90 | mip.install('github:olivergregorius/micropython_ota/micropython_ota.py') 91 | ``` 92 | 93 | #### Cross-compiled mpy-file 94 | 95 | **NOTE**: Set the release_version variable accordingly. 96 | 97 | ```python 98 | import mip 99 | release_version='vX.Y.Z' 100 | mip.install(f'https://github.com/olivergregorius/micropython_ota/releases/download/{release_version}/micropython_ota.mpy') 101 | ``` 102 | 103 | ## Usage 104 | 105 | This library provides two methods for 106 | 107 | 1. handling code updates during boot (`ota_update`) and 108 | 2. checking for code updates at regular intervals (`check_for_ota_update`). 109 | 110 | The `ota_update` method might be called in the boot.py file, right after the network connection has been established: 111 | 112 | ```python 113 | import micropython_ota 114 | 115 | # connect to network 116 | 117 | ota_host = 'http://192.168.2.100' 118 | project_name = 'sample' 119 | filenames = ['boot.py', 'main.py'] 120 | 121 | micropython_ota.ota_update(ota_host, project_name, filenames, use_version_prefix=True, hard_reset_device=True, soft_reset_device=False, timeout=5) 122 | ``` 123 | 124 | That's it. On boot the library retrieves the version-file from `http://192.168.2.100/sample/version` and evaluates its content against a locally persisted 125 | version-file. (Of course, on the first run the local version-file does not exist, yet. This is treated as a new version being available.) 126 | If the versions differ, the source code files listed in `filenames` are updated accordingly and on success the local version-file is updated as well. If the 127 | `use_version_prefix` is set to True (default) the library expects the 'Version as prefix' directory structure on the server, otherwise it expects the 'Version 128 | as subdirectory' directory structure (see [Preparation](#preparation)). If the `hard_reset_device`-flag is set to `True` (default) the device will be reset 129 | after the successful update by calling `machine.reset()`. For just soft-resetting the device the flag `soft_reset_device` can be set to `True` (defaults to 130 | `False`), taking precedence. This will call the `machine.soft_reset()`-method. The timeout can be set accordingly, by default its value is 5 seconds. 131 | 132 | For regular checking for code updates the method `check_for_ota_update` might be called in the course of the regular application logic in main.py, e.g.: 133 | 134 | ```python 135 | import micropython_ota 136 | import utime 137 | 138 | ota_host = 'http://192.168.2.100' 139 | project_name = 'sample' 140 | 141 | while True: 142 | # do some other stuff 143 | utime.sleep(10) 144 | micropython_ota.check_for_ota_update(ota_host, project_name, soft_reset_device=False, timeout=5) 145 | ``` 146 | 147 | In this case on each iteration the library checks for a new version as described above and resets the device if a new version is available. By default a 148 | hard-reset is performed (by calling `machine.reset()`). By setting the flag `soft_reset_device` to `True` (defaults to `False`) the device will just be 149 | soft-reset (by calling `machine.soft_reset()`). After the reset the `ota_update`-method called in the boot.py performs the actual update. This method accepts 150 | the timeout setting, too, by default it is set to 5 seconds. 151 | 152 | ## HTTP(S) Basic Authentication 153 | 154 | `ota_update()` and `check_for_ota_update()` methods allow optional `user` and `passwd` parameters. When specified the library performs a basic authentication 155 | against the server hosting the source files. Use of HTTPS (versus HTTP) is very highly recommended when using basic authentication as, otherwise, the resulting 156 | username and password are sent as plain text i.e. completely unsecure. 157 | 158 | Here is the same example as above, but using HTTPS and Basic Authentication: 159 | 160 | ```python 161 | import micropython_ota 162 | 163 | # connect to network 164 | 165 | ota_host = 'https://example.com' 166 | project_name = 'sample' 167 | filenames = ['boot.py', 'main.py'] 168 | user = 'otauser' 169 | passwd = 'topsecret' # it's best to place this credential is a secrets.py file 170 | 171 | micropython_ota.ota_update(ota_host, project_name, filenames, user=user, passwd=passwd, use_version_prefix=True, hard_reset_device=True, soft_reset_device=False, timeout=5) 172 | ``` 173 | 174 | There are plenty of tutorials online on how to set up secured HTTP file access on your webserver, but the basic steps are: 175 | - get and install an SSL certificate (Let's Encrypt is by far the best choice) 176 | - enable HTTPS access on your web server 177 | - prevent directories from listing files 178 | - enable HTTP Basic Authentication password protection on target directories 179 | -------------------------------------------------------------------------------- /micropython_ota.py: -------------------------------------------------------------------------------- 1 | import machine 2 | import ubinascii 3 | import uos 4 | import urequests 5 | 6 | 7 | def check_version(host, project, auth=None, timeout=5) -> (bool, str): 8 | current_version = '' 9 | try: 10 | if 'version' in uos.listdir(): 11 | with open('version', 'r') as current_version_file: 12 | current_version = current_version_file.readline().strip() 13 | 14 | if auth: 15 | response = urequests.get(f'{host}/{project}/version', headers={'Authorization': f'Basic {auth}'}, timeout=timeout) 16 | else: 17 | response = urequests.get(f'{host}/{project}/version', timeout=timeout) 18 | response_status_code = response.status_code 19 | response_text = response.text 20 | response.close() 21 | if response_status_code != 200: 22 | print(f'Remote version file {host}/{project}/version not found') 23 | return False, current_version 24 | remote_version = response_text.strip() 25 | return current_version != remote_version, remote_version 26 | except Exception as ex: 27 | print(f'Something went wrong: {ex}') 28 | return False, current_version 29 | 30 | 31 | def generate_auth(user=None, passwd=None) -> str | None: 32 | if not user and not passwd: 33 | return None 34 | if (user and not passwd) or (passwd and not user): 35 | raise ValueError('Either only user or pass given. None or both are required.') 36 | auth_bytes = ubinascii.b2a_base64(f'{user}:{passwd}'.encode()) 37 | return auth_bytes.decode().strip() 38 | 39 | 40 | def ota_update(host, project, filenames, use_version_prefix=True, user=None, passwd=None, hard_reset_device=True, soft_reset_device=False, timeout=5) -> None: 41 | all_files_found = True 42 | auth = generate_auth(user, passwd) 43 | prefix_or_path_separator = '_' if use_version_prefix else '/' 44 | try: 45 | version_changed, remote_version = check_version(host, project, auth=auth, timeout=timeout) 46 | if version_changed: 47 | try: 48 | uos.mkdir('tmp') 49 | except: 50 | pass 51 | for filename in filenames: 52 | if auth: 53 | response = urequests.get(f'{host}/{project}/{remote_version}{prefix_or_path_separator}{filename}', headers={'Authorization': f'Basic {auth}'}, timeout=timeout) 54 | else: 55 | response = urequests.get(f'{host}/{project}/{remote_version}{prefix_or_path_separator}{filename}', timeout=timeout) 56 | response_status_code = response.status_code 57 | response_text = response.text 58 | response.close() 59 | if response_status_code != 200: 60 | print(f'Remote source file {host}/{project}/{remote_version}{prefix_or_path_separator}{filename} not found') 61 | all_files_found = False 62 | continue 63 | with open(f'tmp/{filename}', 'w') as source_file: 64 | source_file.write(response_text) 65 | if all_files_found: 66 | for filename in filenames: 67 | with open(f'tmp/{filename}', 'r') as source_file, open(filename, 'w') as target_file: 68 | target_file.write(source_file.read()) 69 | uos.remove(f'tmp/{filename}') 70 | try: 71 | uos.rmdir('tmp') 72 | except: 73 | pass 74 | with open('version', 'w') as current_version_file: 75 | current_version_file.write(remote_version) 76 | if soft_reset_device: 77 | print('Soft-resetting device...') 78 | machine.soft_reset() 79 | if hard_reset_device: 80 | print('Hard-resetting device...') 81 | machine.reset() 82 | except Exception as ex: 83 | print(f'Something went wrong: {ex}') 84 | 85 | 86 | def check_for_ota_update(host, project, user=None, passwd=None, timeout=5, soft_reset_device=False): 87 | auth = generate_auth(user, passwd) 88 | version_changed, remote_version = check_version(host, project, auth=auth, timeout=timeout) 89 | if version_changed: 90 | if soft_reset_device: 91 | print(f'Found new version {remote_version}, soft-resetting device...') 92 | machine.soft_reset() 93 | else: 94 | print(f'Found new version {remote_version}, hard-resetting device...') 95 | machine.reset() 96 | -------------------------------------------------------------------------------- /sdist_upip/sdist_upip.py: -------------------------------------------------------------------------------- 1 | # 2 | # This module overrides distutils (also compatible with setuptools) "sdist" 3 | # command to perform pre- and post-processing as required for MicroPython's 4 | # upip package manager. 5 | # 6 | # Preprocessing steps: 7 | # * Creation of Python resource module (R.py) from each top-level package's 8 | # resources. 9 | # Postprocessing steps: 10 | # * Removing metadata files not used by upip (this includes setup.py) 11 | # * Recompressing gzip archive with 4K dictionary size so it can be 12 | # installed even on low-heap targets. 13 | # 14 | import sys 15 | import os 16 | import zlib 17 | from subprocess import Popen, PIPE 18 | import glob 19 | import tarfile 20 | import re 21 | import io 22 | 23 | from distutils.filelist import FileList 24 | from setuptools.command.sdist import sdist as _sdist 25 | 26 | 27 | def gzip_4k(inf, fname): 28 | comp = zlib.compressobj(level=9, wbits=16 + 12) 29 | with open(fname + ".out", "wb") as outf: 30 | while 1: 31 | data = inf.read(1024) 32 | if not data: 33 | break 34 | outf.write(comp.compress(data)) 35 | outf.write(comp.flush()) 36 | os.rename(fname, fname + ".orig") 37 | os.rename(fname + ".out", fname) 38 | 39 | 40 | FILTERS = [ 41 | # include, exclude, repeat 42 | (r".+\.egg-info/(PKG-INFO|requires\.txt)", r"setup.py$"), 43 | (r".+\.py$", r"[^/]+$"), 44 | (None, r".+\.egg-info/.+"), 45 | ] 46 | 47 | 48 | outbuf = io.BytesIO() 49 | 50 | def filter_tar(name): 51 | fin = tarfile.open(name, "r:gz") 52 | fout = tarfile.open(fileobj=outbuf, mode="w") 53 | for info in fin: 54 | # print(info) 55 | if not "/" in info.name: 56 | continue 57 | fname = info.name.split("/", 1)[1] 58 | include = None 59 | 60 | for inc_re, exc_re in FILTERS: 61 | if include is None and inc_re: 62 | if re.match(inc_re, fname): 63 | include = True 64 | 65 | if include is None and exc_re: 66 | if re.match(exc_re, fname): 67 | include = False 68 | 69 | if include is None: 70 | include = True 71 | 72 | if include: 73 | print("including:", fname) 74 | else: 75 | print("excluding:", fname) 76 | continue 77 | 78 | farch = fin.extractfile(info) 79 | fout.addfile(info, farch) 80 | fout.close() 81 | fin.close() 82 | 83 | 84 | def make_resource_module(manifest_files): 85 | resources = [] 86 | # Any non-python file included in manifest is resource 87 | for fname in manifest_files: 88 | ext = fname.rsplit(".", 1)[1] 89 | if ext != "py": 90 | resources.append(fname) 91 | 92 | if resources: 93 | print("creating resource module R.py") 94 | resources.sort() 95 | last_pkg = None 96 | r_file = None 97 | for fname in resources: 98 | try: 99 | pkg, res_name = fname.split("/", 1) 100 | except ValueError: 101 | print("not treating %s as a resource" % fname) 102 | continue 103 | if last_pkg != pkg: 104 | last_pkg = pkg 105 | if r_file: 106 | r_file.write("}\n") 107 | r_file.close() 108 | r_file = open(pkg + "/R.py", "w") 109 | r_file.write("R = {\n") 110 | 111 | with open(fname, "rb") as f: 112 | r_file.write("%r: %r,\n" % (res_name, f.read())) 113 | 114 | if r_file: 115 | r_file.write("}\n") 116 | r_file.close() 117 | 118 | 119 | class sdist(_sdist): 120 | 121 | def run(self): 122 | self.filelist = FileList() 123 | self.get_file_list() 124 | make_resource_module(self.filelist.files) 125 | 126 | r = super().run() 127 | 128 | assert len(self.archive_files) == 1 129 | print("filtering files and recompressing with 4K dictionary") 130 | filter_tar(self.archive_files[0]) 131 | outbuf.seek(0) 132 | gzip_4k(outbuf, self.archive_files[0]) 133 | 134 | return r 135 | 136 | 137 | # For testing only 138 | if __name__ == "__main__": 139 | filter_tar(sys.argv[1]) 140 | outbuf.seek(0) 141 | gzip_4k(outbuf, sys.argv[1]) 142 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import sys 4 | sys.path.pop(0) 5 | from setuptools import setup 6 | sys.path.append("./sdist_upip") 7 | import sdist_upip 8 | 9 | version_reference = os.getenv('GITHUB_REF', default='0.1.0') 10 | release_version_search = re.search(r'(\d+.\d+.\d+)', version_reference) 11 | if release_version_search: 12 | release_version = release_version_search.group() 13 | print(f'Version: {release_version}') 14 | else: 15 | raise ValueError("Version was not found") 16 | 17 | setup( 18 | name='micropython_ota', 19 | version=release_version, 20 | description='Micropython library for upgrading code over-the-air (OTA)', 21 | long_description=open("README.md").read(), 22 | long_description_content_type='text/markdown', 23 | packages=[''], 24 | project_urls={ 25 | 'Source': 'https://github.com/olivergregorius/micropython_ota' 26 | }, 27 | author='Oliver Gregorius', 28 | author_email='oliver@gregorius.dev', 29 | classifiers=[ 30 | "Programming Language :: Python :: 3", 31 | "License :: OSI Approved :: MIT License", 32 | "Intended Audience :: Developers", 33 | "Topic :: Software Development :: Libraries :: Python Modules", 34 | ], 35 | keywords=[ 36 | "OTA", 37 | "Microcontroller", 38 | "Micropython" 39 | ], 40 | cmdclass={'sdist': sdist_upip.sdist} 41 | ) 42 | -------------------------------------------------------------------------------- /tests/mocks/micropython_ota_mock.py: -------------------------------------------------------------------------------- 1 | def mock_check_version_true(host, project, auth, timeout): 2 | return True, 'v1.0.1' 3 | 4 | 5 | def mock_check_version_false(host, project, auth, timeout): 6 | return False, 'v1.0.1' 7 | -------------------------------------------------------------------------------- /tests/mocks/urequests_mock.py: -------------------------------------------------------------------------------- 1 | class MockedResponse: 2 | def __init__(self, url): 3 | self.url = url 4 | self.status_code = MockedUrls[url][0] 5 | self.text = MockedUrls[url][1] 6 | 7 | def close(self): 8 | pass 9 | 10 | 11 | MockedUrls = { 12 | 'http://example.org/sample/version': (200, 'v1.0.1'), 13 | 'http://example.org/non_existing/version': (404, '404 Not Found

404 Not Found


nginx/1.23.0
'), 14 | 'http://example.org/sample/v1.0.1_main.py': (200, 'print("Hello World")'), 15 | 'http://example.org/sample/v1.0.1_library.py': (200, 'print("This is a library")'), 16 | 'http://example.org/sample/v1.0.1/main.py': (200, 'print("Hello Universe")'), 17 | 'http://example.org/sample/v1.0.1/library.py': (200, 'print("This is a very nice library")'), 18 | 'http://example.org/non_existing/v1.0.1_main.py': (404, '404 Not Found

404 Not Found


nginx/1.23.0
'), 19 | 'http://example.org/non_existing/v1.0.1_library.py': (404, '404 Not Found

404 Not Found


nginx/1.23.0
') 20 | } 21 | 22 | 23 | def mock_get(url, params={}, **kwargs): 24 | return MockedResponse(url) 25 | 26 | 27 | def mock_get_OSError(url, params={}, **kwargs): 28 | raise OSError('No route to host') 29 | -------------------------------------------------------------------------------- /tests/test_micropython_ota.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import unittest 4 | from unittest.mock import Mock, patch 5 | 6 | sys.modules['machine'] = Mock() 7 | sys.modules['urequests'] = Mock() 8 | sys.modules['uos'] = __import__('os') 9 | sys.modules['ubinascii'] = __import__('binascii') 10 | import micropython_ota 11 | from mocks import micropython_ota_mock, urequests_mock 12 | 13 | 14 | class TestMicropythonOTA(unittest.TestCase): 15 | def tearDown(self) -> None: 16 | for filename in ['version', 'main.py', 'library.py']: 17 | try: 18 | os.remove(filename) 19 | except OSError: 20 | pass 21 | 22 | @patch( 23 | 'urequests.get', urequests_mock.mock_get 24 | ) 25 | def test_check_version_no_local_version_file(self): 26 | version_changed, remote_version = micropython_ota.check_version('http://example.org', 'sample') 27 | self.assertTrue(version_changed) 28 | self.assertEqual(remote_version, 'v1.0.1') 29 | 30 | def test_check_version_with_local_version_matching_remote_version_no_auth(self): 31 | with open('version', 'w') as current_version_file: 32 | current_version_file.write('v1.0.1') 33 | with patch('urequests.get') as urequests_call: 34 | urequests_call.return_value = urequests_mock.mock_get 35 | version_changed, remote_version = micropython_ota.check_version('http://example.org', 'sample') 36 | self.assertFalse(version_changed) 37 | self.assertEqual(remote_version, 'v1.0.1') 38 | urequests_call.assert_called_with('http://example.org/sample/version', timeout=5) 39 | 40 | def test_check_version_with_local_version_matching_remote_version_with_auth(self): 41 | with patch('urequests.get') as urequests_call: 42 | micropython_ota.check_version('http://example.org', 'sample', auth='aGVsbG86d29ybGQ=') 43 | urequests_call.assert_called_with('http://example.org/sample/version', headers={'Authorization': 'Basic aGVsbG86d29ybGQ='}, timeout=5) 44 | 45 | @patch( 46 | 'urequests.get', urequests_mock.mock_get 47 | ) 48 | def test_check_version_with_local_version_not_matching_remote_version(self): 49 | with open('version', 'w') as current_version_file: 50 | current_version_file.write('v1.0.0') 51 | version_changed, remote_version = micropython_ota.check_version('http://example.org', 'sample') 52 | self.assertTrue(version_changed) 53 | self.assertEqual(remote_version, 'v1.0.1') 54 | 55 | @patch( 56 | 'urequests.get', urequests_mock.mock_get_OSError 57 | ) 58 | def test_check_version_remote_host_unavailable(self): 59 | with open('version', 'w') as current_version_file: 60 | current_version_file.write('v1.0.0') 61 | version_changed, remote_version = micropython_ota.check_version('http://example.org', 'sample') 62 | self.assertFalse(version_changed) 63 | self.assertEqual(remote_version, 'v1.0.0') 64 | 65 | @patch( 66 | 'urequests.get', urequests_mock.mock_get 67 | ) 68 | def test_check_version_remote_version_file_not_found(self): 69 | with open('version', 'w') as current_version_file: 70 | current_version_file.write('v1.0.0') 71 | version_changed, remote_version = micropython_ota.check_version('http://example.org', 'non_existing') 72 | self.assertFalse(version_changed) 73 | self.assertEqual(remote_version, 'v1.0.0') 74 | 75 | @patch( 76 | 'micropython_ota.check_version', micropython_ota_mock.mock_check_version_true 77 | ) 78 | @patch( 79 | 'urequests.get', urequests_mock.mock_get 80 | ) 81 | def test_ota_update_on_version_changed_with_device_hard_reset(self): 82 | with patch('machine.reset') as machine_reset_call, patch('machine.soft_reset') as machine_soft_reset_call: 83 | micropython_ota.ota_update('http://example.org', 'sample', ['main.py', 'library.py']) 84 | with open('version', 'r') as current_version_file: 85 | self.assertEqual(current_version_file.readline(), 'v1.0.1') 86 | with open('main.py', 'r') as source_file: 87 | self.assertEqual(source_file.readline(), 'print("Hello World")') 88 | with open('library.py', 'r') as source_file: 89 | self.assertEqual(source_file.readline(), 'print("This is a library")') 90 | machine_reset_call.assert_called_once() 91 | machine_soft_reset_call.assert_not_called() 92 | 93 | @patch( 94 | 'micropython_ota.check_version', micropython_ota_mock.mock_check_version_true 95 | ) 96 | @patch( 97 | 'urequests.get', urequests_mock.mock_get 98 | ) 99 | def test_ota_update_on_version_changed_with_device_soft_reset(self): 100 | with patch('machine.reset') as machine_hard_reset_call, patch('machine.soft_reset') as machine_soft_reset_call: 101 | micropython_ota.ota_update('http://example.org', 'sample', ['main.py', 'library.py'], hard_reset_device=False, soft_reset_device=True) 102 | with open('version', 'r') as current_version_file: 103 | self.assertEqual(current_version_file.readline(), 'v1.0.1') 104 | with open('main.py', 'r') as source_file: 105 | self.assertEqual(source_file.readline(), 'print("Hello World")') 106 | with open('library.py', 'r') as source_file: 107 | self.assertEqual(source_file.readline(), 'print("This is a library")') 108 | machine_hard_reset_call.assert_not_called() 109 | machine_soft_reset_call.assert_called_once() 110 | 111 | @patch( 112 | 'micropython_ota.check_version', micropython_ota_mock.mock_check_version_true 113 | ) 114 | def test_ota_update_on_version_changed_with_auth(self): 115 | with patch('urequests.get') as urequests_call: 116 | micropython_ota.ota_update('http://example.org', 'sample', ['main.py'], user='hello', passwd='world') 117 | urequests_call.assert_called_with('http://example.org/sample/v1.0.1_main.py', headers={'Authorization': 'Basic aGVsbG86d29ybGQ='}, timeout=5) 118 | 119 | @patch( 120 | 'micropython_ota.check_version', micropython_ota_mock.mock_check_version_true 121 | ) 122 | def test_ota_update_on_version_changed_no_auth(self): 123 | with patch('urequests.get') as urequests_call: 124 | micropython_ota.ota_update('http://example.org', 'sample', ['main.py']) 125 | urequests_call.assert_called_with('http://example.org/sample/v1.0.1_main.py', timeout=5) 126 | 127 | @patch( 128 | 'micropython_ota.check_version', micropython_ota_mock.mock_check_version_true 129 | ) 130 | @patch( 131 | 'urequests.get', urequests_mock.mock_get 132 | ) 133 | def test_ota_update_on_version_changed_with_version_subdirectory_structure(self): 134 | micropython_ota.ota_update('http://example.org', 'sample', ['main.py', 'library.py'], use_version_prefix=False) 135 | with open('version', 'r') as current_version_file: 136 | self.assertEqual(current_version_file.readline(), 'v1.0.1') 137 | with open('main.py', 'r') as source_file: 138 | self.assertEqual(source_file.readline(), 'print("Hello Universe")') 139 | with open('library.py', 'r') as source_file: 140 | self.assertEqual(source_file.readline(), 'print("This is a very nice library")') 141 | 142 | @patch( 143 | 'micropython_ota.check_version', micropython_ota_mock.mock_check_version_true 144 | ) 145 | @patch( 146 | 'urequests.get', urequests_mock.mock_get 147 | ) 148 | def test_ota_update_on_version_changed_without_device_reset(self): 149 | with patch('machine.reset') as machine_hard_reset_call, patch('machine.soft_reset') as machine_soft_reset_call: 150 | micropython_ota.ota_update('http://example.org', 'sample', ['main.py', 'library.py'], hard_reset_device=False) 151 | machine_hard_reset_call.assert_not_called() 152 | machine_soft_reset_call.assert_not_called() 153 | 154 | @patch( 155 | 'micropython_ota.check_version', micropython_ota_mock.mock_check_version_false 156 | ) 157 | @patch( 158 | 'urequests.get', urequests_mock.mock_get 159 | ) 160 | def test_ota_update_on_version_not_changed(self): 161 | micropython_ota.ota_update('http://example.org', 'sample', ['main.py', 'library.py']) 162 | # For this test to pass the files 'version', 'main.py' and 'library.py' must not have been created because no update is needed 163 | self.assertFalse('version' in os.listdir()) 164 | self.assertFalse('main.py' in os.listdir()) 165 | self.assertFalse('library.py' in os.listdir()) 166 | 167 | @patch( 168 | 'micropython_ota.check_version', micropython_ota_mock.mock_check_version_true 169 | ) 170 | @patch( 171 | 'urequests.get', urequests_mock.mock_get_OSError 172 | ) 173 | def test_ota_update_host_unavailable(self): 174 | micropython_ota.ota_update('http://example.org', 'sample', ['main.py', 'library.py']) 175 | # For this test to pass the files 'version', 'main.py' and 'library.py' must not have been created because no update is needed 176 | self.assertFalse('version' in os.listdir()) 177 | self.assertFalse('main.py' in os.listdir()) 178 | 179 | @patch( 180 | 'micropython_ota.check_version', micropython_ota_mock.mock_check_version_true 181 | ) 182 | @patch( 183 | 'urequests.get', urequests_mock.mock_get 184 | ) 185 | def test_ota_update_source_file_not_found(self): 186 | micropython_ota.ota_update('http://example.org', 'non_existing', ['main.py', 'library.py']) 187 | # For this test to pass the files 'version', 'main.py' and 'library.py' must not have been created because no update is needed 188 | self.assertFalse('version' in os.listdir()) 189 | self.assertFalse('main.py' in os.listdir()) 190 | self.assertFalse('library.py' in os.listdir()) 191 | 192 | @patch( 193 | 'micropython_ota.check_version', micropython_ota_mock.mock_check_version_true 194 | ) 195 | def test_check_for_ota_update_on_version_changed(self): 196 | with patch('machine.reset') as machine_hard_reset_call, patch('machine.soft_reset') as machine_soft_reset_call: 197 | micropython_ota.check_for_ota_update('http://example.org', 'sample') 198 | machine_hard_reset_call.assert_called_once() 199 | machine_soft_reset_call.assert_not_called() 200 | 201 | @patch( 202 | 'micropython_ota.check_version', micropython_ota_mock.mock_check_version_true 203 | ) 204 | def test_check_for_ota_update_on_version_changed_with_device_soft_reset(self): 205 | with patch('machine.reset') as machine_hard_reset_call, patch('machine.soft_reset') as machine_soft_reset_call: 206 | micropython_ota.check_for_ota_update('http://example.org', 'sample', soft_reset_device=True) 207 | machine_hard_reset_call.assert_not_called() 208 | machine_soft_reset_call.assert_called_once() 209 | 210 | @patch( 211 | 'micropython_ota.check_version', micropython_ota_mock.mock_check_version_false 212 | ) 213 | def test_check_for_ota_update_on_version_not_changed(self): 214 | with patch('machine.reset') as machine_reset_call, patch('machine.soft_reset') as machine_soft_reset_call: 215 | micropython_ota.check_for_ota_update('http://example.org', 'sample') 216 | machine_reset_call.assert_not_called() 217 | machine_soft_reset_call.assert_not_called() 218 | 219 | def test_generate_auth_user_and_passwd_provided(self): 220 | auth = micropython_ota.generate_auth(user='hello', passwd='world') 221 | self.assertEqual('aGVsbG86d29ybGQ=', auth) 222 | 223 | def test_generate_auth_only_user_provided(self): 224 | with self.assertRaises(ValueError): 225 | micropython_ota.generate_auth(user='hello') 226 | 227 | def test_generate_auth_only_passwd_provided(self): 228 | with self.assertRaises(ValueError): 229 | micropython_ota.generate_auth(passwd='world') 230 | 231 | def test_generate_auth_no_user_and_passwd_provided(self): 232 | auth = micropython_ota.generate_auth() 233 | self.assertIsNone(auth) 234 | --------------------------------------------------------------------------------