diff --git a/.flake8 b/.flake8 index 541e00a..235fbcd 100644 --- a/.flake8 +++ b/.flake8 @@ -1,3 +1,5 @@ [flake8] -ignore = E203, W503 max-line-length = 88 + +# Necessary for compatibility with the Black formatter. +ignore = E203, W503 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 0aedf84..e839aed 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -10,24 +10,24 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - - uses: actions/setup-python@v2 + - uses: actions/setup-python@v5 with: - python-version: "3.10" + python-version: "3.12" - - uses: actions/cache@v2 + - uses: actions/cache@v4 with: path: ~/.local - key: poetry-1.2.2 + key: poetry-1.8.0 - uses: snok/install-poetry@v1 with: - version: 1.2.2 + version: 1.8.0 virtualenvs-create: true virtualenvs-in-project: true - - uses: actions/cache@v2 + - uses: actions/cache@v4 id: cache-deps with: path: .venv diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 94a09a1..ff683ac 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,24 +10,24 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - - uses: actions/setup-python@v2 + - uses: actions/setup-python@v5 with: - python-version: "3.10" + python-version: "3.12" - - uses: actions/cache@v2 + - uses: actions/cache@v4 with: path: ~/.local - key: poetry-1.2.2 + key: poetry-1.8.0 - uses: snok/install-poetry@v1 with: - version: 1.2.2 + version: 1.8.0 virtualenvs-create: true virtualenvs-in-project: true - - uses: actions/cache@v2 + - uses: actions/cache@v4 id: cache-deps with: path: .venv diff --git a/.gitignore b/.gitignore index b6db07d..804288b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ __pycache__/ +.history .mypy_cache/ .pytest_cache/ .vscode/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 17b26d1..0be5d70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,25 @@ # Changelog +## Unreleased + +Highlights: +* Added: support for asynchronous handles. +* Added: escaping of the header and body (protocol). +* Changed: minimal Python version is Python 3.12. +* Changed: body checksum is always included (protocol). +* Changed: body checksum is now calculated over header and payload (protocol). +* Fixed: preamble is now read as string of bytes. +* Improved: CLI is more interactive. +* Improved: consistent terminology and naming. +* Removed: reserved flags (protocol). + ## v2.0.0 Released 17 September 2015 Highlights: * Added: Python 3.4 support. -* Improved: More strict handling of bytes. -* Improved: Formatted code according to PEP8. +* Improved: more strict handling of bytes. +* Improved: formatted code according to PEP8. * Changed: ResetFrame is now a Frame, with correct flags set. * Changed: DamagedFrame is now a property on a Frame. diff --git a/README.md b/README.md index 167cc47..0f28283 100644 --- a/README.md +++ b/README.md @@ -10,30 +10,26 @@ 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. - The format of a frame is as follows: ``` +| Preamble | Header | Body | | 0xAA 0x55 0xAA 0x55 | AA AA BB BB CC | XX XX .. .. .. .. XX XX YY YY YY YY | -| Preamble | Header | Body (optional) | Fields: -A = Length -B = Flags +A = Flags +B = Length C = XOR checksum over header -X = Body payload (max. 65536 bytes) -Y = CRC32 checksum over header + body +X = Payload (max. 65536 bytes) +Y = CRC32 checksum over header + payload ``` -The flags field can have arbitrary values, but the following flags are -reserved. +The flags field can be used for arbitrary purposes. The payload is optional. -* `0x01 = RESET` -* `0x02 = ERROR` -* `0x04 = PRIORITY` +Escaping of the header and body are performed using byte-stuffing, to ensure +that the header and body can contain bytes of the preamble. -Error correction is not implemented and the bytes are not aligned. The +Error correction is not implemented and the bytes are not strictly aligned. The endianness is customizable. ## State chart diagram @@ -45,15 +41,16 @@ 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 +A CLI is included to experiment with TinyLink. 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'. +the input `\flags=16 hello world` would send a frame with the flags equal to 16 +and the payload 'hello world' -PySerial is required to run this CLI. +The CLI requires additional dependencies, that are installed using the `cli` +dependency specification (`poetry install --extras cli`). ## Tests To run the tests, please clone this repository and run `poetry run pytest`. diff --git a/docs/statechart.dot b/docs/statechart.dot index 61a3537..6fb994d 100644 --- a/docs/statechart.dot +++ b/docs/statechart.dot @@ -8,8 +8,7 @@ digraph G { receiving_header[shape=box, style=rounded, label="Receiving\nheader"] receiving_body[shape=box, style=rounded, label="Receiving\nbody"] - output_reset_frame[shape=box, style=rounded, label="Output\nreset frame"] - output_data_frame[shape=box, style=rounded, label="Output\ndata frame"] + output_frame[shape=box, style=rounded, label="Output frame"] start -> waiting_for_preamble @@ -17,15 +16,12 @@ digraph G { waiting_for_preamble -> receiving_header[label="Preamble detected"] receiving_header -> receiving_header[label="Byte received"] - receiving_header -> receiving_body[label="Header complete,\nbody expected"] + receiving_header -> receiving_body[label="Header complete,\nchecksum OK"] receiving_header -> waiting_for_preamble[label="Header invalid"] - receiving_header -> output_reset_frame[label="Header complete,\nreset frame"] - output_reset_frame -> waiting_for_preamble - receiving_body -> receiving_body[label="Byte received"] receiving_body -> waiting_for_preamble[label="Body invalid"] - receiving_body -> output_data_frame[label="Body complete,\ndata frame"] - output_data_frame -> waiting_for_preamble + receiving_body -> output_frame[label="Body complete,\nchecksum OK"] + output_frame -> waiting_for_preamble } \ No newline at end of file diff --git a/docs/statechart.png b/docs/statechart.png index ad2362f..af20959 100644 Binary files a/docs/statechart.png and b/docs/statechart.png differ diff --git a/poetry.lock b/poetry.lock index ed3b160..43827b8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,65 +1,47 @@ -# This file is automatically @generated by Poetry and should not be changed by hand. - -[[package]] -name = "attrs" -version = "22.1.0" -description = "Classes Without Boilerplate" -category = "dev" -optional = false -python-versions = ">=3.5" -files = [ - {file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"}, - {file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"}, -] - -[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"] +# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. [[package]] name = "black" -version = "22.10.0" +version = "25.1.0" description = "The uncompromising code formatter." -category = "dev" optional = false -python-versions = ">=3.7" +python-versions = ">=3.9" +groups = ["dev"] files = [ - {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"}, + {file = "black-25.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32"}, + {file = "black-25.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da"}, + {file = "black-25.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:055e59b198df7ac0b7efca5ad7ff2516bca343276c466be72eb04a3bcc1f82d7"}, + {file = "black-25.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:db8ea9917d6f8fc62abd90d944920d95e73c83a5ee3383493e35d271aca872e9"}, + {file = "black-25.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0"}, + {file = "black-25.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299"}, + {file = "black-25.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096"}, + {file = "black-25.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2"}, + {file = "black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b"}, + {file = "black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc"}, + {file = "black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f"}, + {file = "black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba"}, + {file = "black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f"}, + {file = "black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3"}, + {file = "black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171"}, + {file = "black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18"}, + {file = "black-25.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1ee0a0c330f7b5130ce0caed9936a904793576ef4d2b98c40835d6a65afa6a0"}, + {file = "black-25.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f3df5f1bf91d36002b0a75389ca8663510cf0531cca8aa5c1ef695b46d98655f"}, + {file = "black-25.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9e6827d563a2c820772b32ce8a42828dc6790f095f441beef18f96aa6f8294e"}, + {file = "black-25.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:bacabb307dca5ebaf9c118d2d2f6903da0d62c9faa82bd21a33eecc319559355"}, + {file = "black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717"}, + {file = "black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666"}, ] [package.dependencies] click = ">=8.0.0" mypy-extensions = ">=0.4.3" +packaging = ">=22.0" 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)"] +d = ["aiohttp (>=3.10)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] @@ -67,9 +49,9 @@ uvloop = ["uvloop (>=0.15.2)"] name = "click" version = "8.1.3" description = "Composable command line interface toolkit" -category = "dev" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, @@ -82,70 +64,39 @@ colorama = {version = "*", markers = "platform_system == \"Windows\""} 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" +groups = ["dev"] +markers = "platform_system == \"Windows\" or sys_platform == \"win32\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -[[package]] -name = "exceptiongroup" -version = "1.0.1" -description = "Backport of PEP 654 (exception groups)" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ - {file = "exceptiongroup-1.0.1-py3-none-any.whl", hash = "sha256:4d6c0aa6dd825810941c792f53d7b8d71da26f5e5f84f20f9508e8f2d33b140a"}, - {file = "exceptiongroup-1.0.1.tar.gz", hash = "sha256:73866f7f842ede6cb1daa42c4af078e2035e5f7607f0e2c762cc51bb31bbe7b2"}, -] - -[package.extras] -test = ["pytest (>=6)"] - [[package]] name = "flake8" -version = "6.0.0" +version = "7.1.2" description = "the modular source code checker: pep8 pyflakes and co" -category = "dev" optional = false python-versions = ">=3.8.1" +groups = ["dev"] files = [ - {file = "flake8-6.0.0-py2.py3-none-any.whl", hash = "sha256:3833794e27ff64ea4e9cf5d410082a8b97ff1a06c16aa3d2027339cd0f1195c7"}, - {file = "flake8-6.0.0.tar.gz", hash = "sha256:c61007e76655af75e6785a931f452915b371dc48f56efd765247c8fe68f2b181"}, + {file = "flake8-7.1.2-py2.py3-none-any.whl", hash = "sha256:1cbc62e65536f65e6d754dfe6f1bada7f5cf392d6f5db3c2b85892466c3e7c1a"}, + {file = "flake8-7.1.2.tar.gz", hash = "sha256:c586ffd0b41540951ae41af572e6790dbd49fc12b3aa2541685d253d9bd504bd"}, ] [package.dependencies] mccabe = ">=0.7.0,<0.8.0" -pycodestyle = ">=2.10.0,<2.11.0" -pyflakes = ">=3.0.0,<3.1.0" - -[[package]] -name = "flake8-mypy" -version = "17.8.0" -description = "A plugin for flake8 integrating mypy." -category = "dev" -optional = false -python-versions = "*" -files = [ - {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"}, -] - -[package.dependencies] -attrs = "*" -flake8 = ">=3.0.0" -mypy = "*" +pycodestyle = ">=2.12.0,<2.13.0" +pyflakes = ">=3.2.0,<3.3.0" [[package]] name = "iniconfig" version = "1.1.1" description = "iniconfig: brain-dead simple config-ini parsing" -category = "dev" optional = false python-versions = "*" +groups = ["dev"] files = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, @@ -153,29 +104,27 @@ files = [ [[package]] name = "isort" -version = "5.12.0" +version = "6.0.1" description = "A Python utility / library to sort Python imports." -category = "dev" optional = false -python-versions = ">=3.8.0" +python-versions = ">=3.9.0" +groups = ["dev"] files = [ - {file = "isort-5.12.0-py3-none-any.whl", hash = "sha256:f84c2818376e66cf843d497486ea8fed8700b340f308f076c6fb1229dff318b6"}, - {file = "isort-5.12.0.tar.gz", hash = "sha256:8bef7dde241278824a6d83f44a544709b065191b95b6e50894bdc722fcba0504"}, + {file = "isort-6.0.1-py3-none-any.whl", hash = "sha256:2dc5d7f65c9678d94c88dfc29161a320eec67328bc97aad576874cb4be1e9615"}, + {file = "isort-6.0.1.tar.gz", hash = "sha256:1cb5df28dfbc742e490c5e41bad6da41b805b0a8be7bc93cd0fb2a8a890ac450"}, ] [package.extras] -colors = ["colorama (>=0.4.3)"] -pipfile-deprecated-finder = ["pip-shims (>=0.5.2)", "pipreqs", "requirementslib"] +colors = ["colorama"] 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" +groups = ["dev"] files = [ {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, @@ -183,89 +132,88 @@ files = [ [[package]] name = "mypy" -version = "0.990" +version = "1.15.0" description = "Optional static typing for Python" -category = "dev" optional = false -python-versions = ">=3.7" +python-versions = ">=3.9" +groups = ["dev"] files = [ - {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"}, + {file = "mypy-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:979e4e1a006511dacf628e36fadfecbcc0160a8af6ca7dad2f5025529e082c13"}, + {file = "mypy-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c4bb0e1bd29f7d34efcccd71cf733580191e9a264a2202b0239da95984c5b559"}, + {file = "mypy-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be68172e9fd9ad8fb876c6389f16d1c1b5f100ffa779f77b1fb2176fcc9ab95b"}, + {file = "mypy-1.15.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7be1e46525adfa0d97681432ee9fcd61a3964c2446795714699a998d193f1a3"}, + {file = "mypy-1.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2e2c2e6d3593f6451b18588848e66260ff62ccca522dd231cd4dd59b0160668b"}, + {file = "mypy-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:6983aae8b2f653e098edb77f893f7b6aca69f6cffb19b2cc7443f23cce5f4828"}, + {file = "mypy-1.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2922d42e16d6de288022e5ca321cd0618b238cfc5570e0263e5ba0a77dbef56f"}, + {file = "mypy-1.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2ee2d57e01a7c35de00f4634ba1bbf015185b219e4dc5909e281016df43f5ee5"}, + {file = "mypy-1.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:973500e0774b85d9689715feeffcc980193086551110fd678ebe1f4342fb7c5e"}, + {file = "mypy-1.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a95fb17c13e29d2d5195869262f8125dfdb5c134dc8d9a9d0aecf7525b10c2c"}, + {file = "mypy-1.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1905f494bfd7d85a23a88c5d97840888a7bd516545fc5aaedff0267e0bb54e2f"}, + {file = "mypy-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:c9817fa23833ff189db061e6d2eff49b2f3b6ed9856b4a0a73046e41932d744f"}, + {file = "mypy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd"}, + {file = "mypy-1.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f"}, + {file = "mypy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464"}, + {file = "mypy-1.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8023ff13985661b50a5928fc7a5ca15f3d1affb41e5f0a9952cb68ef090b31ee"}, + {file = "mypy-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e"}, + {file = "mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22"}, + {file = "mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445"}, + {file = "mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d"}, + {file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5"}, + {file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036"}, + {file = "mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357"}, + {file = "mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf"}, + {file = "mypy-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e601a7fa172c2131bff456bb3ee08a88360760d0d2f8cbd7a75a65497e2df078"}, + {file = "mypy-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:712e962a6357634fef20412699a3655c610110e01cdaa6180acec7fc9f8513ba"}, + {file = "mypy-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95579473af29ab73a10bada2f9722856792a36ec5af5399b653aa28360290a5"}, + {file = "mypy-1.15.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f8722560a14cde92fdb1e31597760dc35f9f5524cce17836c0d22841830fd5b"}, + {file = "mypy-1.15.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fbb8da62dc352133d7d7ca90ed2fb0e9d42bb1a32724c287d3c76c58cbaa9c2"}, + {file = "mypy-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:d10d994b41fb3497719bbf866f227b3489048ea4bbbb5015357db306249f7980"}, + {file = "mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e"}, + {file = "mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43"}, ] [package.dependencies] -mypy-extensions = ">=0.4.3" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typing-extensions = ">=3.10" +mypy_extensions = ">=1.0.0" +typing_extensions = ">=4.6.0" [package.extras] dmypy = ["psutil (>=4.0)"] +faster-cache = ["orjson"] install-types = ["pip"] -python2 = ["typed-ast (>=1.4.0,<2)"] +mypyc = ["setuptools (>=50)"] reports = ["lxml"] [[package]] name = "mypy-extensions" -version = "0.4.3" -description = "Experimental type system extensions for programs checked with the mypy typechecker." -category = "dev" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." optional = false -python-versions = "*" +python-versions = ">=3.5" +groups = ["dev"] files = [ - {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"}, + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, ] [[package]] name = "packaging" -version = "21.3" +version = "24.2" description = "Core utilities for Python packages" -category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" +groups = ["dev"] files = [ - {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, - {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, + {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, + {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, ] -[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" +groups = ["dev"] files = [ {file = "pathspec-0.10.2-py3-none-any.whl", hash = "sha256:88c2606f2c1e818b978540f73ecc908e13999c6c3a383daf3705652ae79807a5"}, {file = "pathspec-0.10.2.tar.gz", hash = "sha256:8f6bf73e5758fd365ef5d58ce09ac7c27d2833a8d7da51712eac6e27e35141b0"}, @@ -275,9 +223,9 @@ files = [ 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" +groups = ["dev"] files = [ {file = "platformdirs-2.5.4-py3-none-any.whl", hash = "sha256:af0276409f9a02373d540bf8480021a048711d572745aef4b7842dad245eba10"}, {file = "platformdirs-2.5.4.tar.gz", hash = "sha256:1006647646d80f16130f052404c6b901e80ee4ed6bef6792e1f238a8969106f7"}, @@ -289,108 +237,142 @@ test = ["appdirs (==1.4.4)", "pytest (>=7.2)", "pytest-cov (>=4)", "pytest-mock [[package]] name = "pluggy" -version = "1.0.0" +version = "1.5.0" description = "plugin and hook calling mechanisms for python" -category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" +groups = ["dev"] files = [ - {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, - {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, ] [package.extras] dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] +[[package]] +name = "prompt-toolkit" +version = "3.0.50" +description = "Library for building powerful interactive command lines in Python" +optional = true +python-versions = ">=3.8.0" +groups = ["main"] +markers = "extra == \"cli\"" +files = [ + {file = "prompt_toolkit-3.0.50-py3-none-any.whl", hash = "sha256:9b6427eb19e479d98acff65196a307c555eb567989e6d88ebbb1b509d9779198"}, + {file = "prompt_toolkit-3.0.50.tar.gz", hash = "sha256:544748f3860a2623ca5cd6d2795e7a14f3d0e1c3c9728359013f79877fc89bab"}, +] + +[package.dependencies] +wcwidth = "*" + [[package]] name = "pycodestyle" -version = "2.10.0" +version = "2.12.1" description = "Python style guide checker" -category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" +groups = ["dev"] files = [ - {file = "pycodestyle-2.10.0-py2.py3-none-any.whl", hash = "sha256:8a4eaf0d0495c7395bdab3589ac2db602797d76207242c17d470186815706610"}, - {file = "pycodestyle-2.10.0.tar.gz", hash = "sha256:347187bdb476329d98f695c213d7295a846d1152ff4fe9bacb8a9590b8ee7053"}, + {file = "pycodestyle-2.12.1-py2.py3-none-any.whl", hash = "sha256:46f0fb92069a7c28ab7bb558f05bfc0110dac69a0cd23c61ea0040283a9d78b3"}, + {file = "pycodestyle-2.12.1.tar.gz", hash = "sha256:6838eae08bbce4f6accd5d5572075c63626a15ee3e6f842df996bf62f6d73521"}, ] [[package]] name = "pyflakes" -version = "3.0.1" +version = "3.2.0" description = "passive checker of Python programs" -category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" +groups = ["dev"] files = [ - {file = "pyflakes-3.0.1-py2.py3-none-any.whl", hash = "sha256:ec55bf7fe21fff7f1ad2f7da62363d749e2a470500eab1b555334b67aa1ef8cf"}, - {file = "pyflakes-3.0.1.tar.gz", hash = "sha256:ec8b276a6b60bd80defed25add7e439881c19e64850afd9b346283d4165fd0fd"}, + {file = "pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a"}, + {file = "pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f"}, ] [[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" +name = "pyserial" +version = "3.5" +description = "Python Serial Port Extension" +optional = true +python-versions = "*" +groups = ["main"] +markers = "extra == \"cli\"" files = [ - {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, - {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"}, + {file = "pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0"}, + {file = "pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb"}, ] [package.extras] -diagrams = ["jinja2", "railroad-diagrams"] +cp2110 = ["hidapi"] + +[[package]] +name = "pyserial-asyncio" +version = "0.6" +description = "Python Serial Port Extension - Asynchronous I/O support" +optional = true +python-versions = "*" +groups = ["main"] +markers = "extra == \"cli\"" +files = [ + {file = "pyserial-asyncio-0.6.tar.gz", hash = "sha256:b6032923e05e9d75ec17a5af9a98429c46d2839adfaf80604d52e0faacd7a32f"}, + {file = "pyserial_asyncio-0.6-py3-none-any.whl", hash = "sha256:de9337922619421b62b9b1a84048634b3ac520e1d690a674ed246a2af7ce1fc5"}, +] + +[package.dependencies] +pyserial = "*" [[package]] name = "pytest" -version = "7.2.0" +version = "8.3.5" description = "pytest: simple powerful testing with Python" -category = "dev" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" +groups = ["dev"] files = [ - {file = "pytest-7.2.0-py3-none-any.whl", hash = "sha256:892f933d339f068883b6fd5a459f03d85bfcb355e4981e146d2c7616c21fef71"}, - {file = "pytest-7.2.0.tar.gz", hash = "sha256:c4014eb40e10f11f355ad4e3c2fb2c6c6d1919c73f3b5a433de4708202cade59"}, + {file = "pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820"}, + {file = "pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845"}, ] [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\""} +pluggy = ">=1.5,<2" [package.extras] -testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] [[package]] -name = "tomli" -version = "2.0.1" -description = "A lil' TOML parser" -category = "dev" +name = "typing-extensions" +version = "4.13.2" +description = "Backported and Experimental Type Hints for Python 3.8+" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" +groups = ["dev"] files = [ - {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, - {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, + {file = "typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c"}, + {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"}, ] [[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" +name = "wcwidth" +version = "0.2.13" +description = "Measures the displayed width of unicode strings in a terminal" +optional = true +python-versions = "*" +groups = ["main"] +markers = "extra == \"cli\"" files = [ - {file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"}, - {file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"}, + {file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"}, + {file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"}, ] +[extras] +cli = ["prompt-toolkit", "pyserial-asyncio"] + [metadata] -lock-version = "2.0" -python-versions = "^3.9" -content-hash = "2dff72ad2f9ae813ff6ad7a10660b51c5a575fa76e475700b4c84c3bbb6f25c7" +lock-version = "2.1" +python-versions = "^3.12" +content-hash = "add51becfd2262f8681840316f4ea4d4dd11b1680829a99fe4a5fa2fa22733fa" diff --git a/pyproject.toml b/pyproject.toml index 41c470b..efd8fe4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ exclude = ''' ) ''' line-length = 88 -target-version = ["py39"] +target-version = ["py312"] [tool.isort] profile = "black" @@ -36,14 +36,19 @@ repository = "https://github.com/basilfx/python-tinylink" version = "2.0.0" [tool.poetry.dependencies] -python = "^3.9" +prompt-toolkit = { version = "^3.0.50", optional = true } +pyserial-asyncio = { version = "^0.6", optional = true } +python = "^3.12" [tool.poetry.group.dev.dependencies] -black = "^22.10.0" -isort = "^5.12.0" -flake8 = "^6.0.0" -flake8-mypy = "^17.8.0" -pytest = "^7.2.0" +black = "^25.0.0" +flake8 = "^7.1.0" +isort = "^6.0.0" +pytest = "^8.3.0" +mypy = "^1.15.0" + +[tool.poetry.extras] +cli = ["pyserial-asyncio", "prompt-toolkit"] [tool.poetry.scripts] tinylink = 'tinylink.cli:run' diff --git a/tests/test_tinylink.py b/tests/test_tinylink.py index a1ccfc2..424f373 100644 --- a/tests/test_tinylink.py +++ b/tests/test_tinylink.py @@ -5,27 +5,27 @@ class DummyHandle(object): """ - Dummy handler, so the TinyLink class can exchange data with itself. + Dummy handle, so the TinyLink class can exchange data with itself. """ - stream: bytes + buffer: bytes index: int length: int def __init__(self) -> None: - self.stream = bytearray() + self.buffer = bytearray() self.index = 0 self.length = 0 def read(self, size: int) -> bytes: - data = self.stream[self.index : min(self.length, self.index + size)] + data = self.buffer[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.buffer.extend(data) self.length += len(data) # Return number of bytes written. @@ -45,45 +45,145 @@ def test_basic(self): handle = DummyHandle() link = tinylink.TinyLink(handle) - message = b"Hello, this is a test" - size = link.write(message) + flags = 123 + payload = b"Hello, this is a test" + link.write_frame(tinylink.Frame(flags=flags, payload=payload)) self.assertEqual( - size, + len(handle.buffer), tinylink.LEN_PREAMBLE + tinylink.LEN_HEADER + tinylink.LEN_BODY - + len(message), + + len(payload), ) - # Read `size` bytes to receive the full frame, test it partially. - link.read(1) - link.read(1) - link.read(1) - frames = link.read(size - 3) + frame = link.read_frame() - self.assertEqual(len(frames), 1) - self.assertEqual(frames[0].data, message) + self.assertEqual(frame.flags, flags) + self.assertEqual(frame.payload, payload) - def test_multiple(self): + def test_flags_only(self): """ - Test multiple messages. + Test frame with only a flags and no payload. """ handle = DummyHandle() link = tinylink.TinyLink(handle) - size = 0 + flags = 123 + link.write_frame(tinylink.Frame(flags=flags)) - for i in range(5): - size += link.write(bytes([97 + i])) + self.assertEqual( + len(handle.buffer), + tinylink.LEN_PREAMBLE + tinylink.LEN_HEADER + tinylink.LEN_BODY, + ) + + frame = link.read_frame() + + self.assertEqual(frame.flags, flags) + + def test_escaping(self): + """ + Test escaping of flag (first byte of preamble) inside the header and + body. + """ + + handle = DummyHandle() + link = tinylink.TinyLink(handle) + + flags = tinylink.consts.FLAG + payload = bytes([tinylink.consts.ESCAPE]) + link.write_frame(tinylink.Frame(flags=flags, payload=payload)) + + # The frame will contain the flag five times: once in the header and + # once in the body. + overhead = 2 + + self.assertEqual( + len(handle.buffer), + tinylink.LEN_PREAMBLE + + tinylink.LEN_HEADER + + tinylink.LEN_BODY + + len(payload) + + overhead, + ) - frames = link.read(size) + frame = link.read_frame() - self.assertEqual(len(frames), 5) + self.assertEqual(frame.flags, flags) + self.assertEqual(frame.payload, payload) + + def test_escaping_last_byte(self): + """ + Test frame where the very last byte requires escaping (and the logic + could assume the frame is complete. This happens to be the case for + flag value 10. + """ + + handle = DummyHandle() + link = tinylink.TinyLink(handle) + + flags = 10 + link.write_frame(tinylink.Frame(flags=flags)) + + # The frame with flag value 10 and no payload requires the last frame + # checksum byte to be escaped. The overhead will be 1. + overhead = 1 + + self.assertEqual( + len(handle.buffer), + tinylink.LEN_PREAMBLE + tinylink.LEN_HEADER + tinylink.LEN_BODY + overhead, + ) + + frame = link.read_frame() + + self.assertEqual(frame.flags, flags) + + def test_escaping_max_payload_length(self): + """ + Test escaping with maximum payload length (which means that the + internal buffer should not overflow). + """ + + handle = DummyHandle() + link = tinylink.TinyLink(handle) + + flags = 123 + payload = bytes([tinylink.consts.FLAG] * link.max_payload_length) + link.write_frame(tinylink.Frame(flags=flags, payload=payload)) + + # Every payload byte will be esacped, so the overhead will be twice the + # number of payload bytes. + overhead = len(payload) + + self.assertEqual( + len(handle.buffer), + tinylink.LEN_PREAMBLE + + tinylink.LEN_HEADER + + tinylink.LEN_BODY + + len(payload) + + overhead, + ) + + frame = link.read_frame() + + self.assertEqual(frame.flags, flags) + self.assertEqual(frame.payload, payload) + + def test_multiple(self): + """ + Test multiple payloads. + """ + + handle = DummyHandle() + link = tinylink.TinyLink(handle) + + for i in range(5): + link.write(bytes([97 + i])) for i in range(5): - self.assertEqual(frames[i].data, bytes([97 + i])) + frame = link.read_frame() + self.assertEqual(frame.payload, bytes([97 + i])) def test_sync(self): """ @@ -94,13 +194,13 @@ def test_sync(self): link = tinylink.TinyLink(handle) garbage = b"Garbage here that does not synchronize." - message = b"Hi!" + payload = b"Hi!" - size = handle.write(garbage) + link.write(message) - frames = link.read(size) + handle.write(garbage) + link.write_frame(tinylink.Frame(payload=payload)) + frame = link.read_frame() - self.assertEqual(len(frames), 1) - self.assertEqual(frames[0].data, message) + self.assertEqual(frame.payload, payload) def test_sync_small(self): """ @@ -108,16 +208,16 @@ def test_sync_small(self): """ handle = DummyHandle() - link = tinylink.TinyLink(handle, max_length=4) + link = tinylink.TinyLink(handle, max_payload_length=4) garbage = b"Garbage here that does not synchronize." - message = b"Hi!" + payload = b"Hi!" - size = handle.write(garbage) + link.write(message) - frames = link.read(size) + handle.write(garbage) + link.write_frame(tinylink.Frame(payload=payload)) + frame = link.read_frame() - self.assertEqual(len(frames), 1) - self.assertEqual(frames[0].data, message) + self.assertEqual(frame.payload, payload) def test_size_fit(self): """ @@ -125,15 +225,14 @@ def test_size_fit(self): """ handle = DummyHandle() - link = tinylink.TinyLink(handle, max_length=4) + link = tinylink.TinyLink(handle, max_payload_length=4) - message = b"blub" + payload = b"blub" - size = link.write(message) - frames = link.read(size) + link.write_frame(tinylink.Frame(payload=payload)) + frame = link.read_frame() - self.assertEqual(len(frames), 1) - self.assertEqual(frames[0].data, message) + self.assertEqual(frame.payload, payload) def test_size_no_fit(self): """ @@ -141,33 +240,14 @@ def test_size_no_fit(self): """ handle = DummyHandle() - link = tinylink.TinyLink(handle, max_length=2) + link = tinylink.TinyLink(handle, max_payload_length=2) - message = b"blub" + payload = b"blub" with self.assertRaises(ValueError): - link.write(message) - - def test_damaged_a(self): - """ - Test damaged frame (in total) that will return a Frame with damages is - True. - """ - - handle = DummyHandle() - link = tinylink.TinyLink(handle) - - message = b"Hello, this is a test" - - size = link.write(message) - handle.stream[-tinylink.LEN_CRC :] = [0x00] * tinylink.LEN_CRC - frames = link.read(size) - - self.assertEqual(len(frames), 1) - self.assertEqual(frames[0].data, message) - self.assertEqual(frames[0].damaged, True) + link.write_frame(tinylink.Frame(payload=payload)) - def test_damaged_b(self): + def test_damaged(self): """ Test damaged frame (header) that will not return anything. """ @@ -175,10 +255,10 @@ def test_damaged_b(self): handle = DummyHandle() link = tinylink.TinyLink(handle) - message = b"Hello, this is a test" + payload = b"Hello, this is a test" - size = link.write(message) - handle.stream[tinylink.LEN_PREAMBLE + tinylink.LEN_HEADER - 1] = 0x00 - frames = link.read(size) + link.write_frame(tinylink.Frame(payload=payload)) + handle.buffer[tinylink.LEN_PREAMBLE + tinylink.LEN_HEADER - 1] = 0x00 + frame = link.read_frame() - self.assertEqual(len(frames), 0) + self.assertEqual(frame, None) diff --git a/tinylink/__init__.py b/tinylink/__init__.py index b419def..cefb562 100644 --- a/tinylink/__init__.py +++ b/tinylink/__init__.py @@ -1,4 +1,4 @@ from .consts import * # noqa -from .link import Frame, TinyLink # noqa +from .link import AsyncTinyLink, Frame, TinyLink # noqa __version__ = "2.0.0" diff --git a/tinylink/cli.py b/tinylink/cli.py index 6fadcdd..43c5135 100644 --- a/tinylink/cli.py +++ b/tinylink/cli.py @@ -1,18 +1,19 @@ import argparse +import asyncio import csv -import select import struct import sys -import time from io import StringIO from typing import Optional -import tinylink +import serial_asyncio +from prompt_toolkit import PromptSession +from prompt_toolkit.completion import WordCompleter +from prompt_toolkit.history import FileHistory +from prompt_toolkit.patch_stdout import patch_stdout -try: - import serial -except ImportError: - serial = None +import tinylink +from tinylink.utils import create_async_handle def parse_arguments(argv: list[str]) -> argparse.Namespace: @@ -24,16 +25,18 @@ def parse_arguments(argv: list[str]) -> argparse.Namespace: # 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( - "--length", type=int, default=2**16, help="maximum length of frame" + "baudrate", type=int, nargs="?", default=9600, help="serial baudrate" + ) + parser.add_argument( + "--length", type=int, default=2**16, help="maximum length of payload" ) parser.add_argument( "--endianness", type=str, default="little", choices=["big", "little"], - help="maximum length of frame", + help="endianness of link", ) # Parse command line. @@ -69,93 +72,113 @@ def dump(prefix: str, data: bytes) -> str: return "\n".join(result) -def process_link(link: tinylink.TinyLink) -> None: +async def handle_link(link: tinylink.AsyncTinyLink) -> None: """ - Process incoming link data. + Process incoming frames. """ - frames = link.read() + while True: + frame = await link.read_frame() - # 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) + if frame: + sys.stdout.write(">>> # Flags = 0x%04x\n" % frame.flags) - sys.stdout.write("### Length = %d\n" % len(frame.data)) - sys.stdout.write(dump("<<<", frame.data) + "\n\n") + if frame.payload: + sys.stdout.write(">>> # Length = %d\n" % len(frame.payload)) + sys.stdout.write(dump(">>>", frame.payload) + "\n\n") -def process_stdin(link: tinylink.TinyLink) -> Optional[bool]: +async def handle_console(link: tinylink.AsyncTinyLink) -> Optional[bool]: """ - Process stdin commands. + Process console inputs. """ - command = sys.stdin.readline() + completer = WordCompleter(["\\flags=", "\\pack=", "\\wait=", "\\repeat="]) + + session = PromptSession(history=FileHistory(".history")) + + while True: + with patch_stdout(): + try: + line = await session.prompt_async("--> ", completer=completer) + except KeyboardInterrupt: + continue + + if not line: + continue + + await parse_line(link, line) - # End of file. - if len(command) == 0: - return False +async def parse_line(link: tinylink.AsyncTinyLink, line: str) -> None: # Abuse the CSV module as a command parser, because CSV-like arguments are # possible. - items = list(csv.reader(StringIO(command.strip()), delimiter=" ")) + items = list(csv.reader(StringIO(line.strip()), delimiter=" ")) if not items: return - # Initialize state and start parsing. frame = tinylink.Frame() repeat = 1 pack = "B" - try: - for item in items[0]: - if item[0] == "\\": + for item in items[0]: + if item == "": + continue + elif item[0] == "\\": + try: k, v = item[1:].split("=") if k == "flags": - frame.flags = int(v, 0) + frame.flags = int(v) & 0xFFFF elif k == "pack": pack = v elif k == "wait": - time.sleep(float(v)) + await asyncio.sleep(float(v)) elif k == "repeat": repeat = int(v) else: - raise ValueError("Unkown option: %s" % k) - else: + raise ValueError(f"Unknown modifier: {k}") + except Exception as e: # noqa + sys.stdout.write(f"Unable to parse modifier: {e}\n") + return + else: + try: + # Assume it is a float. + value = struct.pack(link.endianness + pack, float(item)) + except: # noqa try: - # Assume it is a float. - value = struct.pack(link.endianness + pack, float(item)) + # Assume it is an int. + value = struct.pack(link.endianness + pack, int(item, 0)) except: # noqa try: - # Assume it is an int. - value = struct.pack(link.endianness + pack, int(item, 0)) - except ValueError: # Assume it is a byte string. item_bytes = item.encode("ascii") value = struct.pack( link.endianness + str(len(item_bytes)) + "s", item_bytes ) + except Exception as e: # noqa + sys.stdout.write( + "Unable to parse input as float, integer or byte\n" + ) + return - # Concat to frame. - frame.data = (frame.data or bytes()) + value - except Exception as e: - sys.stdout.write("Parse exception: %s\n" % e) + # Concat to frame. + frame.payload = (frame.payload or bytes()) + value - # Output the data. - for i in range(repeat): - sys.stdout.write("### Flags = 0x%04x\n" % frame.flags) + # Output the frame. + for _ in range(repeat): + sys.stdout.write("<<< # Flags = 0x%04x\n" % frame.flags) - if frame.data: - sys.stdout.write("### Length = %d\n" % len(frame.data)) - sys.stdout.write(dump(">>>", frame.data) + "\n\n") + if frame.payload: + sys.stdout.write("<<< # Length = %d\n" % len(frame.payload)) + sys.stdout.write(dump("<<<", frame.payload) + "\n\n") - # Send the frame. + # Write the frame. try: - link.write_frame(frame) + await link.write_frame(frame) except ValueError as e: - sys.stdout.write("Could not send frame: %s\n" % e) + sys.stdout.write(f"Could not write frame: {e}\n") return @@ -164,21 +187,14 @@ def run() -> None: Entry point for console script. """ - sys.exit(main(sys.argv)) + sys.exit(asyncio.run(main(sys.argv))) -def main(argv: list[str]) -> int: +async def main(argv: list[str]) -> int: """ Main entry point. """ - if serial is None: - sys.stdout.write( - "TinyLink CLI uses PySerial, but it is not installed. Please " - "install this first.\n" - ) - return 1 - # Parse arguments. arguments = parse_arguments(argv) @@ -188,32 +204,18 @@ def main(argv: list[str]) -> int: endianness = tinylink.BIG_ENDIAN # Open serial port and create link. - handle = serial.Serial(arguments.port, baudrate=arguments.baudrate) - link = tinylink.TinyLink(handle, max_length=arguments.length, endianness=endianness) - - # Loop until finished. - try: - # Input indicator. - sys.stdout.write("--> ") - sys.stdout.flush() - - while True: - readables, _, _ = select.select([handle, sys.stdin], [], []) - - # Read from serial port. - if handle in readables: - process_link(link) - - # Read from stdin. - if sys.stdin in readables: - if process_stdin(link) is False: - break - - # Input indicator. - sys.stdout.write("--> ") - sys.stdout.flush() - except KeyboardInterrupt: - handle.close() + reader, writer = await serial_asyncio.open_serial_connection( + url=arguments.port, baudrate=arguments.baudrate + ) + + link = tinylink.AsyncTinyLink( + create_async_handle(reader, writer), + max_payload_length=arguments.length, + endianness=endianness, + ) + + # Start co-routines and wait until finished. + await asyncio.gather(handle_console(link), handle_link(link)) # Done. return 0 diff --git a/tinylink/consts.py b/tinylink/consts.py index e83cf85..1cb525b 100644 --- a/tinylink/consts.py +++ b/tinylink/consts.py @@ -1,6 +1,10 @@ -# This can be anything, and is used to synchronize a frame. +# This can be anything, but must be an alternating pattern. PREAMBLE = 0xAA55AA55 +# The escape character is used for byte-stuffing of the header and body. +FLAG = 0xAA +ESCAPE = 0x1B + # Endianness. LITTLE_ENDIAN = "<" BIG_ENDIAN = ">" @@ -10,17 +14,13 @@ 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_CRC = 4 LEN_BODY = LEN_CRC diff --git a/tinylink/link.py b/tinylink/link.py index a25d8f9..0b0e20c 100644 --- a/tinylink/link.py +++ b/tinylink/link.py @@ -1,85 +1,72 @@ import struct -from typing import Protocol +from typing import Optional -from . import consts, utils +from . import consts, types, utils -class Handle(Protocol): - """ - Protocol for a handler. - """ +class Frame: + """Represents a frame. A frame contains flags and an optional payload.""" - def read(self, size: int) -> bytes: - """ " - Read up to `size` bytes. - """ + flags: int + payload: Optional[bytes] - def write(self, data: bytes) -> int: - """ - Write data and return the number of bytes written. + def __init__(self, flags: int = 0x0000, payload: Optional[bytes] = None) -> None: + """Initialize a new frame with optional flags and payload. + + Args: + flags: The frame flags (default: 0x0000). + payload: Optional payload data (default: None). """ + if flags & 0xFFFF != flags: + raise ValueError("Flags must be in range 0 - 65535.") + self.flags = flags + self.payload = payload -class Frame: - """ - Represents a frame. - """ + def __repr__(self) -> str: + """Return a string representation of the frame. - data: bytes - flags: int - damaged: int + Returns: + A string containing the frame's flags and payload if present. + """ + class_name = self.__class__.__name__ - 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.") + if self.payload is None: + return f"{class_name}(flags={self.flags:04x})" else: - data = bytes() + return f"{class_name}({self.payload}, flags={self.flags:04x})" - 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 BaseTinyLink: + """TinyLink state machine for streaming communication with low-speed + embedded applications that only use RX/TX. + A link exchanges frames. Frames exist out of a preamble, a header and a + body. The header contain flags and length information. The body contains + the optional payload. Checksums are included to detect errors as fast as + possible (this can happen when you jump right into a stream of bytes, + without being synchronized). The header and body are escaped. -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). + The payload can be up to 65536 bytes. A minimal frame (without payload and + escaping) is 13 bytes. - 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. + The protocol favors reliability and simplicity over speed and complexity. """ - handle: Handle endianness: str - max_length: int - ignore_damaged: bool + max_payload_length: int + + state: int + buffer: bytearray + index: int + unescaping: bool def __init__( self, - handle: Handle, endianness: str = consts.LITTLE_ENDIAN, - max_length: int = 2 ** (consts.LEN_LENGTH * 8), - ignore_damaged: bool = False, + max_payload_length: int = 2 ** (consts.LEN_LENGTH * 8) - 1, ) -> None: - """ - Construct a new TinyLink state machine. A state machine takes a handle, - which provides a `read` and `write` method. + """Construct a new TinyLink state machine. The endianness is either `consts.LITTLE_ENDIAN` or `consts.BIG_ENDIAN`. While big endian is common for networking, little endian is directly @@ -87,160 +74,306 @@ def __init__( 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`. + `max_payload_length`. In case a frame is received that exceeds this + value, it will be silently ignored. """ - self.handle = handle self.endianness = endianness - self.max_length = max_length - self.ignore_damaged = ignore_damaged + self.max_payload_length = max_payload_length - # Set initial state + # 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.buffer = bytearray(max_length + consts.LEN_HEADER + consts.LEN_BODY) + # Pre-allocate byte buffer that fits header + body. The premable will + # be cleared when it is detected, so it does not need space. + self.buffer = bytearray( + max_payload_length + consts.LEN_HEADER + consts.LEN_BODY + ) self.index = 0 + self.unescaping = False - def write_frame(self, frame: Frame) -> int: - """ - Write a frame via the handle. + def _write_frame(self, frame: Frame) -> bytes: + """Construct the bytes of a frame to write to a handle. + + Args: + frame: The frame to write. + + Raises: + ValueError: If the payload length exceeds the maximum length. + + Returns: + The bytes to write to a handle. """ - result = bytearray() - length = len(frame.data or []) + flags = frame.flags + payload = frame.payload or bytes() + length = len(payload) - # Check length of message. - if length > self.max_length: + # Check length of payload. + if length > self.max_payload_length: raise ValueError( - "Message length %d exceeds max length %d" % (length, self.max_length) + "Message length of %d bytes exceeds maximum payload length of %d bytes" + % (length, self.max_payload_length) ) + # pack preamble. + preamble = struct.pack(self.endianness + "I", consts.PREAMBLE) + # Pack header. - checksum_header = utils.checksum_header(frame.flags, length) - result += struct.pack( - self.endianness + "IHHB", - consts.PREAMBLE, - frame.flags, + checksum_header = self._checksum_header(flags, length) + header = struct.pack( + self.endianness + "HHB", + 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 - ) + # Pack body. + checksum_frame = self._checksum_frame(header, payload) + body = struct.pack( + self.endianness + str(length) + "sI", payload, checksum_frame + ) - # Write to file. - return self.handle.write(result) + # Done. + return preamble + self._escape(header + body) - def write(self, data: bytes, flags: int = consts.FLAG_NONE) -> int: - """ - Shorthand for `write_frame(Frame(data, flags=flags))`. - """ + def _read_byte(self, byte: bytes) -> Optional[Frame]: + """Process a byte that was read. - return self.write_frame(Frame(data, flags=flags)) + Args: + byte: The byte read from a source. - 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. + Returns: + A `Frame`, if the frame is complete (i.e. all bytes received), or + `None` if not yet complete. """ - # 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 buffer. - self.buffer[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 buffer is full. Copy last four - # bytes, because the next byte may form the preamble - # together with the last three bytes. - self.buffer[0:4] = self.buffer[-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 - ) + result = None - 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, - ) + # Unescape and append to buffer. + if self.state in {consts.WAITING_FOR_HEADER, consts.WAITING_FOR_BODY}: + if self.unescaping: + self.index = self.index - 1 + self.unescaping = False + else: + if byte[0] == consts.ESCAPE: + self.unescaping = True - # 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)) + self.buffer[self.index] = byte[0] + self.index += 1 + if self.unescaping: + return + + # Decide what to do. + if self.state == consts.WAITING_FOR_PREAMBLE: + if self.index >= consts.LEN_PREAMBLE: + (preamble,) = struct.unpack_from( + self.endianness + "I", self.buffer[self.index - 4 : self.index] + ) + + if preamble == consts.PREAMBLE: + # Preamble found. Start reading the header. + self.index = 0 + self.state = consts.WAITING_FOR_HEADER + elif ( + self.index + == self.max_payload_length + consts.LEN_HEADER + consts.LEN_BODY + ): + # Preamble not found and buffer is full. Copy last four + # bytes, because the next byte may form the preamble + # together with the last three bytes. + self.buffer[0:4] = self.buffer[-4:] + self.index = 4 + + elif self.state == consts.WAITING_FOR_HEADER: + if self.index == consts.LEN_HEADER: + flags, length, checksum_header = struct.unpack_from( + self.endianness + "HHB", self.buffer + ) + + # Verify checksum. + if ( + checksum_header == self._checksum_header(flags, length) + and length <= self.max_payload_length + ): + self.state = consts.WAITING_FOR_BODY + else: # Reset to start state. self.index = 0 self.state = consts.WAITING_FOR_PREAMBLE - # Decrement number of bytes to read. - limit -= 1 + elif self.state == consts.WAITING_FOR_BODY: + flags, length, _ = struct.unpack_from(self.endianness + "HHB", self.buffer) + + if self.index == length + consts.LEN_HEADER + consts.LEN_CRC: + payload, checksum_frame = struct.unpack_from( + self.endianness + str(length) + "sI", + self.buffer, + consts.LEN_HEADER, + ) + + # Verify checksum. + if checksum_frame == self._checksum_frame( + self.buffer[: consts.LEN_HEADER], payload + ): + result = Frame(flags=flags, payload=payload) + + # Reset to start state. + self.index = 0 + self.state = consts.WAITING_FOR_PREAMBLE # Done. - return frames + return result + + def _checksum_header(self, flags, length) -> int: + """Calculate the header checksum. + + Args: + flags: The flags field to checksum. + length: The length field to checksum. + + Returns: + A single-byte checksum of the header fields. + """ + + a = (flags & 0x00FF) >> 0 + b = (flags & 0xFF00) >> 8 + c = (length & 0x00FF) >> 0 + d = (length & 0xFF00) >> 8 + + return a ^ b ^ c ^ d + + def _checksum_frame(self, header: bytes, payload: bytes) -> int: + """Calculate the frame checksum (header and payload). + + Args: + header: The frame header. + payload: The frame payload. + + Returns: + A four-byte CRC32 checksum of the header and body. + """ + + return utils.crc32(header + payload) & 0xFFFFFFFF + + def _escape(self, data: bytes) -> bytes: + """Escape the data using byte-stuffing, so that the data can safely + contain the preamble. + + Args: + data: The data to escape. + + Returns: + The escaped data. + """ + + buffer = bytearray(len(data) * 2) + index = 0 + + for byte in data: + if byte == consts.FLAG: + buffer[index] = consts.ESCAPE + buffer[index + 1] = consts.FLAG + index += 2 + elif byte == consts.ESCAPE: + buffer[index] = consts.ESCAPE + buffer[index + 1] = consts.ESCAPE + index += 2 + else: + buffer[index] = byte + index += 1 + + return bytes(buffer[:index]) + + +class AsyncTinyLink(BaseTinyLink): + + handle: types.AsyncHandle + + def __init__(self, handle: types.AsyncHandle, *args, **kwargs) -> None: + """Construct a new asynchronous TinyLink instance. + + See `TinyLink.__init__` for more information. + + Args: + handle: A handle that provides a `read` and `write` method. Note + that the handle is just a wrapper and does not 'own' it. + """ + super().__init__(*args, **kwargs) + + self.handle = handle + + async def read_frame(self) -> Optional[Frame]: + while True: + byte = await self.handle.read(1) + + # Handle end-of-file. + if not byte: + return None + + result = self._read_byte(byte) + + if result: + return result + + async def read(self) -> bytes: + frame = await self.read_frame() + + # Handle end-of-file. + if not frame: + return bytes() + + return frame.payload or bytes() + + async def write_frame(self, frame: Frame) -> None: + await self.handle.write(self._write_frame(frame)) + + async def write(self, payload: bytearray, flags: int = 0x0000) -> None: + await self.write_frame(Frame(payload=payload, flags=flags)) + + +class TinyLink(BaseTinyLink): + + handle: types.Handle + + def __init__(self, handle: types.Handle, *args, **kwargs) -> None: + """Construct a new synchronous TinyLink instance. + + See `TinyLink.__init__` for more information. + + Args: + handle: A handle that provides a `read` and `write` method. Note + that the handle is just a wrapper and does not 'own' it. + """ + super().__init__(*args, **kwargs) + + self.handle = handle + + def read_frame(self) -> Optional[Frame]: + while True: + byte = self.handle.read(1) + + # Handle end-of-file. + if not byte: + return None + + result = self._read_byte(byte) + + if result: + return result + + def read(self) -> bytes: + frame = self.read_frame() + + # Handle end-of-file. + if not frame: + return bytes() + + return frame.payload or bytes() + + def write_frame(self, frame: Frame) -> None: + self.handle.write(self._write_frame(frame)) + + def write(self, payload: bytes, flags: int = 0x0000) -> None: + self.write_frame(Frame(payload=payload, flags=flags)) diff --git a/tinylink/types.py b/tinylink/types.py new file mode 100644 index 0000000..d3c5133 --- /dev/null +++ b/tinylink/types.py @@ -0,0 +1,45 @@ +from typing import Protocol + + +class AsyncHandle(Protocol): + """Protocol for an asynchronous handle.""" + + async def read(self, size: int) -> bytes: + """Read up to `size` bytes from the handle. + + Args: + size: Maximum number of bytes to read. + + Returns: + The bytes read from the handle. May be less than `size` if fewer + bytes are available. + """ + + async def write(self, data: bytes) -> None: + """Write data to the handle. + + Args: + data: The bytes to write to the handle. + """ + + +class Handle(Protocol): + """Protocol for a synchronous handle.""" + + def read(self, size: int) -> bytes: + """Read up to `size` bytes from the handle. + + Args: + size: Maximum number of bytes to read. + + Returns: + The bytes read from the handle. May be less than `size` if fewer + bytes are available. + """ + + def write(self, data: bytes) -> None: + """Write data to the handle. + + Args: + data: The bytes to write to the handle. + """ diff --git a/tinylink/utils.py b/tinylink/utils.py index 69c0773..1f392a8 100644 --- a/tinylink/utils.py +++ b/tinylink/utils.py @@ -1,25 +1,34 @@ +import asyncio + +from . import types + CRC32_POLYNOMIAL = 0xEDB88320 CRC32_INITIAL = 0x00000000 -def crc32(buf): - """ - Calculate CRC32 of given input. +def crc32(buf) -> int: + """Calculate CRC32 of given input. + + Args: + buf: the buffer to calculate CRC32 over. + + Returns: + A four-byte CRC32 checksum. """ result = CRC32_INITIAL def crc32_value(c): - ulTemp1 = (result >> 8) & 0x00FFFFFF - ulCRC = (result ^ c) & 0xFF + temp = (result >> 8) & 0x00FFFFFF + crc = (result ^ c) & 0xFF - for i in range(8): - if ulCRC & 0x01: - ulCRC = (ulCRC >> 1) ^ CRC32_POLYNOMIAL + for _ in range(8): + if crc & 0x01: + crc = (crc >> 1) ^ CRC32_POLYNOMIAL else: - ulCRC = ulCRC >> 1 + crc = crc >> 1 - return ulTemp1 ^ ulCRC + return temp ^ crc # Execute function for each byte. for b in buf: @@ -28,22 +37,26 @@ def crc32_value(c): return result -def checksum_header(flags, length): - """ - Calculate checksum over the header. - """ +def create_async_handle( + reader: asyncio.StreamReader, writer: asyncio.StreamWriter +) -> types.AsyncHandle: + """Create a handle from a `asyncio.StreamReader` and asyncio.StreamWriter` + pair. - a = (flags & 0x00FF) >> 0 - b = (flags & 0xFF00) >> 8 - c = (length & 0x00FF) >> 0 - d = (length & 0xFF00) >> 8 + Args: + reader: The reader instance. + writer: The writer instance. - return a ^ b ^ c ^ d + Returns: + A handle that can be used with `link.AsyncTinyLink`. + """ + class Handle: + async def read(self, size: int) -> bytes: + return await reader.read(size) -def checksum_frame(data, checksum_header): - """ - Calculate checksum of both the checksum header and the data. - """ + async def write(self, data: bytes) -> None: + writer.write(data) + await writer.drain() - return crc32(memoryview(data).tobytes() + bytearray([checksum_header])) & 0xFFFFFFFF + return Handle()