From 63eb7641b2690fdd559dd38b8d0ecd8242d30bd5 Mon Sep 17 00:00:00 2001 From: amitlissack Date: Mon, 4 Oct 2021 12:34:47 -0400 Subject: [PATCH] feat(can): replace uart script (#8450) * message payloads. * identify uses payload object. * Create can script. * add todo. * tests. * add mock package. * lint. * memoize. * clean up asyncio stuff. * remove deprecated identify script. can_comm supercedes it. * cosmetics. * async. * add scripts to readme * lint --- hardware/Pipfile | 3 +- hardware/Pipfile.lock | 167 +++++++------ hardware/README.md | 31 ++- .../drivers/can_bus/message.py | 4 +- .../drivers/can_bus/messages/__init__.py | 1 + .../can_bus/messages/message_definitions.py | 93 +++++++ .../drivers/can_bus/messages/messages.py | 44 ++++ .../drivers/can_bus/messages/payloads.py | 55 +++++ .../opentrons_hardware/scripts/can_args.py | 25 ++ .../opentrons_hardware/scripts/can_comm.py | 226 ++++++++++++++++++ .../opentrons_hardware/scripts/identify.py | 90 ------- hardware/opentrons_hardware/utils/__init__.py | 2 + .../utils/binary_serializable.py | 17 +- hardware/setup.py | 2 +- hardware/tests/test_scripts/__init__.py | 1 + hardware/tests/test_scripts/test_can_comm.py | 119 +++++++++ 16 files changed, 708 insertions(+), 172 deletions(-) create mode 100644 hardware/opentrons_hardware/drivers/can_bus/messages/__init__.py create mode 100644 hardware/opentrons_hardware/drivers/can_bus/messages/message_definitions.py create mode 100644 hardware/opentrons_hardware/drivers/can_bus/messages/messages.py create mode 100644 hardware/opentrons_hardware/drivers/can_bus/messages/payloads.py create mode 100644 hardware/opentrons_hardware/scripts/can_args.py create mode 100644 hardware/opentrons_hardware/scripts/can_comm.py delete mode 100644 hardware/opentrons_hardware/scripts/identify.py create mode 100644 hardware/tests/test_scripts/__init__.py create mode 100644 hardware/tests/test_scripts/test_can_comm.py diff --git a/hardware/Pipfile b/hardware/Pipfile index aef24272728..25afb7cd200 100644 --- a/hardware/Pipfile +++ b/hardware/Pipfile @@ -18,7 +18,8 @@ flake8 = "~=3.9.0" flake8-annotations = "~=2.6.2" flake8-docstrings = "~=1.6.0" flake8-noqa = "~=1.1.0" - +mock = "~=4.0.2" +types-mock = "==4.0.1" [requires] python_version = "3.7" diff --git a/hardware/Pipfile.lock b/hardware/Pipfile.lock index 75b5b725948..068055eb291 100644 --- a/hardware/Pipfile.lock +++ b/hardware/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "29e93b2d0aa4f6ddefa96b18c394c22bfcc1a0b9698b74b2f4485d3f45a17cbe" + "sha256": "b220b070fabfe99df61f90e3a516ef411002841e0b0c7e19182fa19ed80aada4" }, "pipfile-spec": 6, "requires": { @@ -201,7 +201,7 @@ "sha256:f0b278ce10936db1a37e6954e15a3730bea96a0997c26d7fee88e6c396c2086d", "sha256:f11642dddbb0253cc8853254301b51390ba0081750a8ac03f20ea8103f0c56b6" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_full_version < '4.0.0'", "version": "==5.5" }, "flake8": { @@ -248,7 +248,7 @@ "sha256:b618b6d2d5ffa2f16add5697cf57a46c76a56229b0ed1c438322e4e95645bd15", "sha256:f284b3e11256ad1e5d03ab86bb2ccd6f5339688ff17a4d797a0fe7df326f23b1" ], - "markers": "python_version < '3.8' and python_version < '3.8'", + "markers": "python_version < '3.8'", "version": "==4.8.1" }, "iniconfig": { @@ -265,6 +265,14 @@ ], "version": "==0.6.1" }, + "mock": { + "hashes": [ + "sha256:122fcb64ee37cfad5b3f48d7a7d51875d7031aaf3d8be7c42e2bee25044eee62", + "sha256:7d3fbbde18228f4ff2f1f119a45cdffa458b4c0dee32eb4d2bb2f82554bac7bc" + ], + "index": "pypi", + "version": "==4.0.3" + }, "multidict": { "hashes": [ "sha256:018132dbd8688c7a69ad89c4a3f39ea2f9f33302ebe567a879da8f4ca73f0d0a", @@ -310,31 +318,32 @@ }, "mypy": { "hashes": [ - "sha256:0d2fc8beb99cd88f2d7e20d69131353053fbecea17904ee6f0348759302c52fa", - "sha256:2b216eacca0ec0ee124af9429bfd858d5619a0725ee5f88057e6e076f9eb1a7b", - "sha256:319ee5c248a7c3f94477f92a729b7ab06bf8a6d04447ef3aa8c9ba2aa47c6dcf", - "sha256:3e0c159a7853e3521e3f582adb1f3eac66d0b0639d434278e2867af3a8c62653", - "sha256:5615785d3e2f4f03ab7697983d82c4b98af5c321614f51b8f1034eb9ebe48363", - "sha256:5ff616787122774f510caeb7b980542a7cc2222be3f00837a304ea85cd56e488", - "sha256:6f8425fecd2ba6007e526209bb985ce7f49ed0d2ac1cc1a44f243380a06a84fb", - "sha256:74f5aa50d0866bc6fb8e213441c41e466c86678c800700b87b012ed11c0a13e0", - "sha256:90b6f46dc2181d74f80617deca611925d7e63007cf416397358aa42efb593e07", - "sha256:947126195bfe4709c360e89b40114c6746ae248f04d379dca6f6ab677aa07641", - "sha256:a301da58d566aca05f8f449403c710c50a9860782148332322decf73a603280b", - "sha256:aa9d4901f3ee1a986a3a79fe079ffbf7f999478c281376f48faa31daaa814e86", - "sha256:b9150db14a48a8fa114189bfe49baccdff89da8c6639c2717750c7ae62316738", - "sha256:b95068a3ce3b50332c40e31a955653be245666a4bc7819d3c8898aa9fb9ea496", - "sha256:ca7ad5aed210841f1e77f5f2f7d725b62c78fa77519312042c719ed2ab937876", - "sha256:d16c54b0dffb861dc6318a8730952265876d90c5101085a4bc56913e8521ba19", - "sha256:e0202e37756ed09daf4b0ba64ad2c245d357659e014c3f51d8cd0681ba66940a", - "sha256:e1c84c65ff6d69fb42958ece5b1255394714e0aac4df5ffe151bc4fe19c7600a", - "sha256:e32b7b282c4ed4e378bba8b8dfa08e1cfa6f6574067ef22f86bee5b1039de0c9", - "sha256:e3b8432f8df19e3c11235c4563a7250666dc9aa7cdda58d21b4177b20256ca9f", - "sha256:e497a544391f733eca922fdcb326d19e894789cd4ff61d48b4b195776476c5cf", - "sha256:f5fdf935a46aa20aa937f2478480ebf4be9186e98e49cc3843af9a5795a49a25" + "sha256:088cd9c7904b4ad80bec811053272986611b84221835e079be5bcad029e79dd9", + "sha256:0aadfb2d3935988ec3815952e44058a3100499f5be5b28c34ac9d79f002a4a9a", + "sha256:119bed3832d961f3a880787bf621634ba042cb8dc850a7429f643508eeac97b9", + "sha256:1a85e280d4d217150ce8cb1a6dddffd14e753a4e0c3cf90baabb32cefa41b59e", + "sha256:3c4b8ca36877fc75339253721f69603a9c7fdb5d4d5a95a1a1b899d8b86a4de2", + "sha256:3e382b29f8e0ccf19a2df2b29a167591245df90c0b5a2542249873b5c1d78212", + "sha256:42c266ced41b65ed40a282c575705325fa7991af370036d3f134518336636f5b", + "sha256:53fd2eb27a8ee2892614370896956af2ff61254c275aaee4c230ae771cadd885", + "sha256:704098302473cb31a218f1775a873b376b30b4c18229421e9e9dc8916fd16150", + "sha256:7df1ead20c81371ccd6091fa3e2878559b5c4d4caadaf1a484cf88d93ca06703", + "sha256:866c41f28cee548475f146aa4d39a51cf3b6a84246969f3759cb3e9c742fc072", + "sha256:a155d80ea6cee511a3694b108c4494a39f42de11ee4e61e72bc424c490e46457", + "sha256:adaeee09bfde366d2c13fe6093a7df5df83c9a2ba98638c7d76b010694db760e", + "sha256:b6fb13123aeef4a3abbcfd7e71773ff3ff1526a7d3dc538f3929a49b42be03f0", + "sha256:b94e4b785e304a04ea0828759172a15add27088520dc7e49ceade7834275bedb", + "sha256:c0df2d30ed496a08de5daed2a9ea807d07c21ae0ab23acf541ab88c24b26ab97", + "sha256:c6c2602dffb74867498f86e6129fd52a2770c48b7cd3ece77ada4fa38f94eba8", + "sha256:ceb6e0a6e27fb364fb3853389607cf7eb3a126ad335790fa1e14ed02fba50811", + "sha256:d9dd839eb0dc1bbe866a288ba3c1afc33a202015d2ad83b31e875b5905a079b6", + "sha256:e4dab234478e3bd3ce83bac4193b2ecd9cf94e720ddd95ce69840273bf44f6de", + "sha256:ec4e0cd079db280b6bdabdc807047ff3e199f334050db5cbb91ba3e959a67504", + "sha256:ecd2c3fe726758037234c93df7e98deb257fd15c24c9180dacf1ef829da5f921", + "sha256:ef565033fa5a958e62796867b1df10c40263ea9ded87164d67572834e57a174d" ], "index": "pypi", - "version": "==0.800" + "version": "==0.910" }, "mypy-extensions": { "hashes": [ @@ -403,7 +412,7 @@ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2'", + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.4.7" }, "pytest": { @@ -432,49 +441,49 @@ }, "regex": { "hashes": [ - "sha256:04f6b9749e335bb0d2f68c707f23bb1773c3fb6ecd10edf0f04df12a8920d468", - "sha256:08d74bfaa4c7731b8dac0a992c63673a2782758f7cfad34cf9c1b9184f911354", - "sha256:0fc1f8f06977c2d4f5e3d3f0d4a08089be783973fc6b6e278bde01f0544ff308", - "sha256:121f4b3185feaade3f85f70294aef3f777199e9b5c0c0245c774ae884b110a2d", - "sha256:1413b5022ed6ac0d504ba425ef02549a57d0f4276de58e3ab7e82437892704fc", - "sha256:1743345e30917e8c574f273f51679c294effba6ad372db1967852f12c76759d8", - "sha256:28fc475f560d8f67cc8767b94db4c9440210f6958495aeae70fac8faec631797", - "sha256:31a99a4796bf5aefc8351e98507b09e1b09115574f7c9dbb9cf2111f7220d2e2", - "sha256:328a1fad67445550b982caa2a2a850da5989fd6595e858f02d04636e7f8b0b13", - "sha256:473858730ef6d6ff7f7d5f19452184cd0caa062a20047f6d6f3e135a4648865d", - "sha256:4cde065ab33bcaab774d84096fae266d9301d1a2f5519d7bd58fc55274afbf7a", - "sha256:5f6a808044faae658f546dd5f525e921de9fa409de7a5570865467f03a626fc0", - "sha256:610b690b406653c84b7cb6091facb3033500ee81089867ee7d59e675f9ca2b73", - "sha256:66256b6391c057305e5ae9209941ef63c33a476b73772ca967d4a2df70520ec1", - "sha256:6eebf512aa90751d5ef6a7c2ac9d60113f32e86e5687326a50d7686e309f66ed", - "sha256:79aef6b5cd41feff359acaf98e040844613ff5298d0d19c455b3d9ae0bc8c35a", - "sha256:808ee5834e06f57978da3e003ad9d6292de69d2bf6263662a1a8ae30788e080b", - "sha256:8e44769068d33e0ea6ccdf4b84d80c5afffe5207aa4d1881a629cf0ef3ec398f", - "sha256:999ad08220467b6ad4bd3dd34e65329dd5d0df9b31e47106105e407954965256", - "sha256:9b006628fe43aa69259ec04ca258d88ed19b64791693df59c422b607b6ece8bb", - "sha256:9d05ad5367c90814099000442b2125535e9d77581855b9bee8780f1b41f2b1a2", - "sha256:a577a21de2ef8059b58f79ff76a4da81c45a75fe0bfb09bc8b7bb4293fa18983", - "sha256:a617593aeacc7a691cc4af4a4410031654f2909053bd8c8e7db837f179a630eb", - "sha256:abb48494d88e8a82601af905143e0de838c776c1241d92021e9256d5515b3645", - "sha256:ac88856a8cbccfc14f1b2d0b829af354cc1743cb375e7f04251ae73b2af6adf8", - "sha256:b4c220a1fe0d2c622493b0a1fd48f8f991998fb447d3cd368033a4b86cf1127a", - "sha256:b844fb09bd9936ed158ff9df0ab601e2045b316b17aa8b931857365ea8586906", - "sha256:bdc178caebd0f338d57ae445ef8e9b737ddf8fbc3ea187603f65aec5b041248f", - "sha256:c206587c83e795d417ed3adc8453a791f6d36b67c81416676cad053b4104152c", - "sha256:c61dcc1cf9fd165127a2853e2c31eb4fb961a4f26b394ac9fe5669c7a6592892", - "sha256:c7cb4c512d2d3b0870e00fbbac2f291d4b4bf2634d59a31176a87afe2777c6f0", - "sha256:d4a332404baa6665b54e5d283b4262f41f2103c255897084ec8f5487ce7b9e8e", - "sha256:d5111d4c843d80202e62b4fdbb4920db1dcee4f9366d6b03294f45ed7b18b42e", - "sha256:e1e8406b895aba6caa63d9fd1b6b1700d7e4825f78ccb1e5260551d168db38ed", - "sha256:e8690ed94481f219a7a967c118abaf71ccc440f69acd583cab721b90eeedb77c", - "sha256:ed283ab3a01d8b53de3a05bfdf4473ae24e43caee7dcb5584e86f3f3e5ab4374", - "sha256:ed4b50355b066796dacdd1cf538f2ce57275d001838f9b132fab80b75e8c84dd", - "sha256:ee329d0387b5b41a5dddbb6243a21cb7896587a651bebb957e2d2bb8b63c0791", - "sha256:f3bf1bc02bc421047bfec3343729c4bbbea42605bcfd6d6bfe2c07ade8b12d2a", - "sha256:f585cbbeecb35f35609edccb95efd95a3e35824cd7752b586503f7e6087303f1", - "sha256:f60667673ff9c249709160529ab39667d1ae9fd38634e006bec95611f632e759" - ], - "version": "==2021.8.28" + "sha256:0de8ad66b08c3e673b61981b9e3626f8784d5564f8c3928e2ad408c0eb5ac38c", + "sha256:1f1125bc5172ab3a049bc6f4b9c0aae95a2a2001a77e6d6e4239fa3653e202b5", + "sha256:255791523f80ea8e48e79af7120b4697ef3b74f6886995dcdb08c41f8e516be0", + "sha256:28040e89a04b60d579c69095c509a4f6a1a5379cd865258e3a186b7105de72c6", + "sha256:37868075eda024470bd0feab872c692ac4ee29db1e14baec103257bf6cc64346", + "sha256:3b71213ec3bad9a5a02e049f2ec86b3d7c3e350129ae0f4e2f99c12b5da919ed", + "sha256:3be40f720af170a6b20ddd2ad7904c58b13d2b56f6734ee5d09bbdeed2fa4816", + "sha256:42952d325439ef223e4e9db7ee6d9087b5c68c5c15b1f9de68e990837682fc7b", + "sha256:470f2c882f2672d8eeda8ab27992aec277c067d280b52541357e1acd7e606dae", + "sha256:4907fb0f9b9309a5bded72343e675a252c2589a41871874feace9a05a540241e", + "sha256:4d87459ad3ab40cd8493774f8a454b2e490d8e729e7e402a0625867a983e4e02", + "sha256:4fa7ba9ab2eba7284e0d7d94f61df7af86015b0398e123331362270d71fab0b9", + "sha256:5b34d2335d6aedec7dcadd3f8283b9682fadad8b9b008da8788d2fce76125ebe", + "sha256:6348a7ab2a502cbdd0b7fd0496d614007489adb7361956b38044d1d588e66e04", + "sha256:638e98d069b14113e8afba6a54d1ca123f712c0d105e67c1f9211b2a825ef926", + "sha256:66696c8336a1b5d1182464f3af3427cc760118f26d0b09a2ddc16a976a4d2637", + "sha256:78cf6a1e023caf5e9a982f5377414e1aeac55198831b852835732cfd0a0ca5ff", + "sha256:81e125d9ba54c34579e4539a967e976a3c56150796674aec318b1b2f49251be7", + "sha256:81fdc90f999b2147fc62e303440c424c47e5573a9b615ed5d43a5b832efcca9e", + "sha256:87e9c489aa98f50f367fb26cc9c8908d668e9228d327644d7aa568d47e456f47", + "sha256:8c1ad61fa024195136a6b7b89538030bd00df15f90ac177ca278df9b2386c96f", + "sha256:9910869c472e5a6728680ca357b5846546cbbd2ab3ad5bef986ef0bc438d0aa6", + "sha256:9925985be05d54b3d25fd6c1ea8e50ff1f7c2744c75bdc4d3b45c790afa2bcb3", + "sha256:9a0b0db6b49da7fa37ca8eddf9f40a8dbc599bad43e64f452284f37b6c34d91c", + "sha256:9c065d95a514a06b92a5026766d72ac91bfabf581adb5b29bc5c91d4b3ee9b83", + "sha256:a6f08187136f11e430638c2c66e1db091105d7c2e9902489f0dbc69b44c222b4", + "sha256:ad0517df22a97f1da20d8f1c8cb71a5d1997fa383326b81f9cf22c9dadfbdf34", + "sha256:b345ecde37c86dd7084c62954468a4a655fd2d24fd9b237949dd07a4d0dd6f4c", + "sha256:b55442650f541d195a535ccec33078c78a9521973fb960923da7515e9ed78fa6", + "sha256:c2b180ed30856dfa70cfe927b0fd38e6b68198a03039abdbeb1f2029758d87e7", + "sha256:c9e30838df7bfd20db6466fd309d9b580d32855f8e2c2e6d74cf9da27dcd9b63", + "sha256:cae4099031d80703954c39680323dabd87a69b21262303160776aa0e55970ca0", + "sha256:ce7b1cca6c23f19bee8dc40228d9c314d86d1e51996b86f924aca302fc8f8bf9", + "sha256:d0861e7f6325e821d5c40514c551fd538b292f8cc3960086e73491b9c5d8291d", + "sha256:d331f238a7accfbbe1c4cd1ba610d4c087b206353539331e32a8f05345c74aec", + "sha256:e07049cece3462c626d650e8bf42ddbca3abf4aa08155002c28cb6d9a5a281e2", + "sha256:e2cb7d4909ed16ed35729d38af585673f1f0833e73dfdf0c18e5be0061107b99", + "sha256:e3770781353a4886b68ef10cec31c1f61e8e3a0be5f213c2bb15a86efd999bc4", + "sha256:e502f8d4e5ef714bcc2c94d499684890c94239526d61fdf1096547db91ca6aa6", + "sha256:e6f2d2f93001801296fe3ca86515eb04915472b5380d4d8752f09f25f0b9b0ed", + "sha256:f588209d3e4797882cd238195c175290dbc501973b10a581086b5c6bcd095ffb" + ], + "version": "==2021.9.30" }, "snowballstemmer": { "hashes": [ @@ -488,7 +497,7 @@ "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2'", + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.10.2" }, "tomli": { @@ -532,9 +541,17 @@ "sha256:f8afcf15cc511ada719a88e013cec87c11aff7b91f019295eb4530f96fe5ef2f", "sha256:fb1bbeac803adea29cedd70781399c99138358c26d05fcbd23c13016b7f5ec65" ], - "markers": "python_version < '3.8' and python_version < '3.8'", + "markers": "python_version < '3.8'", "version": "==1.4.3" }, + "types-mock": { + "hashes": [ + "sha256:1a470543be8de673e2ea14739622de3bfb8c9b10429f50338ba9ca1e868c15e9", + "sha256:1ad09970f4f5ec45a138ab1e88d032f010e851bccef7765b34737ed390bbc5c8" + ], + "index": "pypi", + "version": "==4.0.1" + }, "typing-extensions": { "hashes": [ "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497", @@ -589,11 +606,11 @@ }, "zipp": { "hashes": [ - "sha256:957cfda87797e389580cb8b9e3870841ca991e2125350677b2ca83a0e99390a3", - "sha256:f5812b1e007e48cff63449a5e9f4e7ebea716b4111f9c4f9a645f91d579bf0c4" + "sha256:71c644c5369f4a6e07636f0aa966270449561fcea2e3d6747b8d23efaa9d7832", + "sha256:9fe5ea21568a0a70e50f273397638d39b03353731e6cbbb3fd8502a33fec40bc" ], "markers": "python_version >= '3.6'", - "version": "==3.5.0" + "version": "==3.6.0" } } } diff --git a/hardware/README.md b/hardware/README.md index f4527457a39..0b202a5a714 100644 --- a/hardware/README.md +++ b/hardware/README.md @@ -1,4 +1,4 @@ -# Hardware Project +# Opentrons-Hardware Package ## Running Tests @@ -9,3 +9,32 @@ and running. Tests will default to using `vcan0` as their SocketCAN network. If you wish to change the network, then specify the `CAN_CHANNEL` environment variable with the name of your network. + +## Tools + +The `opentrons-hardware` package includes some utility scripts. + +### Header Generator + +This will generate a C++ header file defining constants shared between firmware and the `opentrons-hardware` package. + +#### Usage + +``` +opentrons_generate_header --target TARGET +``` + +Example: `opentrons_generate_header --target some_file.h` + +### CAN Communication + +This is a tool for sending messages to firmware over CAN bus + +#### Usage + +``` +opentrons_can_comm --interface INTERFACE [--bitrate BITRATE] + [--channel CHANNEL] +``` + +Example: `opentrons_can_comm --interface socketcan --channel vcan0` diff --git a/hardware/opentrons_hardware/drivers/can_bus/message.py b/hardware/opentrons_hardware/drivers/can_bus/message.py index 8785dfe1a5a..e0973d9a0b1 100644 --- a/hardware/opentrons_hardware/drivers/can_bus/message.py +++ b/hardware/opentrons_hardware/drivers/can_bus/message.py @@ -5,9 +5,9 @@ from .arbitration_id import ArbitrationId -@dataclass +@dataclass(frozen=True) class CanMessage: """A can message.""" arbitration_id: ArbitrationId - data: bytearray + data: bytes diff --git a/hardware/opentrons_hardware/drivers/can_bus/messages/__init__.py b/hardware/opentrons_hardware/drivers/can_bus/messages/__init__.py new file mode 100644 index 00000000000..35a8773aa6f --- /dev/null +++ b/hardware/opentrons_hardware/drivers/can_bus/messages/__init__.py @@ -0,0 +1 @@ +"""Can bus message definitions.""" diff --git a/hardware/opentrons_hardware/drivers/can_bus/messages/message_definitions.py b/hardware/opentrons_hardware/drivers/can_bus/messages/message_definitions.py new file mode 100644 index 00000000000..7d22d134bd4 --- /dev/null +++ b/hardware/opentrons_hardware/drivers/can_bus/messages/message_definitions.py @@ -0,0 +1,93 @@ +"""Defintion of CAN messages.""" +from dataclasses import dataclass +from typing import Type + +from typing_extensions import Literal + +from opentrons_hardware.utils import BinarySerializable +from ..constants import MessageId +from . import payloads + + +@dataclass +class HeartbeatRequest: # noqa: D101 + message_id: Literal[MessageId.heartbeat_request] = MessageId.heartbeat_request + payload_type: Type[BinarySerializable] = payloads.EmptyMessage + + +@dataclass +class HeartbeatResponse: # noqa: D101 + message_id: Literal[MessageId.heartbeat_response] = MessageId.heartbeat_response + payload_type: Type[BinarySerializable] = payloads.EmptyMessage + + +@dataclass +class DeviceInfoRequest: # noqa: D101 + message_id: Literal[MessageId.device_info_request] = MessageId.device_info_request + payload_type: Type[BinarySerializable] = payloads.EmptyMessage + + +@dataclass +class DeviceInfoResponse: # noqa: D101 + message_id: Literal[MessageId.device_info_response] = MessageId.device_info_response + payload_type: Type[BinarySerializable] = payloads.DeviceInfoResponseBody + + +@dataclass +class StopRequest: # noqa: D101 + message_id: Literal[MessageId.stop_request] = MessageId.stop_request + payload_type: Type[BinarySerializable] = payloads.EmptyMessage + + +@dataclass +class GetStatusRequest: # noqa: D101 + message_id: Literal[MessageId.get_status_request] = MessageId.get_status_request + payload_type: Type[BinarySerializable] = payloads.EmptyMessage + + +@dataclass +class GetStatusResponse: # noqa: D101 + message_id: Literal[MessageId.get_status_response] = MessageId.get_status_response + payload_type: Type[BinarySerializable] = payloads.EmptyMessage + + +@dataclass +class MoveRequest: # noqa: D101 + message_id: Literal[MessageId.move_request] = MessageId.move_request + payload_type: Type[BinarySerializable] = payloads.MoveRequest + + +@dataclass +class SetupRequest: # noqa: D101 + message_id: Literal[MessageId.setup_request] = MessageId.setup_request + payload_type: Type[BinarySerializable] = payloads.EmptyMessage + + +@dataclass +class GetSpeedRequest: # noqa: D101 + message_id: Literal[MessageId.get_speed_request] = MessageId.get_speed_request + payload_type: Type[BinarySerializable] = payloads.EmptyMessage + + +@dataclass +class GetSpeedResponse: # noqa: D101 + message_id: Literal[MessageId.get_speed_response] = MessageId.get_speed_response + payload_type: Type[BinarySerializable] = payloads.GetSpeedResponse + + +@dataclass +class WriteToEEPromRequest: # noqa: D101 + message_id: Literal[MessageId.write_eeprom] = MessageId.write_eeprom + payload_type: Type[BinarySerializable] = payloads.WriteToEEPromRequest + + +@dataclass +class ReadFromEEPromRequest: # noqa: D101 + message_id: Literal[MessageId.read_eeprom_request] = MessageId.read_eeprom_request + payload_type: Type[BinarySerializable] = payloads.EmptyMessage + + +@dataclass +class ReadFromEEPromResponse: # noqa: D101 + message_id: Literal[MessageId.read_eeprom_response] = MessageId.read_eeprom_response + payload_type: Type[BinarySerializable] = payloads.ReadFromEEPromResponse diff --git a/hardware/opentrons_hardware/drivers/can_bus/messages/messages.py b/hardware/opentrons_hardware/drivers/can_bus/messages/messages.py new file mode 100644 index 00000000000..0549213c5d0 --- /dev/null +++ b/hardware/opentrons_hardware/drivers/can_bus/messages/messages.py @@ -0,0 +1,44 @@ +"""Message types.""" +from functools import lru_cache +from typing import Union, Optional + +from typing_extensions import get_args + +from opentrons_hardware.drivers.can_bus.messages import message_definitions as defs +from opentrons_hardware.drivers.can_bus.constants import MessageId + +MessageDefinition = Union[ + defs.HeartbeatRequest, + defs.HeartbeatResponse, + defs.DeviceInfoRequest, + defs.DeviceInfoResponse, + defs.StopRequest, + defs.GetStatusRequest, + defs.GetStatusResponse, + defs.MoveRequest, + defs.SetupRequest, + defs.GetSpeedRequest, + defs.GetSpeedResponse, + defs.WriteToEEPromRequest, + defs.ReadFromEEPromRequest, + defs.ReadFromEEPromResponse, +] + + +@lru_cache(maxsize=None) +def get_definition(message_id: MessageId) -> Optional[MessageDefinition]: + """Get the message type for a message id. + + Args: + message_id: A message id + + Returns: The message definition for a type + + """ + # Dumb linear search, but the result is memoized. + for i in get_args(MessageDefinition): + if i.message_id == message_id: + # get args returns Tuple[Any...] + return i # type: ignore + + return None diff --git a/hardware/opentrons_hardware/drivers/can_bus/messages/payloads.py b/hardware/opentrons_hardware/drivers/can_bus/messages/payloads.py new file mode 100644 index 00000000000..88d5f6bbc7c --- /dev/null +++ b/hardware/opentrons_hardware/drivers/can_bus/messages/payloads.py @@ -0,0 +1,55 @@ +"""Payloads of can bus messages.""" +from dataclasses import dataclass + +from opentrons_hardware import utils + + +@dataclass +class EmptyMessage(utils.BinarySerializable): + """An empty payload.""" + + pass + + +@dataclass +class DeviceInfoResponseBody(utils.BinarySerializable): + """Device info response.""" + + node_id: utils.UInt8Field + version: utils.UInt32Field + + +@dataclass +class GetStatusResponse(utils.BinarySerializable): + """Get status response.""" + + status: utils.UInt8Field + data: utils.UInt32Field + + +@dataclass +class MoveRequest(utils.BinarySerializable): + """Move request.""" + + steps: utils.UInt32Field + + +@dataclass +class GetSpeedResponse(utils.BinarySerializable): + """Get speed response.""" + + mm_sec: utils.UInt32Field + + +@dataclass +class WriteToEEPromRequest(utils.BinarySerializable): + """Write to eeprom request.""" + + serial_number: utils.UInt8Field + + +@dataclass +class ReadFromEEPromResponse(utils.BinarySerializable): + """Read from ee prom response.""" + + serial_number: utils.UInt8Field diff --git a/hardware/opentrons_hardware/scripts/can_args.py b/hardware/opentrons_hardware/scripts/can_args.py new file mode 100644 index 00000000000..990b6028fcf --- /dev/null +++ b/hardware/opentrons_hardware/scripts/can_args.py @@ -0,0 +1,25 @@ +"""ArgumentParser setup for a can device.""" +from argparse import ArgumentParser + + +def add_can_args(parser: ArgumentParser) -> ArgumentParser: + """Add CAN interface arguments to ArgumentParser. + + Args: + parser: ArgumentParser + + Returns: ArgumentParser with added arguments for CAN interface. + """ + parser.add_argument( + "--interface", + type=str, + required=True, + help="the interface to use (ie: virtual, pcan, socketcan)", + ) + parser.add_argument( + "--bitrate", type=int, default=250000, required=False, help="the bitrate" + ) + parser.add_argument( + "--channel", type=str, default=None, required=False, help="optional channel" + ) + return parser diff --git a/hardware/opentrons_hardware/scripts/can_comm.py b/hardware/opentrons_hardware/scripts/can_comm.py new file mode 100644 index 00000000000..d7a8da03391 --- /dev/null +++ b/hardware/opentrons_hardware/scripts/can_comm.py @@ -0,0 +1,226 @@ +"""A script for sending and monitoring CAN messages.""" +import asyncio +import dataclasses +import logging +import argparse +from enum import Enum +from logging.config import dictConfig +from typing import Type, Sequence, Optional, Callable, cast + +from opentrons_hardware.drivers.can_bus import ( + CanDriver, + MessageId, + NodeId, + CanMessage, + ArbitrationId, + ArbitrationIdParts, + FunctionCode, +) +from opentrons_hardware.drivers.can_bus.messages.messages import get_definition +from opentrons_hardware.scripts.can_args import add_can_args +from opentrons_hardware.utils import BinarySerializable, BinarySerializableException + +log = logging.getLogger(__name__) + + +GetInputFunc = Callable[[str], str] +OutputFunc = Callable[[str], None] + + +class InvalidInput(Exception): + """Invalid input exception.""" + + pass + + +async def listen_task(can_driver: CanDriver) -> None: + """A task that listens for can messages. + + Args: + can_driver: Driver + + Returns: Nothing. + + """ + async for message in can_driver: + log.info(f"Received <-- {message}") + + +def create_choices(enum_type: Type[Enum]) -> Sequence[str]: + """Create choice strings. + + Args: + enum_type: enum + + Returns: + a collection of strings describing the choices in enum. + + """ + # mypy wants type annotation for v. + return [f"{i}: {v.name}" for (i, v) in enumerate(enum_type)] # type: ignore + + +def prompt_enum( + enum_type: Type[Enum], get_user_input: GetInputFunc, output_func: OutputFunc +) -> Type[Enum]: + """Prompt to choose a member of the enum. + + Args: + output_func: Function to output text to user. + get_user_input: Function to get user input. + enum_type: an enum type + + Returns: + The choice. + + """ + output_func(f"choose {enum_type.__name__}:") + for row in create_choices(enum_type): + output_func(f"\t{row}") + + try: + return list(enum_type)[int(get_user_input("enter choice: "))] + except (ValueError, IndexError) as e: + raise InvalidInput(str(e)) + + +def prompt_payload( + payload_type: Type[BinarySerializable], get_user_input: GetInputFunc +) -> BinarySerializable: + """Prompt to get payload. + + Args: + get_user_input: Function to get user input. + payload_type: Serializable payload type. + + Returns: + Serializable payload. + + """ + payload_fields = dataclasses.fields(payload_type) + i = {} + for f in payload_fields: + # TODO (amit 2021-10-01): Conversion to int is not good here long term. + # Should be handled by type coercion in utils.BinarySerializable. + # All values are ints now, but may be bytes in the future (ie serial + # numbers, fw upgrade blobs). + try: + i[f.name] = f.type.build(int(get_user_input(f"enter {f.name}: "))) + except ValueError as e: + raise InvalidInput(str(e)) + # Mypy is not liking constructing the derived types. + return payload_type(**i) # type: ignore + + +def prompt_message(get_user_input: GetInputFunc, output_func: OutputFunc) -> CanMessage: + """Prompt user to create a message. + + Args: + get_user_input: Function to get user input. + output_func: Function to output text to user. + + Returns: a CanMessage + """ + message_id = prompt_enum(MessageId, get_user_input, output_func) + node_id = prompt_enum(NodeId, get_user_input, output_func) + # TODO (amit, 2021-10-01): Get function code when the time comes. + function_code = FunctionCode.network_management + message_def = get_definition(cast(MessageId, message_id)) + if message_def is None: + raise InvalidInput(f"No message definition found for {message_id}") + payload = prompt_payload(message_def.payload_type, get_user_input) + try: + data = payload.serialize() + except BinarySerializableException as e: + raise InvalidInput(str(e)) + can_message = CanMessage( + arbitration_id=ArbitrationId( + parts=ArbitrationIdParts( + message_id=message_id, node_id=node_id, function_code=function_code + ) + ), + data=data, + ) + log.info(f"Sending --> {can_message}") + return can_message + + +async def ui_task(can_driver: CanDriver) -> None: + """UI task to create and send messages. + + Args: + can_driver: Can driver. + + Returns: None. + """ + while True: + try: + # Run sync prompt message in threadpool executor. + can_message = await asyncio.get_event_loop().run_in_executor( + None, prompt_message, input, print + ) + await can_driver.send(can_message) + except InvalidInput as e: + print(str(e)) + + +async def run(interface: str, bitrate: int, channel: Optional[str] = None) -> None: + """Entry point for script.""" + log.info(f"Connecting to {interface} {bitrate} {channel}") + driver = await CanDriver.build( + bitrate=bitrate, interface=interface, channel=channel + ) + + loop = asyncio.get_event_loop() + fut = asyncio.gather( + loop.create_task(listen_task(driver)), loop.create_task(ui_task(driver)) + ) + try: + await fut + except KeyboardInterrupt: + fut.cancel() + except asyncio.CancelledError: + pass + finally: + driver.shutdown() + + +LOG_CONFIG = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "basic": {"format": ("%(asctime)s %(name)s %(levelname)s %(message)s")} + }, + "handlers": { + "file_handler": { + "class": "logging.handlers.RotatingFileHandler", + "formatter": "basic", + "filename": "can_comm.log", + "maxBytes": 5000000, + "level": logging.INFO, + "backupCount": 3, + }, + }, + "loggers": { + "": { + "handlers": ["file_handler"], + "level": logging.INFO, + }, + }, +} + + +def main() -> None: + """Entry point.""" + dictConfig(LOG_CONFIG) + + parser = argparse.ArgumentParser(description="CAN bus testing.") + add_can_args(parser) + + args = parser.parse_args() + + asyncio.run(run(args.interface, args.bitrate, args.channel)) + + +if __name__ == "__main__": + main() diff --git a/hardware/opentrons_hardware/scripts/identify.py b/hardware/opentrons_hardware/scripts/identify.py deleted file mode 100644 index 73a7dce51aa..00000000000 --- a/hardware/opentrons_hardware/scripts/identify.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Script to identify peripherals on the can bus.""" -import argparse -import asyncio -from typing import Optional - -from opentrons_hardware.drivers.can_bus import ( - CanDriver, - MessageId, - FunctionCode, - NodeId, - CanMessage, - ArbitrationId, - ArbitrationIdParts, -) - - -async def request(can_driver: CanDriver) -> None: - """Send identify request. - - Args: - can_driver: CanDriver - - Returns: - None - """ - parts = ArbitrationIdParts( - function=FunctionCode.network_management, - node_id=NodeId.broadcast, - message_id=MessageId.device_info_request, - ) - message = CanMessage(arbitration_id=ArbitrationId(parts=parts), data=bytearray()) - await can_driver.send(message=message) - - -async def wait_responses(can_driver: CanDriver) -> None: - """Wait for identification responses. - - Display the responses - - Args: - can_driver: CanDriver - - Returns: - None - """ - async for message in can_driver: - if message.arbitration_id.parts.message_id == MessageId.device_info_response: - node = NodeId(message.data[0]) - print(f"Received response from Node: {node.name}({node.value:x})") - print(f"\tArbitration id: {message.arbitration_id}") - - -async def run(interface: str, bitrate: int, channel: Optional[str] = None) -> None: - """Entry point for script.""" - driver = await CanDriver.build( - bitrate=bitrate, interface=interface, channel=channel - ) - await request(driver) - - try: - await wait_responses(driver) - except KeyboardInterrupt: - print("Finished.") - finally: - driver.shutdown() - - -def main() -> None: - """Entry point.""" - parser = argparse.ArgumentParser(description="Identify peripherals on the can bus.") - parser.add_argument( - "--interface", - type=str, - required=True, - help="the interface to use (ie: virtual, pcan, socketcan)", - ) - parser.add_argument( - "--bitrate", type=int, default=250000, required=False, help="the bitrate" - ) - parser.add_argument( - "--channel", type=str, default=None, required=False, help="optional channel" - ) - - args = parser.parse_args() - - asyncio.run(run(args.interface, args.bitrate, args.channel)) - - -if __name__ == "__main__": - main() diff --git a/hardware/opentrons_hardware/utils/__init__.py b/hardware/opentrons_hardware/utils/__init__.py index ad12aeef214..9d5c5cf2228 100644 --- a/hardware/opentrons_hardware/utils/__init__.py +++ b/hardware/opentrons_hardware/utils/__init__.py @@ -9,6 +9,7 @@ UInt8Field, UInt16Field, UInt32Field, + BinaryFieldBase, BinarySerializableException, InvalidFieldException, ) @@ -22,6 +23,7 @@ "UInt8Field", "UInt16Field", "UInt32Field", + "BinaryFieldBase", "BinarySerializableException", "InvalidFieldException", ] diff --git a/hardware/opentrons_hardware/utils/binary_serializable.py b/hardware/opentrons_hardware/utils/binary_serializable.py index 85c3bc221ac..fe5ee3c9798 100644 --- a/hardware/opentrons_hardware/utils/binary_serializable.py +++ b/hardware/opentrons_hardware/utils/binary_serializable.py @@ -22,6 +22,12 @@ class InvalidFieldException(BinarySerializableException): pass +class SerializationException(BinarySerializableException): + """Serialization error.""" + + pass + + T = TypeVar("T") @@ -56,6 +62,10 @@ def __eq__(self, other: object) -> bool: """Comparison.""" return isinstance(other, BinaryFieldBase) and other.value == self.value + def __repr__(self) -> str: + """Representation string.""" + return f"{self.__class__.__name__}(value={repr(self.value)})" + class UInt32Field(BinaryFieldBase[int]): """Unsigned 32 bit integer field.""" @@ -114,7 +124,10 @@ def serialize(self) -> bytes: """ string = self._get_format_string() vals = [x.value for x in astuple(self)] - return struct.pack(string, *vals) + try: + return struct.pack(string, *vals) + except struct.error as e: + raise SerializationException(str(e)) @classmethod def build(cls, data: bytes) -> BinarySerializable: @@ -147,7 +160,7 @@ def _get_format_string(cls) -> str: ) except AttributeError: raise InvalidFieldException( - f"All fields must be of type {BinaryFieldBase.__class__}" + f"All fields must be of type {BinaryFieldBase}" ) # Cache it on the cls. diff --git a/hardware/setup.py b/hardware/setup.py index 5f2204482a6..b6bf38cbaf2 100644 --- a/hardware/setup.py +++ b/hardware/setup.py @@ -79,7 +79,7 @@ def read(*parts: str) -> str: entry_points={ "console_scripts": [ "opentrons_generate_header = opentrons_hardware.scripts.generate_header:main", # noqa: E501 - "opentrons_canbus_identify = opentrons_hardware.scripts.identify:main", + "opentrons_can_comm = opentrons_hardware.scripts.can_comm:main", ] }, ) diff --git a/hardware/tests/test_scripts/__init__.py b/hardware/tests/test_scripts/__init__.py new file mode 100644 index 00000000000..bfa36c316a6 --- /dev/null +++ b/hardware/tests/test_scripts/__init__.py @@ -0,0 +1 @@ +"""Scripts tests.""" diff --git a/hardware/tests/test_scripts/test_can_comm.py b/hardware/tests/test_scripts/test_can_comm.py new file mode 100644 index 00000000000..056f618a265 --- /dev/null +++ b/hardware/tests/test_scripts/test_can_comm.py @@ -0,0 +1,119 @@ +"""Integration tests for the can_comm script.""" +from typing import List + +from mock import MagicMock + +import pytest + +from opentrons_hardware.drivers.can_bus import ( + CanMessage, + ArbitrationId, + ArbitrationIdParts, +) +from opentrons_hardware.drivers.can_bus.messages.payloads import DeviceInfoResponseBody +from opentrons_hardware.scripts import can_comm +from opentrons_hardware.drivers.can_bus.constants import MessageId, NodeId + + +@pytest.fixture +def mock_get_input() -> MagicMock: + """Mock get input.""" + return MagicMock(spec=input) + + +@pytest.fixture +def mock_output() -> MagicMock: + """Mock get input.""" + return MagicMock(spec=print) + + +def test_prompt_message_without_payload( + mock_get_input: MagicMock, mock_output: MagicMock +) -> None: + """It should create a message without payload.""" + message_id = MessageId.get_speed_request + node_id = NodeId.gantry + mock_get_input.side_effect = [ + str(list(MessageId).index(message_id)), + str(list(NodeId).index(node_id)), + ] + r = can_comm.prompt_message(mock_get_input, mock_output) + assert r == CanMessage( + arbitration_id=ArbitrationId( + parts=ArbitrationIdParts(message_id=message_id, node_id=node_id) + ), + data=b"", + ) + + +def test_prompt_message_with_payload( + mock_get_input: MagicMock, mock_output: MagicMock +) -> None: + """It should send a message with payload.""" + message_id = MessageId.device_info_response + node_id = NodeId.pipette + mock_get_input.side_effect = [ + str(list(MessageId).index(message_id)), + str(list(NodeId).index(node_id)), + "14", + str(0xFF00FF00), + ] + r = can_comm.prompt_message(mock_get_input, mock_output) + assert r == CanMessage( + arbitration_id=ArbitrationId( + parts=ArbitrationIdParts(message_id=message_id, node_id=node_id) + ), + data=b"\x0e\xff\x00\xff\x00", + ) + + +@pytest.mark.parametrize( + argnames=["user_input"], + argvalues=[ + # Not a number + [["b"]], + # Out of range + [["1000000000"]], + ], +) +def test_prompt_enum_bad_input( + user_input: List[str], mock_get_input: MagicMock, mock_output: MagicMock +) -> None: + """It should raise on bad input.""" + mock_get_input.side_effect = user_input + with pytest.raises(can_comm.InvalidInput): + can_comm.prompt_enum(MessageId, mock_get_input, mock_output) + + +@pytest.mark.parametrize( + argnames=["user_input"], + argvalues=[ + # Not a number + [["b"]], + [["0", "b"]], + ], +) +def test_prompt_payload_bad_input( + user_input: List[str], mock_get_input: MagicMock +) -> None: + """It should raise on bad input.""" + mock_get_input.side_effect = user_input + with pytest.raises(can_comm.InvalidInput): + can_comm.prompt_payload(DeviceInfoResponseBody, mock_get_input) + + +def test_prompt_message_bad_input( + mock_get_input: MagicMock, mock_output: MagicMock +) -> None: + """It should raise on bad input.""" + message_id = MessageId.device_info_response + node_id = NodeId.pipette + mock_get_input.side_effect = [ + str(list(MessageId).index(message_id)), + str(list(NodeId).index(node_id)), + # out of range for Uint8 + "256", + str(0xFF00FF00), + ] + with pytest.raises(can_comm.InvalidInput): + can_comm.prompt_message(mock_get_input, mock_output)