diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..2bcd70e --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +max-line-length = 88 diff --git a/.gitignore b/.gitignore index 4f595f8..b6db07d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,6 @@ -*.pyc -*.pyo -*.so -*.coverage - -build/ +__pycache__/ +.mypy_cache/ +.pytest_cache/ +.vscode/ dist/ -sdist/ +env/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2e12ed5 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,12 @@ +# Contributing +To contribute to this repository, use GitHub pull-requests to contribute code +to this repository. + +A few things to pay attention to: + +- Dependencies are managed using [poetry](https://python-poetry.org/). +- Code is formatted using [Black](https://github.com/psf/black) and imports are + sorted using [isort](https://pycqa.github.io/isort/). +- Documentation conforms to the + [Google Python Style Guide](https://google.github.io/styleguide/pyguide.html#doc-function-args). +- Your branch is linear and logical. diff --git a/LICENSE b/LICENSE.md similarity index 96% rename from LICENSE rename to LICENSE.md index 24c7a71..41afdb6 100644 --- a/LICENSE +++ b/LICENSE.md @@ -1,4 +1,4 @@ -Copyright (c) 2014-2015 Bas Stottelaar +Copyright (c) 2014-2022 Bas Stottelaar Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index d4d4c8b..8593074 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,10 @@ Frame-based streaming protocol for embedded applications. [![Build Status](https://travis-ci.org/basilfx/python-tinylink.svg?branch=master)](https://travis-ci.org/basilfx/python-tinylink) ## Introduction -This is a general purpose Python module to provide a bi-directional frame-based streaming protocol for low-speed embedded applications, such as serial connected devices. It allowes the receiver to 'jump into' a stream of data frames. Every frame starts with a preamble, so the receiver can synchronize. Any mismatch in checksum will the receiver. +This is a general purpose Python module to provide a bi-directional frame-based +streaming protocol for low-speed embedded applications, such as serial +connected devices. It allowes the receiver to 'jump into' a stream of data +frames. Every frame starts with a preamble, so the receiver can synchronize. A payload is optional. @@ -22,28 +25,40 @@ X = Body payload (max. 65536 bytes) Y = CRC32 checksum over header + body ``` -The flags field can have arbitrary values, but the following flags are reserved. +The flags field can have arbitrary values, but the following flags are +reserved. * `0x01 = RESET` * `0x02 = ERROR` * `0x04 = PRIORITY` -Error correction is not implemented and the bytes are not aligned. The endianness is customizable. +Error correction is not implemented and the bytes are not aligned. The +endianness is customizable. ## State chart diagram Below is a simplified statechart diagram of the receiver. ![Alt text](docs/statechart.png) ## Installation -The latest development version can be installed via `pip install git+https://github.com/basilfx/python-tinylink`. - -## Tests -Tests can be executed with `nosetests`. Check the `tests/` folder for more information. +The latest development version can be installed via +`pip install git+https://github.com/basilfx/python-tinylink`. ## CLI -A simple serial CLI is included. When installed, run `tinylink /dev/tty.PORT_HERE` to start it. You can use it to send raw bytes via the link and display what comes back. +A simple serial CLI is included. When installed, run +`tinylink /dev/tty.PORT_HERE` to start it. You can use it to send raw bytes via +the link and display what comes back. + +The CLI supports so-called modifiers to modify the outgoing data. For example, +the input `\flags=1 hello world` would send a reset frame with the value +'hello world'. PySerial is required to run this CLI. +## Tests +To run the tests, please clone this repository and run `poetry run pytest`. + +## Contributing +See the [`CONTRIBUTING.md`](CONTRIBUTING.md) file. + ## License -See the `LICENSE` file (MIT license). +See the [`LICENSE.md`](LICENSE.md) file (MIT license). diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..2eac2ef --- /dev/null +++ b/poetry.lock @@ -0,0 +1,396 @@ +[[package]] +name = "attrs" +version = "22.1.0" +description = "Classes Without Boilerplate" +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.extras] +dev = ["cloudpickle", "coverage[toml] (>=5.0.2)", "furo", "hypothesis", "mypy (>=0.900,!=0.940)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "sphinx", "sphinx-notfound-page", "zope.interface"] +docs = ["furo", "sphinx", "sphinx-notfound-page", "zope.interface"] +tests = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "zope.interface"] +tests-no-zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins"] + +[[package]] +name = "black" +version = "22.10.0" +description = "The uncompromising code formatter." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +pathspec = ">=0.9.0" +platformdirs = ">=2" +tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""} +typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "click" +version = "8.1.3" +description = "Composable command line interface toolkit" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" + +[[package]] +name = "exceptiongroup" +version = "1.0.1" +description = "Backport of PEP 654 (exception groups)" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "flake8" +version = "5.0.4" +description = "the modular source code checker: pep8 pyflakes and co" +category = "dev" +optional = false +python-versions = ">=3.6.1" + +[package.dependencies] +mccabe = ">=0.7.0,<0.8.0" +pycodestyle = ">=2.9.0,<2.10.0" +pyflakes = ">=2.5.0,<2.6.0" + +[[package]] +name = "flake8-mypy" +version = "17.8.0" +description = "A plugin for flake8 integrating mypy." +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +attrs = "*" +flake8 = ">=3.0.0" +mypy = "*" + +[[package]] +name = "iniconfig" +version = "1.1.1" +description = "iniconfig: brain-dead simple config-ini parsing" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "isort" +version = "5.10.1" +description = "A Python utility / library to sort Python imports." +category = "dev" +optional = false +python-versions = ">=3.6.1,<4.0" + +[package.extras] +colors = ["colorama (>=0.4.3,<0.5.0)"] +pipfile-deprecated-finder = ["pipreqs", "requirementslib"] +plugins = ["setuptools"] +requirements-deprecated-finder = ["pip-api", "pipreqs"] + +[[package]] +name = "mccabe" +version = "0.7.0" +description = "McCabe checker, plugin for flake8" +category = "dev" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "mypy" +version = "0.990" +description = "Optional static typing for Python" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +mypy-extensions = ">=0.4.3" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = ">=3.10" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +install-types = ["pip"] +python2 = ["typed-ast (>=1.4.0,<2)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "0.4.3" +description = "Experimental type system extensions for programs checked with the mypy typechecker." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "packaging" +version = "21.3" +description = "Core utilities for Python packages" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" + +[[package]] +name = "pathspec" +version = "0.10.2" +description = "Utility library for gitignore style pattern matching of file paths." +category = "dev" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "platformdirs" +version = "2.5.4" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["furo (>=2022.9.29)", "proselint (>=0.13)", "sphinx (>=5.3)", "sphinx-autodoc-typehints (>=1.19.4)"] +test = ["appdirs (==1.4.4)", "pytest (>=7.2)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"] + +[[package]] +name = "pluggy" +version = "1.0.0" +description = "plugin and hook calling mechanisms for python" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pycodestyle" +version = "2.9.1" +description = "Python style guide checker" +category = "dev" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "pyflakes" +version = "2.5.0" +description = "passive checker of Python programs" +category = "dev" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "pyparsing" +version = "3.0.9" +description = "pyparsing module - Classes and methods to define and execute parsing grammars" +category = "dev" +optional = false +python-versions = ">=3.6.8" + +[package.extras] +diagrams = ["jinja2", "railroad-diagrams"] + +[[package]] +name = "pytest" +version = "7.2.0" +description = "pytest: simple powerful testing with Python" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +attrs = ">=19.2.0" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} + +[package.extras] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +category = "dev" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "typing-extensions" +version = "4.4.0" +description = "Backported and Experimental Type Hints for Python 3.7+" +category = "dev" +optional = false +python-versions = ">=3.7" + +[metadata] +lock-version = "1.1" +python-versions = "^3.9" +content-hash = "58f19783151d1d9dbc1788126cf6ff24ef9e62cf015939dba56f02cc2186e75e" + +[metadata.files] +attrs = [ + {file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"}, + {file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"}, +] +black = [ + {file = "black-22.10.0-1fixedarch-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:5cc42ca67989e9c3cf859e84c2bf014f6633db63d1cbdf8fdb666dcd9e77e3fa"}, + {file = "black-22.10.0-1fixedarch-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:5d8f74030e67087b219b032aa33a919fae8806d49c867846bfacde57f43972ef"}, + {file = "black-22.10.0-1fixedarch-cp37-cp37m-macosx_10_16_x86_64.whl", hash = "sha256:197df8509263b0b8614e1df1756b1dd41be6738eed2ba9e9769f3880c2b9d7b6"}, + {file = "black-22.10.0-1fixedarch-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:2644b5d63633702bc2c5f3754b1b475378fbbfb481f62319388235d0cd104c2d"}, + {file = "black-22.10.0-1fixedarch-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:e41a86c6c650bcecc6633ee3180d80a025db041a8e2398dcc059b3afa8382cd4"}, + {file = "black-22.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2039230db3c6c639bd84efe3292ec7b06e9214a2992cd9beb293d639c6402edb"}, + {file = "black-22.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14ff67aec0a47c424bc99b71005202045dc09270da44a27848d534600ac64fc7"}, + {file = "black-22.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:819dc789f4498ecc91438a7de64427c73b45035e2e3680c92e18795a839ebb66"}, + {file = "black-22.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5b9b29da4f564ba8787c119f37d174f2b69cdfdf9015b7d8c5c16121ddc054ae"}, + {file = "black-22.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8b49776299fece66bffaafe357d929ca9451450f5466e997a7285ab0fe28e3b"}, + {file = "black-22.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:21199526696b8f09c3997e2b4db8d0b108d801a348414264d2eb8eb2532e540d"}, + {file = "black-22.10.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e464456d24e23d11fced2bc8c47ef66d471f845c7b7a42f3bd77bf3d1789650"}, + {file = "black-22.10.0-cp37-cp37m-win_amd64.whl", hash = "sha256:9311e99228ae10023300ecac05be5a296f60d2fd10fff31cf5c1fa4ca4b1988d"}, + {file = "black-22.10.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:fba8a281e570adafb79f7755ac8721b6cf1bbf691186a287e990c7929c7692ff"}, + {file = "black-22.10.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:915ace4ff03fdfff953962fa672d44be269deb2eaf88499a0f8805221bc68c87"}, + {file = "black-22.10.0-cp38-cp38-win_amd64.whl", hash = "sha256:444ebfb4e441254e87bad00c661fe32df9969b2bf224373a448d8aca2132b395"}, + {file = "black-22.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:974308c58d057a651d182208a484ce80a26dac0caef2895836a92dd6ebd725e0"}, + {file = "black-22.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72ef3925f30e12a184889aac03d77d031056860ccae8a1e519f6cbb742736383"}, + {file = "black-22.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:432247333090c8c5366e69627ccb363bc58514ae3e63f7fc75c54b1ea80fa7de"}, + {file = "black-22.10.0-py3-none-any.whl", hash = "sha256:c957b2b4ea88587b46cf49d1dc17681c1e672864fd7af32fc1e9664d572b3458"}, + {file = "black-22.10.0.tar.gz", hash = "sha256:f513588da599943e0cde4e32cc9879e825d58720d6557062d1098c5ad80080e1"}, +] +click = [ + {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, + {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, +] +colorama = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] +exceptiongroup = [ + {file = "exceptiongroup-1.0.1-py3-none-any.whl", hash = "sha256:4d6c0aa6dd825810941c792f53d7b8d71da26f5e5f84f20f9508e8f2d33b140a"}, + {file = "exceptiongroup-1.0.1.tar.gz", hash = "sha256:73866f7f842ede6cb1daa42c4af078e2035e5f7607f0e2c762cc51bb31bbe7b2"}, +] +flake8 = [ + {file = "flake8-5.0.4-py2.py3-none-any.whl", hash = "sha256:7a1cf6b73744f5806ab95e526f6f0d8c01c66d7bbe349562d22dfca20610b248"}, + {file = "flake8-5.0.4.tar.gz", hash = "sha256:6fbe320aad8d6b95cec8b8e47bc933004678dc63095be98528b7bdd2a9f510db"}, +] +flake8-mypy = [ + {file = "flake8-mypy-17.8.0.tar.gz", hash = "sha256:47120db63aff631ee1f84bac6fe8e64731dc66da3efc1c51f85e15ade4a3ba18"}, + {file = "flake8_mypy-17.8.0-py35.py36-none-any.whl", hash = "sha256:cff009f4250e8391bf48990093cff85802778c345c8449d6498b62efefeebcbc"}, +] +iniconfig = [ + {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, + {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, +] +isort = [ + {file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"}, + {file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"}, +] +mccabe = [ + {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, + {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, +] +mypy = [ + {file = "mypy-0.990-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:aaf1be63e0207d7d17be942dcf9a6b641745581fe6c64df9a38deb562a7dbafa"}, + {file = "mypy-0.990-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d555aa7f44cecb7ea3c0ac69d58b1a5afb92caa017285a8e9c4efbf0518b61b4"}, + {file = "mypy-0.990-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f694d6d09a460b117dccb6857dda269188e3437c880d7b60fa0014fa872d1e9"}, + {file = "mypy-0.990-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:269f0dfb6463b8780333310ff4b5134425157ef0d2b1d614015adaf6d6a7eabd"}, + {file = "mypy-0.990-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8798c8ed83aa809f053abff08664bdca056038f5a02af3660de00b7290b64c47"}, + {file = "mypy-0.990-cp310-cp310-win_amd64.whl", hash = "sha256:47a9955214615108c3480a500cfda8513a0b1cd3c09a1ed42764ca0dd7b931dd"}, + {file = "mypy-0.990-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4a8a6c10f4c63fbf6ad6c03eba22c9331b3946a4cec97f008e9ffb4d3b31e8e2"}, + {file = "mypy-0.990-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cd2dd3730ba894ec2a2082cc703fbf3e95a08479f7be84912e3131fc68809d46"}, + {file = "mypy-0.990-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7da0005e47975287a92b43276e460ac1831af3d23032c34e67d003388a0ce8d0"}, + {file = "mypy-0.990-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:262c543ef24deb10470a3c1c254bb986714e2b6b1a67d66daf836a548a9f316c"}, + {file = "mypy-0.990-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3ff201a0c6d3ea029d73b1648943387d75aa052491365b101f6edd5570d018ea"}, + {file = "mypy-0.990-cp311-cp311-win_amd64.whl", hash = "sha256:1767830da2d1afa4e62b684647af0ff79b401f004d7fa08bc5b0ce2d45bcd5ec"}, + {file = "mypy-0.990-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:6826d9c4d85bbf6d68cb279b561de6a4d8d778ca8e9ab2d00ee768ab501a9852"}, + {file = "mypy-0.990-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46897755f944176fbc504178422a5a2875bbf3f7436727374724842c0987b5af"}, + {file = "mypy-0.990-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:0680389c34284287fe00e82fc8bccdea9aff318f7e7d55b90d967a13a9606013"}, + {file = "mypy-0.990-cp37-cp37m-win_amd64.whl", hash = "sha256:b08541a06eed35b543ae1a6b301590eb61826a1eb099417676ddc5a42aa151c5"}, + {file = "mypy-0.990-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:be88d665e76b452c26fb2bdc3d54555c01226fba062b004ede780b190a50f9db"}, + {file = "mypy-0.990-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9b8f4a8213b1fd4b751e26b59ae0e0c12896568d7e805861035c7a15ed6dc9eb"}, + {file = "mypy-0.990-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2b6f85c2ad378e3224e017904a051b26660087b3b76490d533b7344f1546d3ff"}, + {file = "mypy-0.990-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ee5f99817ee70254e7eb5cf97c1b11dda29c6893d846c8b07bce449184e9466"}, + {file = "mypy-0.990-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49082382f571c3186ce9ea0bd627cb1345d4da8d44a8377870f4442401f0a706"}, + {file = "mypy-0.990-cp38-cp38-win_amd64.whl", hash = "sha256:aba38e3dd66bdbafbbfe9c6e79637841928ea4c79b32e334099463c17b0d90ef"}, + {file = "mypy-0.990-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:9d851c09b981a65d9d283a8ccb5b1d0b698e580493416a10942ef1a04b19fd37"}, + {file = "mypy-0.990-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d847dd23540e2912d9667602271e5ebf25e5788e7da46da5ffd98e7872616e8e"}, + {file = "mypy-0.990-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cc6019808580565040cd2a561b593d7c3c646badd7e580e07d875eb1bf35c695"}, + {file = "mypy-0.990-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a3150d409609a775c8cb65dbe305c4edd7fe576c22ea79d77d1454acd9aeda8"}, + {file = "mypy-0.990-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3227f14fe943524f5794679156488f18bf8d34bfecd4623cf76bc55958d229c5"}, + {file = "mypy-0.990-cp39-cp39-win_amd64.whl", hash = "sha256:c76c769c46a1e6062a84837badcb2a7b0cdb153d68601a61f60739c37d41cc74"}, + {file = "mypy-0.990-py3-none-any.whl", hash = "sha256:8f1940325a8ed460ba03d19ab83742260fa9534804c317224e5d4e5aa588e2d6"}, + {file = "mypy-0.990.tar.gz", hash = "sha256:72382cb609142dba3f04140d016c94b4092bc7b4d98ca718740dc989e5271b8d"}, +] +mypy-extensions = [ + {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, + {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, +] +packaging = [ + {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, + {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, +] +pathspec = [ + {file = "pathspec-0.10.2-py3-none-any.whl", hash = "sha256:88c2606f2c1e818b978540f73ecc908e13999c6c3a383daf3705652ae79807a5"}, + {file = "pathspec-0.10.2.tar.gz", hash = "sha256:8f6bf73e5758fd365ef5d58ce09ac7c27d2833a8d7da51712eac6e27e35141b0"}, +] +platformdirs = [ + {file = "platformdirs-2.5.4-py3-none-any.whl", hash = "sha256:af0276409f9a02373d540bf8480021a048711d572745aef4b7842dad245eba10"}, + {file = "platformdirs-2.5.4.tar.gz", hash = "sha256:1006647646d80f16130f052404c6b901e80ee4ed6bef6792e1f238a8969106f7"}, +] +pluggy = [ + {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, + {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, +] +pycodestyle = [ + {file = "pycodestyle-2.9.1-py2.py3-none-any.whl", hash = "sha256:d1735fc58b418fd7c5f658d28d943854f8a849b01a5d0a1e6f3f3fdd0166804b"}, + {file = "pycodestyle-2.9.1.tar.gz", hash = "sha256:2c9607871d58c76354b697b42f5d57e1ada7d261c261efac224b664affdc5785"}, +] +pyflakes = [ + {file = "pyflakes-2.5.0-py2.py3-none-any.whl", hash = "sha256:4579f67d887f804e67edb544428f264b7b24f435b263c4614f384135cea553d2"}, + {file = "pyflakes-2.5.0.tar.gz", hash = "sha256:491feb020dca48ccc562a8c0cbe8df07ee13078df59813b83959cbdada312ea3"}, +] +pyparsing = [ + {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, + {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"}, +] +pytest = [ + {file = "pytest-7.2.0-py3-none-any.whl", hash = "sha256:892f933d339f068883b6fd5a459f03d85bfcb355e4981e146d2c7616c21fef71"}, + {file = "pytest-7.2.0.tar.gz", hash = "sha256:c4014eb40e10f11f355ad4e3c2fb2c6c6d1919c73f3b5a433de4708202cade59"}, +] +tomli = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] +typing-extensions = [ + {file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"}, + {file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"}, +] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..36e799c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,53 @@ +[tool.black] +exclude = ''' +( + \.git/ + | \.vscode/ + | env/ +) +''' +line-length = 88 +target-version = ["py39"] + +[tool.isort] +profile = "black" + +[tool.poetry] +authors = ["Bas Stottelaar "] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Topic :: System :: Networking", + "Topic :: Software Development :: Embedded Systems", +] +description = "Frame-based streaming protocol for embedded applications." +homepage = "https://github.com/basilfx/python-tinylink" +include = [ + "LICENSE.md", +] +keywords = ["python", "embedded", "tinylink", "streaming", "serial"] +license = "MIT" +name = "tinylink" +packages = [ + { include = "tinylink" } +] +readme = "README.md" +repository = "https://github.com/basilfx/python-tinylink" +version = "2.0.0" + +[tool.poetry.dependencies] +python = "^3.9" + +[tool.poetry.group.dev.dependencies] +black = "^22.10.0" +isort = "^5.10.1" +flake8 = "^5.0.4" +flake8-mypy = "^17.8.0" +pytest = "^7.2.0" + +[tool.poetry.scripts] +tinylink = 'tinylink.cli:run' + +[build-system] +build-backend = "poetry.core.masonry.api" +requires = ["poetry-core"] diff --git a/setup.py b/setup.py deleted file mode 100644 index e387695..0000000 --- a/setup.py +++ /dev/null @@ -1,31 +0,0 @@ -from setuptools import setup - -# Setup definitions -setup( - name="python-tinylink", - version="2.0.0", - description="Frame-based streaming protocol for embedded applications.", - author="Bas Stottelaar", - author_email="basstottelaar@gmail.com", - packages=["tinylink"], - license="MIT", - keywords="python embedded arm arduino tinylink streaming serial", - setup_requires=["nose"], - install_requires=["six"], - zip_safe=False, - entry_points={ - "console_scripts": [ - "tinylink = tinylink.cli:run", - ] - }, - classifiers=[ - "Development Status :: 5 - Production/Stable", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Topic :: System :: Networking", - "Topic :: Software Development :: Embedded Systems", - "Programming Language :: Python :: 2", - "Programming Language :: Python :: 2.7", - "Programming Language :: Python :: 3.4" - ] -) diff --git a/tests/test_tinylink.py b/tests/test_tinylink.py index c929dec..a1ccfc2 100644 --- a/tests/test_tinylink.py +++ b/tests/test_tinylink.py @@ -1,6 +1,5 @@ -from six.moves import xrange - import unittest + import tinylink @@ -9,25 +8,29 @@ class DummyHandle(object): Dummy handler, so the TinyLink class can exchange data with itself. """ - def __init__(self): + stream: bytes + index: int + length: int + + def __init__(self) -> None: self.stream = bytearray() self.index = 0 self.length = 0 - def write(self, data): + def read(self, size: int) -> bytes: + data = self.stream[self.index : min(self.length, self.index + size)] + self.index += len(data) + + # Return data. + return bytes(data) + + def write(self, data: bytes) -> int: self.stream.extend(data) self.length += len(data) - # Return number of bytes written + # Return number of bytes written. return len(data) - def read(self, count): - data = self.stream[self.index:min(self.length, self.index + count)] - self.index += len(data) - - # Return data - return bytes(data) - class TinyLinkTest(unittest.TestCase): """ @@ -46,10 +49,14 @@ def test_basic(self): size = link.write(message) self.assertEqual( - size, tinylink.LEN_PREAMBLE + tinylink.LEN_HEADER + - tinylink.LEN_BODY + len(message)) - - # Read `size' bytes to receive the full frame, test it partially + size, + tinylink.LEN_PREAMBLE + + tinylink.LEN_HEADER + + tinylink.LEN_BODY + + len(message), + ) + + # Read `size` bytes to receive the full frame, test it partially. link.read(1) link.read(1) link.read(1) @@ -68,14 +75,14 @@ def test_multiple(self): size = 0 - for i in xrange(5): + for i in range(5): size += link.write(bytes([97 + i])) frames = link.read(size) self.assertEqual(len(frames), 5) - for i in xrange(5): + for i in range(5): self.assertEqual(frames[i].data, bytes([97 + i])) def test_sync(self): @@ -86,7 +93,7 @@ def test_sync(self): handle = DummyHandle() link = tinylink.TinyLink(handle) - garbage = b"Garbage here that doesn't synchronize." + garbage = b"Garbage here that does not synchronize." message = b"Hi!" size = handle.write(garbage) + link.write(message) @@ -103,7 +110,7 @@ def test_sync_small(self): handle = DummyHandle() link = tinylink.TinyLink(handle, max_length=4) - garbage = b"Garbage here that doesn't synchronize." + garbage = b"Garbage here that does not synchronize." message = b"Hi!" size = handle.write(garbage) + link.write(message) @@ -153,7 +160,7 @@ def test_damaged_a(self): message = b"Hello, this is a test" size = link.write(message) - handle.stream[-tinylink.LEN_CRC:] = [0x00] * tinylink.LEN_CRC + handle.stream[-tinylink.LEN_CRC :] = [0x00] * tinylink.LEN_CRC frames = link.read(size) self.assertEqual(len(frames), 1) @@ -162,7 +169,7 @@ def test_damaged_a(self): def test_damaged_b(self): """ - Test damaged frame (header) that won't return anything. + Test damaged frame (header) that will not return anything. """ handle = DummyHandle() @@ -171,7 +178,7 @@ def test_damaged_b(self): message = b"Hello, this is a test" size = link.write(message) - handle.stream[tinylink.LEN_PREAMBLE+tinylink.LEN_HEADER-1] = 0x00 + handle.stream[tinylink.LEN_PREAMBLE + tinylink.LEN_HEADER - 1] = 0x00 frames = link.read(size) self.assertEqual(len(frames), 0) diff --git a/tinylink/__init__.py b/tinylink/__init__.py index 591878b..b419def 100644 --- a/tinylink/__init__.py +++ b/tinylink/__init__.py @@ -1,232 +1,4 @@ -from tinylink import utils +from .consts import * # noqa +from .link import Frame, TinyLink # noqa -import struct -import six - -__version__ = "1.1" - -__all__ = ["Frame", "TinyLink"] - -# This can be anything, and is used to synchronize a frame -PREAMBLE = 0xAA55AA55 - -# Endianness -LITTLE_ENDIAN = "<" -BIG_ENDIAN = ">" - -# Protocol states. -WAITING_FOR_PREAMBLE = 1 -WAITING_FOR_HEADER = 2 -WAITING_FOR_BODY = 3 - -# Message flags (reserved). -FLAG_NONE = 0x00 -FLAG_RESET = 0x01 -FLAG_ERROR = 0x02 -FLAG_PRIORITY = 0x04 - -# Don't change these values! -LEN_PREAMBLE = 4 -LEN_FLAGS = 2 -LEN_LENGTH = 2 -LEN_XOR = 1 -LEN_CRC = 4 -LEN_HEADER = LEN_FLAGS + LEN_LENGTH + LEN_XOR -LEN_BODY = LEN_CRC - - -class Frame(object): - """ - Represents a frame. - """ - - def __init__(self, data=None, flags=FLAG_NONE, damaged=False): - if data is not None: - if not type(data) == six.binary_type: - raise ValueError("Provided data must be encoded as bytes.") - else: - data = bytes() - - self.data = data - self.flags = flags - self.damaged = damaged - - def __repr__(self): - return "%s(%s, flags=%d, damaged=%s)" % ( - self.__class__.__name__, repr(self.data), self.flags, self.damaged) - - -class TinyLink(object): - """ - TinyLink state machine for streaming communication with low-speed embedded - applications that only use RX/TX. Every message is encapsulated in a frame. - A frame has a header checksum and a frame checksum, to detect errors as - fast as possible (this can happen when you jump right into a stream of - packets, without being synchronized). - - A typical frame has 13 bytes overhead, and can have a data payload up to - 65536 bytes. - - It does not provide error correction and the bytes are not aligned. - """ - - def __init__(self, handle, endianness=LITTLE_ENDIAN, - max_length=2**(LEN_LENGTH * 8), ignore_damaged=False): - """ - Construct a new TinyLink state machine. A state machine takes a handle, - which provides a `read' and `write' method. - - The endianness is either LITTLE_ENDIAN or BIG_ENDIAN. While big endian - is common for networking, little endian is directly compatible with ARM - microcontrollers, so the microcontrollers don't have to change the - endianness. - - Both microcontroller and this instance should agree upon the value of - `max_length'. In case a message is received that exceeds this value, it - will be silently ignored. - - By default, if a fully received frame is damaged, it will be returned - as a `DamagedFrame' instance, unless `ignored_damaged' is True. - """ - - self.handle = handle - self.endianness = endianness - self.max_length = max_length - self.ignore_damaged = ignore_damaged - - # Set initial state - self.state = WAITING_FOR_PREAMBLE - - # Pre-allocate buffer that fits header + body. The premable will be - # cleared when it is detected, so it doesn't need space. - self.stream = bytearray(max_length + LEN_HEADER + LEN_BODY) - self.index = 0 - - # Python 2 does not allow unpack from bytearray, but Python 3. - if six.PY3: - self.buffer = self.stream - else: - self.buffer = buffer(self.stream) - - def write_frame(self, frame): - """ - Write a frame via the handle. - """ - - result = bytearray() - length = len(frame.data or []) - - # Check length of message - if length > self.max_length: - raise ValueError( - "Message length %d exceeds max length %d" % ( - length, self.max_length)) - - # Pack header - checksum_header = utils.checksum_header(frame.flags, length) - result += struct.pack( - self.endianness + "IHHB", PREAMBLE, frame.flags, length, - checksum_header) - - # Pack data - if frame.data is not None: - checksum_frame = utils.checksum_frame(frame.data, checksum_header) - result += struct.pack( - self.endianness + str(length) + "sI", frame.data, - checksum_frame) - - # Write to file - return self.handle.write(result) - - def write(self, data, flags=FLAG_NONE): - """ - Shorthand for `write_frame(Frame(data, flags=flags))'. - """ - - return self.write_frame(Frame(data, flags=flags)) - - def read(self, limit=1): - """ - Read up to `limit' bytes from the handle and process it. Returns a list - of received frames, if any. - """ - - # List of frames received - frames = [] - - # Bytes are added one at a time - while limit: - char = self.handle.read(1) - - if not char: - return [] - - # Append to stream - self.stream[self.index] = ord(char) - self.index += 1 - - # Decide what to do - if self.state == WAITING_FOR_PREAMBLE: - if self.index >= LEN_PREAMBLE: - start, = struct.unpack_from( - self.endianness + "I", self.buffer, self.index - 4) - - if start == PREAMBLE: - # Advance to next state - self.index = 0 - self.state = WAITING_FOR_HEADER - elif self.index == self.max_length + LEN_HEADER + LEN_BODY: - # Preamble not found and stream is full. Copy last four - # bytes, because the next byte may form the preamble - # together with the last three bytes. - self.stream[0:4] = self.stream[-4:] - self.index = 4 - - elif self.state == WAITING_FOR_HEADER: - if self.index == LEN_HEADER: - flags, length, checksum = struct.unpack_from( - self.endianness + "HHB", self.buffer) - - # Verify checksum - if checksum == utils.checksum_header(flags, length) and \ - length <= self.max_length: - - if length > 0: - self.state = WAITING_FOR_BODY - else: - # Frame without body. - frames.append(Frame(flags=flags)) - - self.index = 0 - self.state = WAITING_FOR_PREAMBLE - else: - # Reset to start state - self.index = 0 - self.state = WAITING_FOR_PREAMBLE - - elif self.state == WAITING_FOR_BODY: - # Unpack header - flags, length, checksum_a = struct.unpack_from( - self.endianness + "HHB", self.buffer) - - if self.index == LEN_HEADER + length + LEN_CRC: - # Unpack body - result, checksum_b = struct.unpack_from( - self.endianness + str(length) + "sI", - self.buffer, LEN_HEADER) - - # Verify checksum - if checksum_b == utils.checksum_frame(result, checksum_a): - frames.append(Frame(result, flags=flags)) - elif not self.ignore_damaged: - frames.append(Frame(result, flags=flags, damaged=True)) - - # Reset to start state - self.index = 0 - self.state = WAITING_FOR_PREAMBLE - - # Decrement number of bytes to read - limit -= 1 - - # Done - return frames +__version__ = "2.0.0" diff --git a/tinylink/cli.py b/tinylink/cli.py index ff71ee8..1f74bb6 100644 --- a/tinylink/cli.py +++ b/tinylink/cli.py @@ -1,14 +1,13 @@ -from six.moves import xrange -from six import StringIO - +import argparse import csv -import six -import sys -import time import select import struct +import sys +import time +from io import StringIO +from typing import Optional + import tinylink -import argparse try: import serial @@ -16,36 +15,32 @@ serial = None -def run(): - """ - Entry point for console script. - """ - - sys.exit(main()) - - -def parse_arguments(): +def parse_arguments(argv: list[str]) -> argparse.Namespace: """ Create and parse command line arguments. """ - parser = argparse.ArgumentParser() + parser = argparse.ArgumentParser(prog=argv[0]) - # Add option + # Add options. parser.add_argument("port", type=str, help="serial port") + parser.add_argument("baudrate", type=int, default=9600, help="serial baudrate") parser.add_argument( - "baudrate", type=int, default=9600, help="serial baudrate") - parser.add_argument( - "--length", type=int, default=2**16, help="maximum length of frame") + "--length", type=int, default=2**16, help="maximum length of frame" + ) parser.add_argument( - "--endianness", type=str, default="little", choices=["big", "little"], - help="maximum length of frame") + "--endianness", + type=str, + default="little", + choices=["big", "little"], + help="maximum length of frame", + ) - # Parse command line - return parser.parse_args(), parser + # Parse command line. + return parser.parse_args(argv[1:]) -def dump(prefix, data): +def dump(prefix: str, data: bytes) -> str: """ Dump data as two hex columns. """ @@ -53,15 +48,15 @@ def dump(prefix, data): result = [] length = len(data) - for i in xrange(0, length, 16): + for i in range(0, length, 16): hexstr = "" bytestr = b"" - for j in xrange(0, 16): + for j in range(0, 16): if i + j < length: - b = six.indexbytes(data, i + j) + b = data[i + j] hexstr += "%02x " % b - bytestr += six.int2byte(b) if 0x20 <= b < 0x7F else b"." + bytestr += bytes((b,)) if 0x20 <= b < 0x7F else b"." else: hexstr += " " @@ -70,18 +65,18 @@ def dump(prefix, data): result.append(prefix + " " + hexstr + bytestr.decode("ascii")) - # Return concatenated string + # Return concatenated string. return "\n".join(result) -def process_link(link): +def process_link(link: tinylink.TinyLink) -> None: """ Process incoming link data. """ frames = link.read() - # Print received frames + # Print received frames. for frame in frames: sys.stdout.write("### Type = %s\n" % frame.__class__.__name__) sys.stdout.write("### Flags = 0x%04x\n" % frame.flags) @@ -90,7 +85,7 @@ def process_link(link): sys.stdout.write(dump("<<<", frame.data) + "\n\n") -def process_stdin(link): +def process_stdin(link: tinylink.TinyLink) -> Optional[bool]: """ Process stdin commands. """ @@ -135,13 +130,13 @@ def process_stdin(link): except: try: # Assume it is an int. - value = struct.pack( - link.endianness + pack, int(item, 0)) + value = struct.pack(link.endianness + pack, int(item, 0)) except ValueError: # Assume it is a byte string. - item = item.encode("ascii") + item_bytes = item.encode("ascii") value = struct.pack( - link.endianness + str(len(item)) + "s", item) + link.endianness + str(len(item_bytes)) + "s", item_bytes + ) # Concat to frame. frame.data = (frame.data or bytes()) + value @@ -149,7 +144,7 @@ def process_stdin(link): sys.stdout.write("Parse exception: %s\n" % e) # Output the data. - for i in xrange(repeat): + for i in range(repeat): sys.stdout.write("### Flags = 0x%04x\n" % frame.flags) if frame.data: @@ -164,7 +159,15 @@ def process_stdin(link): return -def main(): +def run() -> None: + """ + Entry point for console script. + """ + + sys.exit(main(sys.argv)) + + +def main(argv: list[str]) -> int: """ Main entry point. """ @@ -172,49 +175,50 @@ def main(): if serial is None: sys.stdout.write( "TinyLink CLI uses PySerial, but it is not installed. Please " - "install this first.\n") + "install this first.\n" + ) return 1 - # Parse arguments - arguments, parser = parse_arguments() + # Parse arguments. + arguments = parse_arguments(argv) if arguments.endianness == "little": endianness = tinylink.LITTLE_ENDIAN else: endianness = tinylink.BIG_ENDIAN - # Open serial port and create link + # Open serial port and create link. handle = serial.Serial(arguments.port, baudrate=arguments.baudrate) - link = tinylink.TinyLink( - handle, max_length=arguments.length, endianness=endianness) + link = tinylink.TinyLink(handle, max_length=arguments.length, endianness=endianness) - # Loop until finished + # Loop until finished. try: - # Input indicator + # Input indicator. sys.stdout.write("--> ") sys.stdout.flush() while True: readables, _, _ = select.select([handle, sys.stdin], [], []) - # Read from serial port + # Read from serial port. if handle in readables: process_link(link) - # Read from stdin + # Read from stdin. if sys.stdin in readables: if process_stdin(link) is False: break - # Input indicator + # Input indicator. sys.stdout.write("--> ") sys.stdout.flush() except KeyboardInterrupt: handle.close() - # Done + # Done. return 0 -# E.g. `python tinylink_cli.py /dev/tty.usbmodem1337 --baudrate 9600' + +# E.g. `python cli.py /dev/tty.usbmodem1337 --baudrate 9600`. if __name__ == "__main__": run() diff --git a/tinylink/consts.py b/tinylink/consts.py new file mode 100644 index 0000000..e83cf85 --- /dev/null +++ b/tinylink/consts.py @@ -0,0 +1,26 @@ +# This can be anything, and is used to synchronize a frame. +PREAMBLE = 0xAA55AA55 + +# Endianness. +LITTLE_ENDIAN = "<" +BIG_ENDIAN = ">" + +# Protocol states. +WAITING_FOR_PREAMBLE = 1 +WAITING_FOR_HEADER = 2 +WAITING_FOR_BODY = 3 + +# Message flags (reserved). +FLAG_NONE = 0x00 +FLAG_RESET = 0x01 +FLAG_ERROR = 0x02 +FLAG_PRIORITY = 0x04 + +# Do not change these values! +LEN_PREAMBLE = 4 +LEN_FLAGS = 2 +LEN_LENGTH = 2 +LEN_XOR = 1 +LEN_CRC = 4 +LEN_HEADER = LEN_FLAGS + LEN_LENGTH + LEN_XOR +LEN_BODY = LEN_CRC diff --git a/tinylink/link.py b/tinylink/link.py new file mode 100644 index 0000000..f028c8b --- /dev/null +++ b/tinylink/link.py @@ -0,0 +1,249 @@ +import struct +from typing import Protocol + +from . import consts, utils + + +class Handle(Protocol): + """ + Protocol for a handler. + """ + + def read(self, size: int) -> bytes: + """ " + Read up to `size` bytes. + """ + + def write(self, data: bytes) -> int: + """ + Write data and return the number of bytes written. + """ + + +class Frame: + """ + Represents a frame. + """ + + data: bytes + flags: int + damaged: int + + def __init__( + self, data: bytes = None, flags: int = consts.FLAG_NONE, damaged: bool = False + ) -> None: + if data is not None: + if type(data) is not bytes: + raise ValueError("Provided data must be encoded as bytes.") + else: + data = bytes() + + self.data = data + self.flags = flags + self.damaged = damaged + + def __repr__(self) -> str: + return "%s(%s, flags=%d, damaged=%s)" % ( + self.__class__.__name__, + repr(self.data), + self.flags, + self.damaged, + ) + + +class TinyLink: + """ + TinyLink state machine for streaming communication with low-speed embedded + applications that only use RX/TX. Every message is encapsulated in a frame. + A frame has a header checksum and a frame checksum, to detect errors as + fast as possible (this can happen when you jump right into a stream of + packets, without being synchronized). + + A typical frame has 13 bytes overhead, and can have a data payload up to + 65536 bytes. + + It does not provide error correction and the bytes are not aligned. + """ + + handle: Handle + endianness: str + max_length: int + ignore_damaged: bool + + def __init__( + self, + handle: Handle, + endianness: str = consts.LITTLE_ENDIAN, + max_length: int = 2 ** (consts.LEN_LENGTH * 8), + ignore_damaged: bool = False, + ) -> None: + """ + Construct a new TinyLink state machine. A state machine takes a handle, + which provides a `read` and `write` method. + + The endianness is either `consts.LITTLE_ENDIAN` or `consts.BIG_ENDIAN`. + While big endian is common for networking, little endian is directly + compatible with ARM microcontrollers, so the microcontrollers do not + have to perform conversion of endianness. + + Both microcontroller and this instance should agree upon the value of + `max_lengthz. In case a message is received that exceeds this value, it + will be silently ignored. + + By default, if a fully received frame is damaged, it will be returned + as an instance of `DamagedFrame` instance, unless `ignored_damaged` is + set to `True`. + """ + + self.handle = handle + self.endianness = endianness + self.max_length = max_length + self.ignore_damaged = ignore_damaged + + # Set initial state + self.state = consts.WAITING_FOR_PREAMBLE + + # Pre-allocate buffer that fits header + body. The premable will be + # cleared when it is detected, so it does not need space. + self.stream = bytearray(max_length + consts.LEN_HEADER + consts.LEN_BODY) + self.index = 0 + + # Python 2 does not allow unpack from bytearray, but Python 3. + self.buffer = self.stream + + def write_frame(self, frame: Frame) -> int: + """ + Write a frame via the handle. + """ + + result = bytearray() + length = len(frame.data or []) + + # Check length of message. + if length > self.max_length: + raise ValueError( + "Message length %d exceeds max length %d" % (length, self.max_length) + ) + + # Pack header. + checksum_header = utils.checksum_header(frame.flags, length) + result += struct.pack( + self.endianness + "IHHB", + consts.PREAMBLE, + frame.flags, + length, + checksum_header, + ) + + # Pack data. + if frame.data is not None: + checksum_frame = utils.checksum_frame(frame.data, checksum_header) + result += struct.pack( + self.endianness + str(length) + "sI", frame.data, checksum_frame + ) + + # Write to file. + return self.handle.write(result) + + def write(self, data: bytes, flags: int = consts.FLAG_NONE) -> int: + """ + Shorthand for `write_frame(Frame(data, flags=flags))`. + """ + + return self.write_frame(Frame(data, flags=flags)) + + def read(self, limit: int = 1) -> list[Frame]: + """ + Read up to `limit` bytes from the handle and process it. Returns a list + of received frames, if any. + """ + + # List of frames received. + frames = [] + + # Bytes are added one at a time. + while limit: + char = self.handle.read(1) + + if not char: + return [] + + # Append to stream. + self.stream[self.index] = ord(char) + self.index += 1 + + # Decide what to do. + if self.state == consts.WAITING_FOR_PREAMBLE: + if self.index >= consts.LEN_PREAMBLE: + (start,) = struct.unpack_from( + self.endianness + "I", self.buffer, self.index - 4 + ) + + if start == consts.PREAMBLE: + # Advance to next state. + self.index = 0 + self.state = consts.WAITING_FOR_HEADER + elif ( + self.index + == self.max_length + consts.LEN_HEADER + consts.LEN_BODY + ): + # Preamble not found and stream is full. Copy last four + # bytes, because the next byte may form the preamble + # together with the last three bytes. + self.stream[0:4] = self.stream[-4:] + self.index = 4 + + elif self.state == consts.WAITING_FOR_HEADER: + if self.index == consts.LEN_HEADER: + flags, length, checksum = struct.unpack_from( + self.endianness + "HHB", self.buffer + ) + + # Verify checksum. + if ( + checksum == utils.checksum_header(flags, length) + and length <= self.max_length + ): + + if length > 0: + self.state = consts.WAITING_FOR_BODY + else: + # Frame without body. + frames.append(Frame(flags=flags)) + + self.index = 0 + self.state = consts.WAITING_FOR_PREAMBLE + else: + # Reset to start state. + self.index = 0 + self.state = consts.WAITING_FOR_PREAMBLE + + elif self.state == consts.WAITING_FOR_BODY: + # Unpack header. + flags, length, checksum_a = struct.unpack_from( + self.endianness + "HHB", self.buffer + ) + + if self.index == consts.LEN_HEADER + length + consts.LEN_CRC: + # Unpack body. + result, checksum_b = struct.unpack_from( + self.endianness + str(length) + "sI", + self.buffer, + consts.LEN_HEADER, + ) + + # Verify checksum. + if checksum_b == utils.checksum_frame(result, checksum_a): + frames.append(Frame(result, flags=flags)) + elif not self.ignore_damaged: + frames.append(Frame(result, flags=flags, damaged=True)) + + # Reset to start state. + self.index = 0 + self.state = consts.WAITING_FOR_PREAMBLE + + # Decrement number of bytes to read. + limit -= 1 + + # Done. + return frames diff --git a/tinylink/utils.py b/tinylink/utils.py index 3fbdde1..69c0773 100644 --- a/tinylink/utils.py +++ b/tinylink/utils.py @@ -1,5 +1,3 @@ -from six.moves import xrange - CRC32_POLYNOMIAL = 0xEDB88320 CRC32_INITIAL = 0x00000000 @@ -13,9 +11,9 @@ def crc32(buf): def crc32_value(c): ulTemp1 = (result >> 8) & 0x00FFFFFF - ulCRC = (result ^ c) & 0xff + ulCRC = (result ^ c) & 0xFF - for i in xrange(8): + for i in range(8): if ulCRC & 0x01: ulCRC = (ulCRC >> 1) ^ CRC32_POLYNOMIAL else: @@ -23,7 +21,7 @@ def crc32_value(c): return ulTemp1 ^ ulCRC - # Execute above function for each byte. + # Execute function for each byte. for b in buf: result = crc32_value(b) @@ -48,5 +46,4 @@ def checksum_frame(data, checksum_header): Calculate checksum of both the checksum header and the data. """ - return crc32( - memoryview(data).tobytes() + bytearray([checksum_header])) & 0xFFFFFFFF + return crc32(memoryview(data).tobytes() + bytearray([checksum_header])) & 0xFFFFFFFF