diff --git a/.github/workflows/app-test-build-deploy.yaml b/.github/workflows/app-test-build-deploy.yaml index 8c3bd21503d..f0bfe7d8946 100644 --- a/.github/workflows/app-test-build-deploy.yaml +++ b/.github/workflows/app-test-build-deploy.yaml @@ -97,7 +97,11 @@ jobs: strategy: matrix: os: ['windows-2022', 'ubuntu-22.04', 'macos-latest'] - name: 'opentrons app backend unit tests on ${{matrix.os}}' + shell: ['app-shell', 'app-shell-odd', 'discovery-client'] + exclude: + - os: 'windows-2022' + shell: 'app-shell-odd' + name: 'opentrons ${{matrix.shell}} unit tests on ${{matrix.os}}' timeout-minutes: 60 runs-on: ${{ matrix.os }} steps: @@ -144,7 +148,7 @@ jobs: yarn config set cache-folder ${{ github.workspace }}/.yarn-cache make setup-js - name: 'test native(er) packages' - run: make test-js-internal tests="app-shell/src app-shell-odd/src discovery-client/src" cov_opts="--coverage=true" + run: make test-js-internal tests="${{matrix.shell}}/src" cov_opts="--coverage=true" - name: 'Upload coverage report' uses: 'codecov/codecov-action@v3' with: diff --git a/.github/workflows/opentrons-ai-client-staging-continuous-deploy.yaml b/.github/workflows/opentrons-ai-client-staging-continuous-deploy.yaml index af767b36adc..7a89bfa02dd 100644 --- a/.github/workflows/opentrons-ai-client-staging-continuous-deploy.yaml +++ b/.github/workflows/opentrons-ai-client-staging-continuous-deploy.yaml @@ -52,6 +52,9 @@ jobs: yarn config set cache-folder ${{ github.workspace }}/.yarn-cache make setup-js - name: 'build' + env: + # inject dev id since this is for staging + OT_AI_CLIENT_MIXPANEL_ID: ${{ secrets.OT_AI_CLIENT_MIXPANEL_DEV_ID }} run: | make -C opentrons-ai-client build-staging - name: Configure AWS Credentials diff --git a/.github/workflows/opentrons-ai-client-test-build-deploy.yaml b/.github/workflows/opentrons-ai-client-test.yaml similarity index 90% rename from .github/workflows/opentrons-ai-client-test-build-deploy.yaml rename to .github/workflows/opentrons-ai-client-test.yaml index 2f569d9bf78..2c5cc6cfc64 100644 --- a/.github/workflows/opentrons-ai-client-test-build-deploy.yaml +++ b/.github/workflows/opentrons-ai-client-test.yaml @@ -9,12 +9,9 @@ on: paths: - 'Makefile' - 'opentrons-ai-client/**/*' - - 'components/**/*' - - '*.js' - - '*.json' - - 'yarn.lock' - - '.github/workflows/app-test-build-deploy.yaml' - - '.github/workflows/utils.js' + - 'components/**' + - 'shared-data/**' + - '.github/workflows/opentrons-ai-client-test.yml' branches: - '**' tags: @@ -24,10 +21,9 @@ on: paths: - 'Makefile' - 'opentrons-ai-client/**/*' - - 'components/**/*' - - '*.js' - - '*.json' - - 'yarn.lock' + - 'components/**' + - 'shared-data/**' + - '.github/workflows/opentrons-ai-client-test.yml' workflow_dispatch: concurrency: diff --git a/.github/workflows/opentrons-ai-production-deploy.yaml b/.github/workflows/opentrons-ai-production-deploy.yaml index 825c3561f25..2327b48ecad 100644 --- a/.github/workflows/opentrons-ai-production-deploy.yaml +++ b/.github/workflows/opentrons-ai-production-deploy.yaml @@ -52,6 +52,8 @@ jobs: yarn config set cache-folder ${{ github.workspace }}/.yarn-cache make setup-js - name: 'build' + env: + OT_AI_CLIENT_MIXPANEL_ID: ${{ secrets.OT_AI_CLIENT_MIXPANEL_ID }} run: | make -C opentrons-ai-client build-production - name: Configure AWS Credentials diff --git a/.github/workflows/pd-test-build-deploy.yaml b/.github/workflows/pd-test-build-deploy.yaml index 306a475aacc..006da36d6a4 100644 --- a/.github/workflows/pd-test-build-deploy.yaml +++ b/.github/workflows/pd-test-build-deploy.yaml @@ -79,49 +79,49 @@ jobs: files: ./coverage/lcov.info flags: protocol-designer - e2e-test: - name: 'pd e2e tests' - needs: ['js-unit-test'] - timeout-minutes: 30 - strategy: - matrix: - os: ['ubuntu-22.04'] - runs-on: '${{ matrix.os }}' - steps: - - uses: 'actions/checkout@v3' - with: - fetch-depth: 0 - # https://github.com/actions/checkout/issues/290 - - name: 'Fix actions/checkout odd handling of tags' - if: startsWith(github.ref, 'refs/tags') - run: | - git fetch -f origin ${{ github.ref }}:${{ github.ref }} - git checkout ${{ github.ref }} - - uses: 'actions/setup-node@v3' - with: - node-version: '18.19.0' - - name: 'install udev for usb-detection' - if: startsWith(matrix.os, 'ubuntu') - run: | - # WORKAROUND: Remove microsoft debian repo due to https://github.com/microsoft/linux-package-repositories/issues/130. Remove line below after it is resolved - sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list - sudo apt-get update && sudo apt-get install libudev-dev - - name: 'cache yarn cache' - uses: actions/cache@v3 - with: - path: | - ${{ github.workspace }}/.yarn-cache - ${{ github.workspace }}/.npm-cache - key: js-${{ secrets.GH_CACHE_VERSION }}-${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} - restore-keys: | - js-${{ secrets.GH_CACHE_VERSION }}-${{ runner.os }}-yarn- - - name: 'setup-js' - run: | - npm config set cache ./.npm-cache - yarn config set cache-folder ./.yarn-cache - make setup-js - - name: 'test-e2e' - run: make -C protocol-designer test-e2e + # e2e-test: + # name: 'pd e2e tests' + # needs: ['js-unit-test'] + # timeout-minutes: 30 + # strategy: + # matrix: + # os: ['ubuntu-22.04'] + # runs-on: '${{ matrix.os }}' + # steps: + # - uses: 'actions/checkout@v3' + # with: + # fetch-depth: 0 + # # https://github.com/actions/checkout/issues/290 + # - name: 'Fix actions/checkout odd handling of tags' + # if: startsWith(github.ref, 'refs/tags') + # run: | + # git fetch -f origin ${{ github.ref }}:${{ github.ref }} + # git checkout ${{ github.ref }} + # - uses: 'actions/setup-node@v3' + # with: + # node-version: '18.19.0' + # - name: 'install udev for usb-detection' + # if: startsWith(matrix.os, 'ubuntu') + # run: | + # # WORKAROUND: Remove microsoft debian repo due to https://github.com/microsoft/linux-package-repositories/issues/130. Remove line below after it is resolved + # sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list + # sudo apt-get update && sudo apt-get install libudev-dev + # - name: 'cache yarn cache' + # uses: actions/cache@v3 + # with: + # path: | + # ${{ github.workspace }}/.yarn-cache + # ${{ github.workspace }}/.npm-cache + # key: js-${{ secrets.GH_CACHE_VERSION }}-${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} + # restore-keys: | + # js-${{ secrets.GH_CACHE_VERSION }}-${{ runner.os }}-yarn- + # - name: 'setup-js' + # run: | + # npm config set cache ./.npm-cache + # yarn config set cache-folder ./.yarn-cache + # make setup-js + # - name: 'test-e2e' + # run: make -C protocol-designer test-e2e build-pd: name: 'build protocol designer artifact' needs: ['js-unit-test'] diff --git a/abr-testing/Makefile b/abr-testing/Makefile index f711579ff57..5c5cc6d06df 100644 --- a/abr-testing/Makefile +++ b/abr-testing/Makefile @@ -88,3 +88,14 @@ push-no-restart-ot3: sdist Pipfile.lock .PHONY: push-ot3 push-ot3: push-no-restart-ot3 + +.PHONY: abr-setup +abr-setup: + $(python) abr_testing/tools/abr_setup.py + +.PHONY: simulate +PROTOCOL_DIR := abr_testing/protocols +SIMULATION_TOOL := abr_testing/protocol_simulation/abr_sim_check.py +EXTENSION := .py +simulate: + $(python) $(SIMULATION_TOOL) \ No newline at end of file diff --git a/abr-testing/Pipfile b/abr-testing/Pipfile index 0ea9e6f76aa..90534f708ae 100644 --- a/abr-testing/Pipfile +++ b/abr-testing/Pipfile @@ -18,7 +18,7 @@ slackclient = "*" slack-sdk = "*" pandas = "*" pandas-stubs = "*" -numpy = "==1.8.3" +paramiko = "*" [dev-packages] atomicwrites = "==1.4.1" diff --git a/abr-testing/Pipfile.lock b/abr-testing/Pipfile.lock index 08da1926e92..79885cdc940 100644 --- a/abr-testing/Pipfile.lock +++ b/abr-testing/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "f2a4a8a95be01ccb8425c8069a3dc8e3f932c1f9b36f5b12c838ee9cfc26015e" + "sha256": "a537d1f1a5f5d0658a3ba2c62deabf390fd7b9e72acbee6704f8d095c1b535e9" }, "pipfile-spec": 6, "requires": { @@ -22,108 +22,108 @@ }, "aiohappyeyeballs": { "hashes": [ - "sha256:55a1714f084e63d49639800f95716da97a1f173d46a16dfcfda0016abb93b6b2", - "sha256:7ce92076e249169a13c2f49320d1967425eaf1f407522d707d59cac7628d62bd" + "sha256:75cf88a15106a5002a8eb1dab212525c00d1f4c0fa96e551c9fbe6f09a621586", + "sha256:8a7a83727b2756f394ab2895ea0765a0a8c475e3c71e98d43d76f22b4b435572" ], "markers": "python_version >= '3.8'", - "version": "==2.4.0" + "version": "==2.4.3" }, "aiohttp": { "hashes": [ - "sha256:02594361128f780eecc2a29939d9dfc870e17b45178a867bf61a11b2a4367277", - "sha256:03f2645adbe17f274444953bdea69f8327e9d278d961d85657cb0d06864814c1", - "sha256:074d1bff0163e107e97bd48cad9f928fa5a3eb4b9d33366137ffce08a63e37fe", - "sha256:0912b8a8fadeb32ff67a3ed44249448c20148397c1ed905d5dac185b4ca547bb", - "sha256:0d277cfb304118079e7044aad0b76685d30ecb86f83a0711fc5fb257ffe832ca", - "sha256:0d93400c18596b7dc4794d48a63fb361b01a0d8eb39f28800dc900c8fbdaca91", - "sha256:123dd5b16b75b2962d0fff566effb7a065e33cd4538c1692fb31c3bda2bfb972", - "sha256:17e997105bd1a260850272bfb50e2a328e029c941c2708170d9d978d5a30ad9a", - "sha256:18a01eba2574fb9edd5f6e5fb25f66e6ce061da5dab5db75e13fe1558142e0a3", - "sha256:1923a5c44061bffd5eebeef58cecf68096e35003907d8201a4d0d6f6e387ccaa", - "sha256:1942244f00baaacaa8155eca94dbd9e8cc7017deb69b75ef67c78e89fdad3c77", - "sha256:1b2c16a919d936ca87a3c5f0e43af12a89a3ce7ccbce59a2d6784caba945b68b", - "sha256:1c19de68896747a2aa6257ae4cf6ef59d73917a36a35ee9d0a6f48cff0f94db8", - "sha256:1e72589da4c90337837fdfe2026ae1952c0f4a6e793adbbfbdd40efed7c63599", - "sha256:22c0a23a3b3138a6bf76fc553789cb1a703836da86b0f306b6f0dc1617398abc", - "sha256:2c634a3207a5445be65536d38c13791904fda0748b9eabf908d3fe86a52941cf", - "sha256:2d21ac12dc943c68135ff858c3a989f2194a709e6e10b4c8977d7fcd67dfd511", - "sha256:2f1f1c75c395991ce9c94d3e4aa96e5c59c8356a15b1c9231e783865e2772699", - "sha256:305be5ff2081fa1d283a76113b8df7a14c10d75602a38d9f012935df20731487", - "sha256:33e6bc4bab477c772a541f76cd91e11ccb6d2efa2b8d7d7883591dfb523e5987", - "sha256:349ef8a73a7c5665cca65c88ab24abe75447e28aa3bc4c93ea5093474dfdf0ff", - "sha256:380f926b51b92d02a34119d072f178d80bbda334d1a7e10fa22d467a66e494db", - "sha256:38172a70005252b6893088c0f5e8a47d173df7cc2b2bd88650957eb84fcf5022", - "sha256:391cc3a9c1527e424c6865e087897e766a917f15dddb360174a70467572ac6ce", - "sha256:3a1c32a19ee6bbde02f1cb189e13a71b321256cc1d431196a9f824050b160d5a", - "sha256:4120d7fefa1e2d8fb6f650b11489710091788de554e2b6f8347c7a20ceb003f5", - "sha256:424ae21498790e12eb759040bbb504e5e280cab64693d14775c54269fd1d2bb7", - "sha256:44b324a6b8376a23e6ba25d368726ee3bc281e6ab306db80b5819999c737d820", - "sha256:4790f0e15f00058f7599dab2b206d3049d7ac464dc2e5eae0e93fa18aee9e7bf", - "sha256:4aff049b5e629ef9b3e9e617fa6e2dfeda1bf87e01bcfecaf3949af9e210105e", - "sha256:4b38b1570242fbab8d86a84128fb5b5234a2f70c2e32f3070143a6d94bc854cf", - "sha256:4d46c7b4173415d8e583045fbc4daa48b40e31b19ce595b8d92cf639396c15d5", - "sha256:4f1c9866ccf48a6df2b06823e6ae80573529f2af3a0992ec4fe75b1a510df8a6", - "sha256:4f7acae3cf1a2a2361ec4c8e787eaaa86a94171d2417aae53c0cca6ca3118ff6", - "sha256:54d9ddea424cd19d3ff6128601a4a4d23d54a421f9b4c0fff740505813739a91", - "sha256:58718e181c56a3c02d25b09d4115eb02aafe1a732ce5714ab70326d9776457c3", - "sha256:5ede29d91a40ba22ac1b922ef510aab871652f6c88ef60b9dcdf773c6d32ad7a", - "sha256:61645818edd40cc6f455b851277a21bf420ce347baa0b86eaa41d51ef58ba23d", - "sha256:66bf9234e08fe561dccd62083bf67400bdbf1c67ba9efdc3dac03650e97c6088", - "sha256:673f988370f5954df96cc31fd99c7312a3af0a97f09e407399f61583f30da9bc", - "sha256:676f94c5480d8eefd97c0c7e3953315e4d8c2b71f3b49539beb2aa676c58272f", - "sha256:6c225286f2b13bab5987425558baa5cbdb2bc925b2998038fa028245ef421e75", - "sha256:7384d0b87d4635ec38db9263e6a3f1eb609e2e06087f0aa7f63b76833737b471", - "sha256:7e2fe37ac654032db1f3499fe56e77190282534810e2a8e833141a021faaab0e", - "sha256:7f2bfc0032a00405d4af2ba27f3c429e851d04fad1e5ceee4080a1c570476697", - "sha256:7f6b639c36734eaa80a6c152a238242bedcee9b953f23bb887e9102976343092", - "sha256:814375093edae5f1cb31e3407997cf3eacefb9010f96df10d64829362ae2df69", - "sha256:8224f98be68a84b19f48e0bdc14224b5a71339aff3a27df69989fa47d01296f3", - "sha256:898715cf566ec2869d5cb4d5fb4be408964704c46c96b4be267442d265390f32", - "sha256:8989f46f3d7ef79585e98fa991e6ded55d2f48ae56d2c9fa5e491a6e4effb589", - "sha256:8ba01ebc6175e1e6b7275c907a3a36be48a2d487549b656aa90c8a910d9f3178", - "sha256:8c5c6fa16412b35999320f5c9690c0f554392dc222c04e559217e0f9ae244b92", - "sha256:8c6a4e5e40156d72a40241a25cc226051c0a8d816610097a8e8f517aeacd59a2", - "sha256:8eaf44ccbc4e35762683078b72bf293f476561d8b68ec8a64f98cf32811c323e", - "sha256:8fb4fc029e135859f533025bc82047334e24b0d489e75513144f25408ecaf058", - "sha256:9093a81e18c45227eebe4c16124ebf3e0d893830c6aca7cc310bfca8fe59d857", - "sha256:94c4381ffba9cc508b37d2e536b418d5ea9cfdc2848b9a7fea6aebad4ec6aac1", - "sha256:94fac7c6e77ccb1ca91e9eb4cb0ac0270b9fb9b289738654120ba8cebb1189c6", - "sha256:95c4dc6f61d610bc0ee1edc6f29d993f10febfe5b76bb470b486d90bbece6b22", - "sha256:975218eee0e6d24eb336d0328c768ebc5d617609affaca5dbbd6dd1984f16ed0", - "sha256:ad146dae5977c4dd435eb31373b3fe9b0b1bf26858c6fc452bf6af394067e10b", - "sha256:afe16a84498441d05e9189a15900640a2d2b5e76cf4efe8cbb088ab4f112ee57", - "sha256:b1c43eb1ab7cbf411b8e387dc169acb31f0ca0d8c09ba63f9eac67829585b44f", - "sha256:b90078989ef3fc45cf9221d3859acd1108af7560c52397ff4ace8ad7052a132e", - "sha256:b98e698dc34966e5976e10bbca6d26d6724e6bdea853c7c10162a3235aba6e16", - "sha256:ba5a8b74c2a8af7d862399cdedce1533642fa727def0b8c3e3e02fcb52dca1b1", - "sha256:c31ad0c0c507894e3eaa843415841995bf8de4d6b2d24c6e33099f4bc9fc0d4f", - "sha256:c3b9162bab7e42f21243effc822652dc5bb5e8ff42a4eb62fe7782bcbcdfacf6", - "sha256:c58c6837a2c2a7cf3133983e64173aec11f9c2cd8e87ec2fdc16ce727bcf1a04", - "sha256:c83f7a107abb89a227d6c454c613e7606c12a42b9a4ca9c5d7dad25d47c776ae", - "sha256:cde98f323d6bf161041e7627a5fd763f9fd829bcfcd089804a5fdce7bb6e1b7d", - "sha256:ce91db90dbf37bb6fa0997f26574107e1b9d5ff939315247b7e615baa8ec313b", - "sha256:d00f3c5e0d764a5c9aa5a62d99728c56d455310bcc288a79cab10157b3af426f", - "sha256:d17920f18e6ee090bdd3d0bfffd769d9f2cb4c8ffde3eb203777a3895c128862", - "sha256:d55f011da0a843c3d3df2c2cf4e537b8070a419f891c930245f05d329c4b0689", - "sha256:d742c36ed44f2798c8d3f4bc511f479b9ceef2b93f348671184139e7d708042c", - "sha256:d9a487ef090aea982d748b1b0d74fe7c3950b109df967630a20584f9a99c0683", - "sha256:d9ef084e3dc690ad50137cc05831c52b6ca428096e6deb3c43e95827f531d5ef", - "sha256:da452c2c322e9ce0cfef392e469a26d63d42860f829026a63374fde6b5c5876f", - "sha256:dc4826823121783dccc0871e3f405417ac116055bf184ac04c36f98b75aacd12", - "sha256:de7a5299827253023c55ea549444e058c0eb496931fa05d693b95140a947cb73", - "sha256:e04a1f2a65ad2f93aa20f9ff9f1b672bf912413e5547f60749fa2ef8a644e061", - "sha256:e1ca1ef5ba129718a8fc827b0867f6aa4e893c56eb00003b7367f8a733a9b072", - "sha256:ee40b40aa753d844162dcc80d0fe256b87cba48ca0054f64e68000453caead11", - "sha256:f071854b47d39591ce9a17981c46790acb30518e2f83dfca8db2dfa091178691", - "sha256:f29930bc2921cef955ba39a3ff87d2c4398a0394ae217f41cb02d5c26c8b1b77", - "sha256:f489a2c9e6455d87eabf907ac0b7d230a9786be43fbe884ad184ddf9e9c1e385", - "sha256:f5bf3ead3cb66ab990ee2561373b009db5bc0e857549b6c9ba84b20bc462e172", - "sha256:f6f18898ace4bcd2d41a122916475344a87f1dfdec626ecde9ee802a711bc569", - "sha256:f8112fb501b1e0567a1251a2fd0747baae60a4ab325a871e975b7bb67e59221f", - "sha256:fd31f176429cecbc1ba499d4aba31aaccfea488f418d60376b911269d3b883c5" + "sha256:007ec22fbc573e5eb2fb7dec4198ef8f6bf2fe4ce20020798b2eb5d0abda6138", + "sha256:00819de9e45d42584bed046314c40ea7e9aea95411b38971082cad449392b08c", + "sha256:01948b1d570f83ee7bbf5a60ea2375a89dfb09fd419170e7f5af029510033d24", + "sha256:038f514fe39e235e9fef6717fbf944057bfa24f9b3db9ee551a7ecf584b5b480", + "sha256:03a42ac7895406220124c88911ebee31ba8b2d24c98507f4a8bf826b2937c7f2", + "sha256:05646ebe6b94cc93407b3bf34b9eb26c20722384d068eb7339de802154d61bc5", + "sha256:0631dd7c9f0822cc61c88586ca76d5b5ada26538097d0f1df510b082bad3411a", + "sha256:0b00807e2605f16e1e198f33a53ce3c4523114059b0c09c337209ae55e3823a8", + "sha256:0e1b370d8007c4ae31ee6db7f9a2fe801a42b146cec80a86766e7ad5c4a259cf", + "sha256:15ecd889a709b0080f02721255b3f80bb261c2293d3c748151274dfea93ac871", + "sha256:1b66ccafef7336a1e1f0e389901f60c1d920102315a56df85e49552308fc0486", + "sha256:1bbb122c557a16fafc10354b9d99ebf2f2808a660d78202f10ba9d50786384b9", + "sha256:1eb89d3d29adaf533588f209768a9c02e44e4baf832b08118749c5fad191781d", + "sha256:258c5dd01afc10015866114e210fb7365f0d02d9d059c3c3415382ab633fcbcb", + "sha256:2609e9ab08474702cc67b7702dbb8a80e392c54613ebe80db7e8dbdb79837c68", + "sha256:274cfa632350225ce3fdeb318c23b4a10ec25c0e2c880eff951a3842cf358ac1", + "sha256:28529e08fde6f12eba8677f5a8608500ed33c086f974de68cc65ab218713a59d", + "sha256:2b606353da03edcc71130b52388d25f9a30a126e04caef1fd637e31683033abd", + "sha256:30ca7c3b94708a9d7ae76ff281b2f47d8eaf2579cd05971b5dc681db8caac6e1", + "sha256:333cf6cf8e65f6a1e06e9eb3e643a0c515bb850d470902274239fea02033e9a8", + "sha256:3455522392fb15ff549d92fbf4b73b559d5e43dc522588f7eb3e54c3f38beee7", + "sha256:362f641f9071e5f3ee6f8e7d37d5ed0d95aae656adf4ef578313ee585b585959", + "sha256:3bcd391d083f636c06a68715e69467963d1f9600f85ef556ea82e9ef25f043f7", + "sha256:3dffb610a30d643983aeb185ce134f97f290f8935f0abccdd32c77bed9388b42", + "sha256:3fe407bf93533a6fa82dece0e74dbcaaf5d684e5a51862887f9eaebe6372cd79", + "sha256:413251f6fcf552a33c981c4709a6bba37b12710982fec8e558ae944bfb2abd38", + "sha256:438cd072f75bb6612f2aca29f8bd7cdf6e35e8f160bc312e49fbecab77c99e3a", + "sha256:4470c73c12cd9109db8277287d11f9dd98f77fc54155fc71a7738a83ffcc8ea8", + "sha256:45c3b868724137f713a38376fef8120c166d1eadd50da1855c112fe97954aed8", + "sha256:486f7aabfa292719a2753c016cc3a8f8172965cabb3ea2e7f7436c7f5a22a151", + "sha256:4f05e9727ce409358baa615dbeb9b969db94324a79b5a5cea45d39bdb01d82e6", + "sha256:50aed5155f819873d23520919e16703fc8925e509abbb1a1491b0087d1cd969e", + "sha256:50edbcad60d8f0e3eccc68da67f37268b5144ecc34d59f27a02f9611c1d4eec7", + "sha256:54ca74df1be3c7ca1cf7f4c971c79c2daf48d9aa65dea1a662ae18926f5bc8ce", + "sha256:578a4b875af3e0daaf1ac6fa983d93e0bbfec3ead753b6d6f33d467100cdc67b", + "sha256:597a079284b7ee65ee102bc3a6ea226a37d2b96d0418cc9047490f231dc09fe8", + "sha256:59bb3c54aa420521dc4ce3cc2c3fe2ad82adf7b09403fa1f48ae45c0cbde6628", + "sha256:5c6a5b8c7926ba5d8545c7dd22961a107526562da31a7a32fa2456baf040939f", + "sha256:64f6c17757251e2b8d885d728b6433d9d970573586a78b78ba8929b0f41d045a", + "sha256:679abe5d3858b33c2cf74faec299fda60ea9de62916e8b67e625d65bf069a3b7", + "sha256:741a46d58677d8c733175d7e5aa618d277cd9d880301a380fd296975a9cdd7bc", + "sha256:7789050d9e5d0c309c706953e5e8876e38662d57d45f936902e176d19f1c58ab", + "sha256:77abf6665ae54000b98b3c742bc6ea1d1fb31c394bcabf8b5d2c1ac3ebfe7f3b", + "sha256:79019094f87c9fb44f8d769e41dbb664d6e8fcfd62f665ccce36762deaa0e911", + "sha256:7b06b7843929e41a94ea09eb1ce3927865387e3e23ebe108e0d0d09b08d25be9", + "sha256:7e338c0523d024fad378b376a79faff37fafb3c001872a618cde1d322400a572", + "sha256:7ea7ffc6d6d6f8a11e6f40091a1040995cdff02cfc9ba4c2f30a516cb2633554", + "sha256:8105fd8a890df77b76dd3054cddf01a879fc13e8af576805d667e0fa0224c35d", + "sha256:84afcdea18eda514c25bc68b9af2a2b1adea7c08899175a51fe7c4fb6d551257", + "sha256:9294bbb581f92770e6ed5c19559e1e99255e4ca604a22c5c6397b2f9dd3ee42c", + "sha256:93429602396f3383a797a2a70e5f1de5df8e35535d7806c9f91df06f297e109b", + "sha256:9627cc1a10c8c409b5822a92d57a77f383b554463d1884008e051c32ab1b3742", + "sha256:998f3bd3cfc95e9424a6acd7840cbdd39e45bc09ef87533c006f94ac47296090", + "sha256:9c72109213eb9d3874f7ac8c0c5fa90e072d678e117d9061c06e30c85b4cf0e6", + "sha256:9fc1500fd2a952c5c8e3b29aaf7e3cc6e27e9cfc0a8819b3bce48cc1b849e4cc", + "sha256:a3f00003de6eba42d6e94fabb4125600d6e484846dbf90ea8e48a800430cc142", + "sha256:a45d85cf20b5e0d0aa5a8dca27cce8eddef3292bc29d72dcad1641f4ed50aa16", + "sha256:a7d8d14fe962153fc681f6366bdec33d4356f98a3e3567782aac1b6e0e40109a", + "sha256:a8fa23fe62c436ccf23ff930149c047f060c7126eae3ccea005f0483f27b2e28", + "sha256:aa6658732517ddabe22c9036479eabce6036655ba87a0224c612e1ae6af2087e", + "sha256:aafc8ee9b742ce75044ae9a4d3e60e3d918d15a4c2e08a6c3c3e38fa59b92d94", + "sha256:ab5a5a0c7a7991d90446a198689c0535be89bbd6b410a1f9a66688f0880ec026", + "sha256:acd48d5b80ee80f9432a165c0ac8cbf9253eaddb6113269a5e18699b33958dbb", + "sha256:ad7593bb24b2ab09e65e8a1d385606f0f47c65b5a2ae6c551db67d6653e78c28", + "sha256:baa42524a82f75303f714108fea528ccacf0386af429b69fff141ffef1c534f9", + "sha256:bdfcf6443637c148c4e1a20c48c566aa694fa5e288d34b20fcdc58507882fed3", + "sha256:be7443669ae9c016b71f402e43208e13ddf00912f47f623ee5994e12fc7d4b3f", + "sha256:c02a30b904282777d872266b87b20ed8cc0d1501855e27f831320f471d54d983", + "sha256:c1277cd707c465cd09572a774559a3cc7c7a28802eb3a2a9472588f062097205", + "sha256:c30a0eafc89d28e7f959281b58198a9fa5e99405f716c0289b7892ca345fe45f", + "sha256:c5ce2ce7c997e1971b7184ee37deb6ea9922ef5163c6ee5aa3c274b05f9e12fa", + "sha256:c823bc3971c44ab93e611ab1a46b1eafeae474c0c844aff4b7474287b75fe49c", + "sha256:ce0cdc074d540265bfeb31336e678b4e37316849d13b308607efa527e981f5c2", + "sha256:d1720b4f14c78a3089562b8875b53e36b51c97c51adc53325a69b79b4b48ebcb", + "sha256:d183cf9c797a5291e8301790ed6d053480ed94070637bfaad914dd38b0981f67", + "sha256:d9010c31cd6fa59438da4e58a7f19e4753f7f264300cd152e7f90d4602449762", + "sha256:d9e5e4a85bdb56d224f412d9c98ae4cbd032cc4f3161818f692cd81766eee65a", + "sha256:da1dee8948d2137bb51fbb8a53cce6b1bcc86003c6b42565f008438b806cccd8", + "sha256:df9270660711670e68803107d55c2b5949c2e0f2e4896da176e1ecfc068b974a", + "sha256:e00e3505cd80440f6c98c6d69269dcc2a119f86ad0a9fd70bccc59504bebd68a", + "sha256:e48d5021a84d341bcaf95c8460b152cfbad770d28e5fe14a768988c461b821bc", + "sha256:e7f8b04d83483577fd9200461b057c9f14ced334dcb053090cea1da9c8321a91", + "sha256:edfe3341033a6b53a5c522c802deb2079eee5cbfbb0af032a55064bd65c73a23", + "sha256:ef9c33cc5cbca35808f6c74be11eb7f5f6b14d2311be84a15b594bd3e58b5527", + "sha256:f2d4324a98062be0525d16f768a03e0bbb3b9fe301ceee99611dc9a7953124e6", + "sha256:f3935f82f6f4a3820270842e90456ebad3af15810cf65932bd24da4463bc0a4c", + "sha256:f614ab0c76397661b90b6851a030004dac502e48260ea10f2441abd2207fbcc7", + "sha256:f7db54c7914cc99d901d93a34704833568d86c20925b2762f9fa779f9cd2e70f", + "sha256:fbc6264158392bad9df19537e872d476f7c57adf718944cc1e4495cbabf38e2a", + "sha256:fe2fb38c2ed905a2582948e2de560675e9dfbee94c6d5ccdb1301c6d0a5bf092", + "sha256:ffe595f10566f8276b76dc3a11ae4bb7eba1aac8ddd75811736a15b0d5311414" ], "markers": "python_version >= '3.8'", - "version": "==3.10.5" + "version": "==3.10.10" }, "aionotify": { "hashes": [ @@ -165,6 +165,39 @@ "markers": "python_version >= '3.7'", "version": "==24.2.0" }, + "bcrypt": { + "hashes": [ + "sha256:096a15d26ed6ce37a14c1ac1e48119660f21b24cba457f160a4b830f3fe6b5cb", + "sha256:0da52759f7f30e83f1e30a888d9163a81353ef224d82dc58eb5bb52efcabc399", + "sha256:1bb429fedbe0249465cdd85a58e8376f31bb315e484f16e68ca4c786dcc04291", + "sha256:1d84cf6d877918620b687b8fd1bf7781d11e8a0998f576c7aa939776b512b98d", + "sha256:1ee38e858bf5d0287c39b7a1fc59eec64bbf880c7d504d3a06a96c16e14058e7", + "sha256:1ff39b78a52cf03fdf902635e4c81e544714861ba3f0efc56558979dd4f09170", + "sha256:27fe0f57bb5573104b5a6de5e4153c60814c711b29364c10a75a54bb6d7ff48d", + "sha256:3413bd60460f76097ee2e0a493ccebe4a7601918219c02f503984f0a7ee0aebe", + "sha256:3698393a1b1f1fd5714524193849d0c6d524d33523acca37cd28f02899285060", + "sha256:373db9abe198e8e2c70d12b479464e0d5092cc122b20ec504097b5f2297ed184", + "sha256:39e1d30c7233cfc54f5c3f2c825156fe044efdd3e0b9d309512cc514a263ec2a", + "sha256:3bbbfb2734f0e4f37c5136130405332640a1e46e6b23e000eeff2ba8d005da68", + "sha256:3d3a6d28cb2305b43feac298774b997e372e56c7c7afd90a12b3dc49b189151c", + "sha256:5a1e8aa9b28ae28020a3ac4b053117fb51c57a010b9f969603ed885f23841458", + "sha256:61ed14326ee023917ecd093ee6ef422a72f3aec6f07e21ea5f10622b735538a9", + "sha256:655ea221910bcac76ea08aaa76df427ef8625f92e55a8ee44fbf7753dbabb328", + "sha256:762a2c5fb35f89606a9fde5e51392dad0cd1ab7ae64149a8b935fe8d79dd5ed7", + "sha256:77800b7147c9dc905db1cba26abe31e504d8247ac73580b4aa179f98e6608f34", + "sha256:8ac68872c82f1add6a20bd489870c71b00ebacd2e9134a8aa3f98a0052ab4b0e", + "sha256:8d7bb9c42801035e61c109c345a28ed7e84426ae4865511eb82e913df18f58c2", + "sha256:8f6ede91359e5df88d1f5c1ef47428a4420136f3ce97763e31b86dd8280fbdf5", + "sha256:9c1c4ad86351339c5f320ca372dfba6cb6beb25e8efc659bedd918d921956bae", + "sha256:c02d944ca89d9b1922ceb8a46460dd17df1ba37ab66feac4870f6862a1533c00", + "sha256:c52aac18ea1f4a4f65963ea4f9530c306b56ccd0c6f8c8da0c06976e34a6e841", + "sha256:cb2a8ec2bc07d3553ccebf0746bbf3d19426d1c6d1adbd4fa48925f66af7b9e8", + "sha256:cf69eaf5185fd58f268f805b505ce31f9b9fc2d64b376642164e9244540c1221", + "sha256:f4f4acf526fcd1c34e7ce851147deedd4e26e6402369304220250598b26448db" + ], + "markers": "python_version >= '3.7'", + "version": "==4.2.0" + }, "cachetools": { "hashes": [ "sha256:02134e8439cdc2ffb62023ce1debca2944c3f289d66bb17ead3ab3dede74b292", @@ -181,101 +214,189 @@ "markers": "python_version >= '3.6'", "version": "==2024.8.30" }, + "cffi": { + "hashes": [ + "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", + "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", + "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1", + "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", + "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", + "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", + "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8", + "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36", + "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", + "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", + "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc", + "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", + "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", + "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", + "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", + "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", + "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", + "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", + "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", + "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b", + "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", + "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", + "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c", + "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", + "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", + "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", + "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8", + "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1", + "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", + "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", + "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", + "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595", + "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0", + "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", + "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", + "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", + "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", + "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", + "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", + "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16", + "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", + "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e", + "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", + "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964", + "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", + "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576", + "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", + "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3", + "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662", + "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", + "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", + "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", + "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", + "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", + "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", + "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", + "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", + "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9", + "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7", + "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", + "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a", + "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", + "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", + "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", + "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", + "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87", + "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b" + ], + "markers": "platform_python_implementation != 'PyPy'", + "version": "==1.17.1" + }, "charset-normalizer": { "hashes": [ - "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027", - "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087", - "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786", - "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8", - "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09", - "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185", - "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574", - "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e", - "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519", - "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898", - "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269", - "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3", - "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f", - "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6", - "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8", - "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a", - "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73", - "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc", - "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714", - "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2", - "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc", - "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce", - "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d", - "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e", - "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6", - "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269", - "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96", - "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d", - "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a", - "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4", - "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77", - "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d", - "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0", - "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed", - "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068", - "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac", - "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25", - "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8", - "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab", - "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26", - "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2", - "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db", - "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f", - "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5", - "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99", - "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c", - "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d", - "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811", - "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa", - "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a", - "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03", - "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b", - "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04", - "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c", - "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001", - "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458", - "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389", - "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99", - "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985", - "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537", - "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238", - "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f", - "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d", - "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796", - "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a", - "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143", - "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8", - "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c", - "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5", - "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5", - "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711", - "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4", - "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6", - "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c", - "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7", - "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4", - "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b", - "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae", - "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12", - "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c", - "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae", - "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8", - "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887", - "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b", - "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4", - "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f", - "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5", - "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33", - "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519", - "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561" + "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621", + "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6", + "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8", + "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912", + "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c", + "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b", + "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d", + "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d", + "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95", + "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e", + "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565", + "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64", + "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab", + "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be", + "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e", + "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907", + "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0", + "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2", + "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62", + "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62", + "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23", + "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc", + "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284", + "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca", + "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455", + "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858", + "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b", + "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594", + "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc", + "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db", + "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b", + "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea", + "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6", + "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920", + "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749", + "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7", + "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd", + "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99", + "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242", + "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee", + "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129", + "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2", + "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51", + "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee", + "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8", + "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b", + "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613", + "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742", + "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe", + "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3", + "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5", + "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631", + "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7", + "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15", + "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c", + "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea", + "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417", + "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250", + "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88", + "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca", + "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa", + "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99", + "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149", + "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41", + "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574", + "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0", + "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f", + "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d", + "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654", + "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3", + "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19", + "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90", + "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578", + "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9", + "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1", + "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51", + "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719", + "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236", + "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a", + "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c", + "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade", + "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944", + "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc", + "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6", + "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6", + "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27", + "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6", + "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2", + "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12", + "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf", + "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114", + "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7", + "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf", + "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d", + "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b", + "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed", + "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03", + "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4", + "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67", + "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365", + "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a", + "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748", + "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b", + "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", + "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482" ], "markers": "python_full_version >= '3.7.0'", - "version": "==3.3.2" + "version": "==3.4.0" }, "click": { "hashes": [ @@ -293,6 +414,39 @@ "markers": "platform_system == 'Windows'", "version": "==0.4.6" }, + "cryptography": { + "hashes": [ + "sha256:0c580952eef9bf68c4747774cde7ec1d85a6e61de97281f2dba83c7d2c806362", + "sha256:0f996e7268af62598f2fc1204afa98a3b5712313a55c4c9d434aef49cadc91d4", + "sha256:1ec0bcf7e17c0c5669d881b1cd38c4972fade441b27bda1051665faaa89bdcaa", + "sha256:281c945d0e28c92ca5e5930664c1cefd85efe80e5c0d2bc58dd63383fda29f83", + "sha256:2ce6fae5bdad59577b44e4dfed356944fbf1d925269114c28be377692643b4ff", + "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805", + "sha256:443c4a81bb10daed9a8f334365fe52542771f25aedaf889fd323a853ce7377d6", + "sha256:4a02ded6cd4f0a5562a8887df8b3bd14e822a90f97ac5e544c162899bc467664", + "sha256:53a583b6637ab4c4e3591a15bc9db855b8d9dee9a669b550f311480acab6eb08", + "sha256:63efa177ff54aec6e1c0aefaa1a241232dcd37413835a9b674b6e3f0ae2bfd3e", + "sha256:74f57f24754fe349223792466a709f8e0c093205ff0dca557af51072ff47ab18", + "sha256:7e1ce50266f4f70bf41a2c6dc4358afadae90e2a1e5342d3c08883df1675374f", + "sha256:81ef806b1fef6b06dcebad789f988d3b37ccaee225695cf3e07648eee0fc6b73", + "sha256:846da004a5804145a5f441b8530b4bf35afbf7da70f82409f151695b127213d5", + "sha256:8ac43ae87929a5982f5948ceda07001ee5e83227fd69cf55b109144938d96984", + "sha256:9762ea51a8fc2a88b70cf2995e5675b38d93bf36bd67d91721c309df184f49bd", + "sha256:a2a431ee15799d6db9fe80c82b055bae5a752bef645bba795e8e52687c69efe3", + "sha256:bf7a1932ac4176486eab36a19ed4c0492da5d97123f1406cf15e41b05e787d2e", + "sha256:c2e6fc39c4ab499049df3bdf567f768a723a5e8464816e8f009f121a5a9f4405", + "sha256:cbeb489927bd7af4aa98d4b261af9a5bc025bd87f0e3547e11584be9e9427be2", + "sha256:d03b5621a135bffecad2c73e9f4deb1a0f977b9a8ffe6f8e002bf6c9d07b918c", + "sha256:d56e96520b1020449bbace2b78b603442e7e378a9b3bd68de65c782db1507995", + "sha256:df6b6c6d742395dd77a23ea3728ab62f98379eff8fb61be2744d4679ab678f73", + "sha256:e1be4655c7ef6e1bbe6b5d0403526601323420bcf414598955968c9ef3eb7d16", + "sha256:f18c716be16bc1fea8e95def49edf46b82fccaa88587a45f8dc0ff6ab5d8e0a7", + "sha256:f46304d6f0c6ab8e52770addfa2fc41e6629495548862279641972b6215451cd", + "sha256:f7b178f11ed3664fd0e995a47ed2b5ff0a12d893e41dd0494f406d1cf555cab7" + ], + "markers": "python_version >= '3.7'", + "version": "==43.0.3" + }, "exceptiongroup": { "hashes": [ "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", @@ -303,111 +457,126 @@ }, "frozenlist": { "hashes": [ - "sha256:04ced3e6a46b4cfffe20f9ae482818e34eba9b5fb0ce4056e4cc9b6e212d09b7", - "sha256:0633c8d5337cb5c77acbccc6357ac49a1770b8c487e5b3505c57b949b4b82e98", - "sha256:068b63f23b17df8569b7fdca5517edef76171cf3897eb68beb01341131fbd2ad", - "sha256:0c250a29735d4f15321007fb02865f0e6b6a41a6b88f1f523ca1596ab5f50bd5", - "sha256:1979bc0aeb89b33b588c51c54ab0161791149f2461ea7c7c946d95d5f93b56ae", - "sha256:1a4471094e146b6790f61b98616ab8e44f72661879cc63fa1049d13ef711e71e", - "sha256:1b280e6507ea8a4fa0c0a7150b4e526a8d113989e28eaaef946cc77ffd7efc0a", - "sha256:1d0ce09d36d53bbbe566fe296965b23b961764c0bcf3ce2fa45f463745c04701", - "sha256:20b51fa3f588ff2fe658663db52a41a4f7aa6c04f6201449c6c7c476bd255c0d", - "sha256:23b2d7679b73fe0e5a4560b672a39f98dfc6f60df63823b0a9970525325b95f6", - "sha256:23b701e65c7b36e4bf15546a89279bd4d8675faabc287d06bbcfac7d3c33e1e6", - "sha256:2471c201b70d58a0f0c1f91261542a03d9a5e088ed3dc6c160d614c01649c106", - "sha256:27657df69e8801be6c3638054e202a135c7f299267f1a55ed3a598934f6c0d75", - "sha256:29acab3f66f0f24674b7dc4736477bcd4bc3ad4b896f5f45379a67bce8b96868", - "sha256:32453c1de775c889eb4e22f1197fe3bdfe457d16476ea407472b9442e6295f7a", - "sha256:3a670dc61eb0d0eb7080890c13de3066790f9049b47b0de04007090807c776b0", - "sha256:3e0153a805a98f5ada7e09826255ba99fb4f7524bb81bf6b47fb702666484ae1", - "sha256:410478a0c562d1a5bcc2f7ea448359fcb050ed48b3c6f6f4f18c313a9bdb1826", - "sha256:442acde1e068288a4ba7acfe05f5f343e19fac87bfc96d89eb886b0363e977ec", - "sha256:48f6a4533887e189dae092f1cf981f2e3885175f7a0f33c91fb5b7b682b6bab6", - "sha256:4f57dab5fe3407b6c0c1cc907ac98e8a189f9e418f3b6e54d65a718aaafe3950", - "sha256:4f9c515e7914626b2a2e1e311794b4c35720a0be87af52b79ff8e1429fc25f19", - "sha256:55fdc093b5a3cb41d420884cdaf37a1e74c3c37a31f46e66286d9145d2063bd0", - "sha256:5667ed53d68d91920defdf4035d1cdaa3c3121dc0b113255124bcfada1cfa1b8", - "sha256:590344787a90ae57d62511dd7c736ed56b428f04cd8c161fcc5e7232c130c69a", - "sha256:5a7d70357e7cee13f470c7883a063aae5fe209a493c57d86eb7f5a6f910fae09", - "sha256:5c3894db91f5a489fc8fa6a9991820f368f0b3cbdb9cd8849547ccfab3392d86", - "sha256:5c849d495bf5154cd8da18a9eb15db127d4dba2968d88831aff6f0331ea9bd4c", - "sha256:64536573d0a2cb6e625cf309984e2d873979709f2cf22839bf2d61790b448ad5", - "sha256:693945278a31f2086d9bf3df0fe8254bbeaef1fe71e1351c3bd730aa7d31c41b", - "sha256:6db4667b187a6742b33afbbaf05a7bc551ffcf1ced0000a571aedbb4aa42fc7b", - "sha256:6eb73fa5426ea69ee0e012fb59cdc76a15b1283d6e32e4f8dc4482ec67d1194d", - "sha256:722e1124aec435320ae01ee3ac7bec11a5d47f25d0ed6328f2273d287bc3abb0", - "sha256:7268252af60904bf52c26173cbadc3a071cece75f873705419c8681f24d3edea", - "sha256:74fb4bee6880b529a0c6560885fce4dc95936920f9f20f53d99a213f7bf66776", - "sha256:780d3a35680ced9ce682fbcf4cb9c2bad3136eeff760ab33707b71db84664e3a", - "sha256:82e8211d69a4f4bc360ea22cd6555f8e61a1bd211d1d5d39d3d228b48c83a897", - "sha256:89aa2c2eeb20957be2d950b85974b30a01a762f3308cd02bb15e1ad632e22dc7", - "sha256:8aefbba5f69d42246543407ed2461db31006b0f76c4e32dfd6f42215a2c41d09", - "sha256:96ec70beabbd3b10e8bfe52616a13561e58fe84c0101dd031dc78f250d5128b9", - "sha256:9750cc7fe1ae3b1611bb8cfc3f9ec11d532244235d75901fb6b8e42ce9229dfe", - "sha256:9acbb16f06fe7f52f441bb6f413ebae6c37baa6ef9edd49cdd567216da8600cd", - "sha256:9d3e0c25a2350080e9319724dede4f31f43a6c9779be48021a7f4ebde8b2d742", - "sha256:a06339f38e9ed3a64e4c4e43aec7f59084033647f908e4259d279a52d3757d09", - "sha256:a0cb6f11204443f27a1628b0e460f37fb30f624be6051d490fa7d7e26d4af3d0", - "sha256:a7496bfe1da7fb1a4e1cc23bb67c58fab69311cc7d32b5a99c2007b4b2a0e932", - "sha256:a828c57f00f729620a442881cc60e57cfcec6842ba38e1b19fd3e47ac0ff8dc1", - "sha256:a9b2de4cf0cdd5bd2dee4c4f63a653c61d2408055ab77b151c1957f221cabf2a", - "sha256:b46c8ae3a8f1f41a0d2ef350c0b6e65822d80772fe46b653ab6b6274f61d4a49", - "sha256:b7e3ed87d4138356775346e6845cccbe66cd9e207f3cd11d2f0b9fd13681359d", - "sha256:b7f2f9f912dca3934c1baec2e4585a674ef16fe00218d833856408c48d5beee7", - "sha256:ba60bb19387e13597fb059f32cd4d59445d7b18b69a745b8f8e5db0346f33480", - "sha256:beee944ae828747fd7cb216a70f120767fc9f4f00bacae8543c14a6831673f89", - "sha256:bfa4a17e17ce9abf47a74ae02f32d014c5e9404b6d9ac7f729e01562bbee601e", - "sha256:c037a86e8513059a2613aaba4d817bb90b9d9b6b69aace3ce9c877e8c8ed402b", - "sha256:c302220494f5c1ebeb0912ea782bcd5e2f8308037b3c7553fad0e48ebad6ad82", - "sha256:c6321c9efe29975232da3bd0af0ad216800a47e93d763ce64f291917a381b8eb", - "sha256:c757a9dd70d72b076d6f68efdbb9bc943665ae954dad2801b874c8c69e185068", - "sha256:c99169d4ff810155ca50b4da3b075cbde79752443117d89429595c2e8e37fed8", - "sha256:c9c92be9fd329ac801cc420e08452b70e7aeab94ea4233a4804f0915c14eba9b", - "sha256:cc7b01b3754ea68a62bd77ce6020afaffb44a590c2289089289363472d13aedb", - "sha256:db9e724bebd621d9beca794f2a4ff1d26eed5965b004a97f1f1685a173b869c2", - "sha256:dca69045298ce5c11fd539682cff879cc1e664c245d1c64da929813e54241d11", - "sha256:dd9b1baec094d91bf36ec729445f7769d0d0cf6b64d04d86e45baf89e2b9059b", - "sha256:e02a0e11cf6597299b9f3bbd3f93d79217cb90cfd1411aec33848b13f5c656cc", - "sha256:e6a20a581f9ce92d389a8c7d7c3dd47c81fd5d6e655c8dddf341e14aa48659d0", - "sha256:e7004be74cbb7d9f34553a5ce5fb08be14fb33bc86f332fb71cbe5216362a497", - "sha256:e774d53b1a477a67838a904131c4b0eef6b3d8a651f8b138b04f748fccfefe17", - "sha256:edb678da49d9f72c9f6c609fbe41a5dfb9a9282f9e6a2253d5a91e0fc382d7c0", - "sha256:f146e0911cb2f1da549fc58fc7bcd2b836a44b79ef871980d605ec392ff6b0d2", - "sha256:f56e2333dda1fe0f909e7cc59f021eba0d2307bc6f012a1ccf2beca6ba362439", - "sha256:f9a3ea26252bd92f570600098783d1371354d89d5f6b7dfd87359d669f2109b5", - "sha256:f9aa1878d1083b276b0196f2dfbe00c9b7e752475ed3b682025ff20c1c1f51ac", - "sha256:fb3c2db03683b5767dedb5769b8a40ebb47d6f7f45b1b3e3b4b51ec8ad9d9825", - "sha256:fbeb989b5cc29e8daf7f976b421c220f1b8c731cbf22b9130d8815418ea45887", - "sha256:fde5bd59ab5357e3853313127f4d3565fc7dad314a74d7b5d43c22c6a5ed2ced", - "sha256:fe1a06da377e3a1062ae5fe0926e12b84eceb8a50b350ddca72dc85015873f74" + "sha256:000a77d6034fbad9b6bb880f7ec073027908f1b40254b5d6f26210d2dab1240e", + "sha256:03d33c2ddbc1816237a67f66336616416e2bbb6beb306e5f890f2eb22b959cdf", + "sha256:04a5c6babd5e8fb7d3c871dc8b321166b80e41b637c31a995ed844a6139942b6", + "sha256:0996c66760924da6e88922756d99b47512a71cfd45215f3570bf1e0b694c206a", + "sha256:0cc974cc93d32c42e7b0f6cf242a6bd941c57c61b618e78b6c0a96cb72788c1d", + "sha256:0f253985bb515ecd89629db13cb58d702035ecd8cfbca7d7a7e29a0e6d39af5f", + "sha256:11aabdd62b8b9c4b84081a3c246506d1cddd2dd93ff0ad53ede5defec7886b28", + "sha256:12f78f98c2f1c2429d42e6a485f433722b0061d5c0b0139efa64f396efb5886b", + "sha256:140228863501b44b809fb39ec56b5d4071f4d0aa6d216c19cbb08b8c5a7eadb9", + "sha256:1431d60b36d15cda188ea222033eec8e0eab488f39a272461f2e6d9e1a8e63c2", + "sha256:15538c0cbf0e4fa11d1e3a71f823524b0c46299aed6e10ebb4c2089abd8c3bec", + "sha256:15b731db116ab3aedec558573c1a5eec78822b32292fe4f2f0345b7f697745c2", + "sha256:17dcc32fc7bda7ce5875435003220a457bcfa34ab7924a49a1c19f55b6ee185c", + "sha256:1893f948bf6681733aaccf36c5232c231e3b5166d607c5fa77773611df6dc336", + "sha256:189f03b53e64144f90990d29a27ec4f7997d91ed3d01b51fa39d2dbe77540fd4", + "sha256:1a8ea951bbb6cacd492e3948b8da8c502a3f814f5d20935aae74b5df2b19cf3d", + "sha256:1b96af8c582b94d381a1c1f51ffaedeb77c821c690ea5f01da3d70a487dd0a9b", + "sha256:1e76bfbc72353269c44e0bc2cfe171900fbf7f722ad74c9a7b638052afe6a00c", + "sha256:2150cc6305a2c2ab33299453e2968611dacb970d2283a14955923062c8d00b10", + "sha256:226d72559fa19babe2ccd920273e767c96a49b9d3d38badd7c91a0fdeda8ea08", + "sha256:237f6b23ee0f44066219dae14c70ae38a63f0440ce6750f868ee08775073f942", + "sha256:29d94c256679247b33a3dc96cce0f93cbc69c23bf75ff715919332fdbb6a32b8", + "sha256:2b5e23253bb709ef57a8e95e6ae48daa9ac5f265637529e4ce6b003a37b2621f", + "sha256:2d0da8bbec082bf6bf18345b180958775363588678f64998c2b7609e34719b10", + "sha256:2f3f7a0fbc219fb4455264cae4d9f01ad41ae6ee8524500f381de64ffaa077d5", + "sha256:30c72000fbcc35b129cb09956836c7d7abf78ab5416595e4857d1cae8d6251a6", + "sha256:31115ba75889723431aa9a4e77d5f398f5cf976eea3bdf61749731f62d4a4a21", + "sha256:31a9ac2b38ab9b5a8933b693db4939764ad3f299fcaa931a3e605bc3460e693c", + "sha256:366d8f93e3edfe5a918c874702f78faac300209a4d5bf38352b2c1bdc07a766d", + "sha256:374ca2dabdccad8e2a76d40b1d037f5bd16824933bf7bcea3e59c891fd4a0923", + "sha256:44c49271a937625619e862baacbd037a7ef86dd1ee215afc298a417ff3270608", + "sha256:45e0896250900b5aa25180f9aec243e84e92ac84bd4a74d9ad4138ef3f5c97de", + "sha256:498524025a5b8ba81695761d78c8dd7382ac0b052f34e66939c42df860b8ff17", + "sha256:50cf5e7ee9b98f22bdecbabf3800ae78ddcc26e4a435515fc72d97903e8488e0", + "sha256:52ef692a4bc60a6dd57f507429636c2af8b6046db8b31b18dac02cbc8f507f7f", + "sha256:561eb1c9579d495fddb6da8959fd2a1fca2c6d060d4113f5844b433fc02f2641", + "sha256:5a3ba5f9a0dfed20337d3e966dc359784c9f96503674c2faf015f7fe8e96798c", + "sha256:5b6a66c18b5b9dd261ca98dffcb826a525334b2f29e7caa54e182255c5f6a65a", + "sha256:5c28f4b5dbef8a0d8aad0d4de24d1e9e981728628afaf4ea0792f5d0939372f0", + "sha256:5d7f5a50342475962eb18b740f3beecc685a15b52c91f7d975257e13e029eca9", + "sha256:6321899477db90bdeb9299ac3627a6a53c7399c8cd58d25da094007402b039ab", + "sha256:6482a5851f5d72767fbd0e507e80737f9c8646ae7fd303def99bfe813f76cf7f", + "sha256:666534d15ba8f0fda3f53969117383d5dc021266b3c1a42c9ec4855e4b58b9d3", + "sha256:683173d371daad49cffb8309779e886e59c2f369430ad28fe715f66d08d4ab1a", + "sha256:6e9080bb2fb195a046e5177f10d9d82b8a204c0736a97a153c2466127de87784", + "sha256:73f2e31ea8dd7df61a359b731716018c2be196e5bb3b74ddba107f694fbd7604", + "sha256:7437601c4d89d070eac8323f121fcf25f88674627505334654fd027b091db09d", + "sha256:76e4753701248476e6286f2ef492af900ea67d9706a0155335a40ea21bf3b2f5", + "sha256:7707a25d6a77f5d27ea7dc7d1fc608aa0a478193823f88511ef5e6b8a48f9d03", + "sha256:7948140d9f8ece1745be806f2bfdf390127cf1a763b925c4a805c603df5e697e", + "sha256:7a1a048f9215c90973402e26c01d1cff8a209e1f1b53f72b95c13db61b00f953", + "sha256:7d57d8f702221405a9d9b40f9da8ac2e4a1a8b5285aac6100f3393675f0a85ee", + "sha256:7f3c8c1dacd037df16e85227bac13cca58c30da836c6f936ba1df0c05d046d8d", + "sha256:81d5af29e61b9c8348e876d442253723928dce6433e0e76cd925cd83f1b4b817", + "sha256:828afae9f17e6de596825cf4228ff28fbdf6065974e5ac1410cecc22f699d2b3", + "sha256:87f724d055eb4785d9be84e9ebf0f24e392ddfad00b3fe036e43f489fafc9039", + "sha256:8969190d709e7c48ea386db202d708eb94bdb29207a1f269bab1196ce0dcca1f", + "sha256:90646abbc7a5d5c7c19461d2e3eeb76eb0b204919e6ece342feb6032c9325ae9", + "sha256:91d6c171862df0a6c61479d9724f22efb6109111017c87567cfeb7b5d1449fdf", + "sha256:9272fa73ca71266702c4c3e2d4a28553ea03418e591e377a03b8e3659d94fa76", + "sha256:92b5278ed9d50fe610185ecd23c55d8b307d75ca18e94c0e7de328089ac5dcba", + "sha256:97160e245ea33d8609cd2b8fd997c850b56db147a304a262abc2b3be021a9171", + "sha256:977701c081c0241d0955c9586ffdd9ce44f7a7795df39b9151cd9a6fd0ce4cfb", + "sha256:9b7dc0c4338e6b8b091e8faf0db3168a37101943e687f373dce00959583f7439", + "sha256:9b93d7aaa36c966fa42efcaf716e6b3900438632a626fb09c049f6a2f09fc631", + "sha256:9bbcdfaf4af7ce002694a4e10a0159d5a8d20056a12b05b45cea944a4953f972", + "sha256:9c2623347b933fcb9095841f1cc5d4ff0b278addd743e0e966cb3d460278840d", + "sha256:a2fe128eb4edeabe11896cb6af88fca5346059f6c8d807e3b910069f39157869", + "sha256:a72b7a6e3cd2725eff67cd64c8f13335ee18fc3c7befc05aed043d24c7b9ccb9", + "sha256:a9fe0f1c29ba24ba6ff6abf688cb0b7cf1efab6b6aa6adc55441773c252f7411", + "sha256:b97f7b575ab4a8af9b7bc1d2ef7f29d3afee2226bd03ca3875c16451ad5a7723", + "sha256:bdac3c7d9b705d253b2ce370fde941836a5f8b3c5c2b8fd70940a3ea3af7f4f2", + "sha256:c03eff4a41bd4e38415cbed054bbaff4a075b093e2394b6915dca34a40d1e38b", + "sha256:c16d2fa63e0800723139137d667e1056bee1a1cf7965153d2d104b62855e9b99", + "sha256:c1fac3e2ace2eb1052e9f7c7db480818371134410e1f5c55d65e8f3ac6d1407e", + "sha256:ce3aa154c452d2467487765e3adc730a8c153af77ad84096bc19ce19a2400840", + "sha256:cee6798eaf8b1416ef6909b06f7dc04b60755206bddc599f52232606e18179d3", + "sha256:d1b3eb7b05ea246510b43a7e53ed1653e55c2121019a97e60cad7efb881a97bb", + "sha256:d994863bba198a4a518b467bb971c56e1db3f180a25c6cf7bb1949c267f748c3", + "sha256:dd47a5181ce5fcb463b5d9e17ecfdb02b678cca31280639255ce9d0e5aa67af0", + "sha256:dd94994fc91a6177bfaafd7d9fd951bc8689b0a98168aa26b5f543868548d3ca", + "sha256:de537c11e4aa01d37db0d403b57bd6f0546e71a82347a97c6a9f0dcc532b3a45", + "sha256:df6e2f325bfee1f49f81aaac97d2aa757c7646534a06f8f577ce184afe2f0a9e", + "sha256:e66cc454f97053b79c2ab09c17fbe3c825ea6b4de20baf1be28919460dd7877f", + "sha256:e79225373c317ff1e35f210dd5f1344ff31066ba8067c307ab60254cd3a78ad5", + "sha256:f1577515d35ed5649d52ab4319db757bb881ce3b2b796d7283e6634d99ace307", + "sha256:f1e6540b7fa044eee0bb5111ada694cf3dc15f2b0347ca125ee9ca984d5e9e6e", + "sha256:f2ac49a9bedb996086057b75bf93538240538c6d9b38e57c82d51f75a73409d2", + "sha256:f47c9c9028f55a04ac254346e92977bf0f166c483c74b4232bee19a6697e4778", + "sha256:f5f9da7f5dbc00a604fe74aa02ae7c98bcede8a3b8b9666f9f86fc13993bc71a", + "sha256:fd74520371c3c4175142d02a976aee0b4cb4a7cc912a60586ffd8d5929979b30", + "sha256:feeb64bc9bcc6b45c6311c9e9b99406660a9c05ca8a5b30d14a78555088b0b3a" ], "markers": "python_version >= '3.8'", - "version": "==1.4.1" + "version": "==1.5.0" }, "google-api-core": { "hashes": [ - "sha256:53ec0258f2837dd53bbd3d3df50f5359281b3cc13f800c941dd15a9b5a415af4", - "sha256:ca07de7e8aa1c98a8bfca9321890ad2340ef7f2eb136e558cee68f24b94b0a8f" + "sha256:4a152fd11a9f774ea606388d423b68aa7e6d6a0ffe4c8266f74979613ec09f81", + "sha256:6869eacb2a37720380ba5898312af79a4d30b8bca1548fb4093e0697dc4bdf5d" ], "markers": "python_version >= '3.7'", - "version": "==2.19.2" + "version": "==2.21.0" }, "google-api-python-client": { "hashes": [ - "sha256:41f671be10fa077ee5143ee9f0903c14006d39dc644564f4e044ae96b380bf68", - "sha256:b1e62c9889c5ef6022f11d30d7ef23dc55100300f0e8aaf8aa09e8e92540acad" + "sha256:1a5232e9cfed8c201799d9327e4d44dc7ea7daa3c6e1627fca41aa201539c0da", + "sha256:b9d68c6b14ec72580d66001bd33c5816b78e2134b93ccc5cf8f624516b561750" ], "index": "pypi", "markers": "python_version >= '3.7'", - "version": "==2.146.0" + "version": "==2.149.0" }, "google-auth": { "hashes": [ - "sha256:72fd4733b80b6d777dcde515628a9eb4a577339437012874ea286bca7261ee65", - "sha256:8eb87396435c19b20d32abd2f984e31c191a15284af72eb922f10e5bde9c04cc" + "sha256:25df55f327ef021de8be50bad0dfd4a916ad0de96da86cd05661c9297723ad3f", + "sha256:f4c64ed4e01e8e8b646ef34c018f8bf3338df0c8e37d8b3bba40e7f574a3278a" ], "markers": "python_version >= '3.7'", - "version": "==2.34.0" + "version": "==2.35.0" }, "google-auth-httplib2": { "hashes": [ @@ -607,7 +776,6 @@ "sha256:f870204a840a60da0b12273ef34f7051e98c3b5961b61b0c2c1be6dfd64fbcd3", "sha256:ffa75af20b44f8dba823498024771d5ac50620e6915abac414251bd971b4529f" ], - "index": "pypi", "markers": "python_version >= '3.9'", "version": "==1.26.4" }, @@ -657,48 +825,174 @@ }, "pandas": { "hashes": [ - "sha256:001910ad31abc7bf06f49dcc903755d2f7f3a9186c0c040b827e522e9cef0863", - "sha256:0ca6377b8fca51815f382bd0b697a0814c8bda55115678cbc94c30aacbb6eff2", - "sha256:0cace394b6ea70c01ca1595f839cf193df35d1575986e484ad35c4aeae7266c1", - "sha256:1cb51fe389360f3b5a4d57dbd2848a5f033350336ca3b340d1c53a1fad33bcad", - "sha256:2925720037f06e89af896c70bca73459d7e6a4be96f9de79e2d440bd499fe0db", - "sha256:3e374f59e440d4ab45ca2fffde54b81ac3834cf5ae2cdfa69c90bc03bde04d76", - "sha256:40ae1dffb3967a52203105a077415a86044a2bea011b5f321c6aa64b379a3f51", - "sha256:43498c0bdb43d55cb162cdc8c06fac328ccb5d2eabe3cadeb3529ae6f0517c32", - "sha256:4abfe0be0d7221be4f12552995e58723c7422c80a659da13ca382697de830c08", - "sha256:58b84b91b0b9f4bafac2a0ac55002280c094dfc6402402332c0913a59654ab2b", - "sha256:640cef9aa381b60e296db324337a554aeeb883ead99dc8f6c18e81a93942f5f4", - "sha256:66b479b0bd07204e37583c191535505410daa8df638fd8e75ae1b383851fe921", - "sha256:696039430f7a562b74fa45f540aca068ea85fa34c244d0deee539cb6d70aa288", - "sha256:6d2123dc9ad6a814bcdea0f099885276b31b24f7edf40f6cdbc0912672e22eee", - "sha256:8635c16bf3d99040fdf3ca3db669a7250ddf49c55dc4aa8fe0ae0fa8d6dcc1f0", - "sha256:873d13d177501a28b2756375d59816c365e42ed8417b41665f346289adc68d24", - "sha256:8e5a0b00e1e56a842f922e7fae8ae4077aee4af0acb5ae3622bd4b4c30aedf99", - "sha256:8e90497254aacacbc4ea6ae5e7a8cd75629d6ad2b30025a4a8b09aa4faf55151", - "sha256:9057e6aa78a584bc93a13f0a9bf7e753a5e9770a30b4d758b8d5f2a62a9433cd", - "sha256:90c6fca2acf139569e74e8781709dccb6fe25940488755716d1d354d6bc58bce", - "sha256:92fd6b027924a7e178ac202cfbe25e53368db90d56872d20ffae94b96c7acc57", - "sha256:9dfde2a0ddef507a631dc9dc4af6a9489d5e2e740e226ad426a05cabfbd7c8ef", - "sha256:9e79019aba43cb4fda9e4d983f8e88ca0373adbb697ae9c6c43093218de28b54", - "sha256:a77e9d1c386196879aa5eb712e77461aaee433e54c68cf253053a73b7e49c33a", - "sha256:c7adfc142dac335d8c1e0dcbd37eb8617eac386596eb9e1a1b77791cf2498238", - "sha256:d187d355ecec3629624fccb01d104da7d7f391db0311145817525281e2804d23", - "sha256:ddf818e4e6c7c6f4f7c8a12709696d193976b591cc7dc50588d3d1a6b5dc8772", - "sha256:e9b79011ff7a0f4b1d6da6a61aa1aa604fb312d6647de5bad20013682d1429ce", - "sha256:eee3a87076c0756de40b05c5e9a6069c035ba43e8dd71c379e68cab2c20f16ad" + "sha256:062309c1b9ea12a50e8ce661145c6aab431b1e99530d3cd60640e255778bd43a", + "sha256:15c0e1e02e93116177d29ff83e8b1619c93ddc9c49083f237d4312337a61165d", + "sha256:1948ddde24197a0f7add2bdc4ca83bf2b1ef84a1bc8ccffd95eda17fd836ecb5", + "sha256:1db71525a1538b30142094edb9adc10be3f3e176748cd7acc2240c2f2e5aa3a4", + "sha256:22a9d949bfc9a502d320aa04e5d02feab689d61da4e7764b62c30b991c42c5f0", + "sha256:29401dbfa9ad77319367d36940cd8a0b3a11aba16063e39632d98b0e931ddf32", + "sha256:31d0ced62d4ea3e231a9f228366919a5ea0b07440d9d4dac345376fd8e1477ea", + "sha256:3508d914817e153ad359d7e069d752cdd736a247c322d932eb89e6bc84217f28", + "sha256:37e0aced3e8f539eccf2e099f65cdb9c8aa85109b0be6e93e2baff94264bdc6f", + "sha256:381175499d3802cde0eabbaf6324cce0c4f5d52ca6f8c377c29ad442f50f6348", + "sha256:38cf8125c40dae9d5acc10fa66af8ea6fdf760b2714ee482ca691fc66e6fcb18", + "sha256:3b71f27954685ee685317063bf13c7709a7ba74fc996b84fc6821c59b0f06468", + "sha256:3fc6873a41186404dad67245896a6e440baacc92f5b716ccd1bc9ed2995ab2c5", + "sha256:4850ba03528b6dd51d6c5d273c46f183f39a9baf3f0143e566b89450965b105e", + "sha256:4f18ba62b61d7e192368b84517265a99b4d7ee8912f8708660fb4a366cc82667", + "sha256:56534ce0746a58afaf7942ba4863e0ef81c9c50d3f0ae93e9497d6a41a057645", + "sha256:59ef3764d0fe818125a5097d2ae867ca3fa64df032331b7e0917cf5d7bf66b13", + "sha256:5dbca4c1acd72e8eeef4753eeca07de9b1db4f398669d5994086f788a5d7cc30", + "sha256:5de54125a92bb4d1c051c0659e6fcb75256bf799a732a87184e5ea503965bce3", + "sha256:61c5ad4043f791b61dd4752191d9f07f0ae412515d59ba8f005832a532f8736d", + "sha256:6374c452ff3ec675a8f46fd9ab25c4ad0ba590b71cf0656f8b6daa5202bca3fb", + "sha256:63cc132e40a2e084cf01adf0775b15ac515ba905d7dcca47e9a251819c575ef3", + "sha256:66108071e1b935240e74525006034333f98bcdb87ea116de573a6a0dccb6c039", + "sha256:6dfcb5ee8d4d50c06a51c2fffa6cff6272098ad6540aed1a76d15fb9318194d8", + "sha256:7c2875855b0ff77b2a64a0365e24455d9990730d6431b9e0ee18ad8acee13dbd", + "sha256:7eee9e7cea6adf3e3d24e304ac6b8300646e2a5d1cd3a3c2abed9101b0846761", + "sha256:800250ecdadb6d9c78eae4990da62743b857b470883fa27f652db8bdde7f6659", + "sha256:86976a1c5b25ae3f8ccae3a5306e443569ee3c3faf444dfd0f41cda24667ad57", + "sha256:8cd6d7cc958a3910f934ea8dbdf17b2364827bb4dafc38ce6eef6bb3d65ff09c", + "sha256:99df71520d25fade9db7c1076ac94eb994f4d2673ef2aa2e86ee039b6746d20c", + "sha256:a5a1595fe639f5988ba6a8e5bc9649af3baf26df3998a0abe56c02609392e0a4", + "sha256:ad5b65698ab28ed8d7f18790a0dc58005c7629f227be9ecc1072aa74c0c1d43a", + "sha256:b1d432e8d08679a40e2a6d8b2f9770a5c21793a6f9f47fdd52c5ce1948a5a8a9", + "sha256:b8661b0238a69d7aafe156b7fa86c44b881387509653fdf857bebc5e4008ad42", + "sha256:ba96630bc17c875161df3818780af30e43be9b166ce51c9a18c1feae342906c2", + "sha256:bc6b93f9b966093cb0fd62ff1a7e4c09e6d546ad7c1de191767baffc57628f39", + "sha256:c124333816c3a9b03fbeef3a9f230ba9a737e9e5bb4060aa2107a86cc0a497fc", + "sha256:cd8d0c3be0515c12fed0bdbae072551c8b54b7192c7b1fda0ba56059a0179698", + "sha256:d9c45366def9a3dd85a6454c0e7908f2b3b8e9c138f5dc38fed7ce720d8453ed", + "sha256:f00d1345d84d8c86a63e476bb4955e46458b304b9575dcf71102b5c705320015", + "sha256:f3a255b2c19987fbbe62a9dfd6cff7ff2aa9ccab3fc75218fd4b7530f01efa24", + "sha256:fffb8ae78d8af97f849404f21411c95062db1496aeb3e56f146f0355c9989319" ], "index": "pypi", "markers": "python_version >= '3.9'", - "version": "==2.2.2" + "version": "==2.2.3" }, "pandas-stubs": { "hashes": [ - "sha256:3c0951a2c3e45e3475aed9d80b7147ae82f176b9e42e9fb321cfdebf3d411b3d", - "sha256:e230f5fa4065f9417804f4d65cd98f86c002efcc07933e8abcd48c3fad9c30a2" + "sha256:3a6f8f142105a42550be677ba741ba532621f4e0acad2155c0e7b2450f114cfa", + "sha256:d4ab618253f0acf78a5d0d2bfd6dffdd92d91a56a69bdc8144e5a5c6d25be3b5" ], "index": "pypi", "markers": "python_version >= '3.10'", - "version": "==2.2.2.240909" + "version": "==2.2.3.241009" + }, + "paramiko": { + "hashes": [ + "sha256:1fedf06b085359051cd7d0d270cebe19e755a8a921cc2ddbfa647fb0cd7d68f9", + "sha256:ad11e540da4f55cedda52931f1a3f812a8238a7af7f62a60de538cd80bb28124" + ], + "index": "pypi", + "markers": "python_version >= '3.6'", + "version": "==3.5.0" + }, + "propcache": { + "hashes": [ + "sha256:00181262b17e517df2cd85656fcd6b4e70946fe62cd625b9d74ac9977b64d8d9", + "sha256:0e53cb83fdd61cbd67202735e6a6687a7b491c8742dfc39c9e01e80354956763", + "sha256:1235c01ddaa80da8235741e80815ce381c5267f96cc49b1477fdcf8c047ef325", + "sha256:140fbf08ab3588b3468932974a9331aff43c0ab8a2ec2c608b6d7d1756dbb6cb", + "sha256:191db28dc6dcd29d1a3e063c3be0b40688ed76434622c53a284e5427565bbd9b", + "sha256:1e41d67757ff4fbc8ef2af99b338bfb955010444b92929e9e55a6d4dcc3c4f09", + "sha256:1ec43d76b9677637a89d6ab86e1fef70d739217fefa208c65352ecf0282be957", + "sha256:20a617c776f520c3875cf4511e0d1db847a076d720714ae35ffe0df3e440be68", + "sha256:218db2a3c297a3768c11a34812e63b3ac1c3234c3a086def9c0fee50d35add1f", + "sha256:22aa8f2272d81d9317ff5756bb108021a056805ce63dd3630e27d042c8092798", + "sha256:25a1f88b471b3bc911d18b935ecb7115dff3a192b6fef46f0bfaf71ff4f12418", + "sha256:25c8d773a62ce0451b020c7b29a35cfbc05de8b291163a7a0f3b7904f27253e6", + "sha256:2a60ad3e2553a74168d275a0ef35e8c0a965448ffbc3b300ab3a5bb9956c2162", + "sha256:2a66df3d4992bc1d725b9aa803e8c5a66c010c65c741ad901e260ece77f58d2f", + "sha256:2ccc28197af5313706511fab3a8b66dcd6da067a1331372c82ea1cb74285e036", + "sha256:2e900bad2a8456d00a113cad8c13343f3b1f327534e3589acc2219729237a2e8", + "sha256:2ee7606193fb267be4b2e3b32714f2d58cad27217638db98a60f9efb5efeccc2", + "sha256:33ac8f098df0585c0b53009f039dfd913b38c1d2edafed0cedcc0c32a05aa110", + "sha256:3444cdba6628accf384e349014084b1cacd866fbb88433cd9d279d90a54e0b23", + "sha256:363ea8cd3c5cb6679f1c2f5f1f9669587361c062e4899fce56758efa928728f8", + "sha256:375a12d7556d462dc64d70475a9ee5982465fbb3d2b364f16b86ba9135793638", + "sha256:388f3217649d6d59292b722d940d4d2e1e6a7003259eb835724092a1cca0203a", + "sha256:3947483a381259c06921612550867b37d22e1df6d6d7e8361264b6d037595f44", + "sha256:39e104da444a34830751715f45ef9fc537475ba21b7f1f5b0f4d71a3b60d7fe2", + "sha256:3c997f8c44ec9b9b0bcbf2d422cc00a1d9b9c681f56efa6ca149a941e5560da2", + "sha256:3dfafb44f7bb35c0c06eda6b2ab4bfd58f02729e7c4045e179f9a861b07c9850", + "sha256:3ebbcf2a07621f29638799828b8d8668c421bfb94c6cb04269130d8de4fb7136", + "sha256:3f88a4095e913f98988f5b338c1d4d5d07dbb0b6bad19892fd447484e483ba6b", + "sha256:439e76255daa0f8151d3cb325f6dd4a3e93043e6403e6491813bcaaaa8733887", + "sha256:4569158070180c3855e9c0791c56be3ceeb192defa2cdf6a3f39e54319e56b89", + "sha256:466c219deee4536fbc83c08d09115249db301550625c7fef1c5563a584c9bc87", + "sha256:4a9d9b4d0a9b38d1c391bb4ad24aa65f306c6f01b512e10a8a34a2dc5675d348", + "sha256:4c7dde9e533c0a49d802b4f3f218fa9ad0a1ce21f2c2eb80d5216565202acab4", + "sha256:53d1bd3f979ed529f0805dd35ddaca330f80a9a6d90bc0121d2ff398f8ed8861", + "sha256:55346705687dbd7ef0d77883ab4f6fabc48232f587925bdaf95219bae072491e", + "sha256:56295eb1e5f3aecd516d91b00cfd8bf3a13991de5a479df9e27dd569ea23959c", + "sha256:56bb5c98f058a41bb58eead194b4db8c05b088c93d94d5161728515bd52b052b", + "sha256:5a5b3bb545ead161be780ee85a2b54fdf7092815995661947812dde94a40f6fb", + "sha256:5f2564ec89058ee7c7989a7b719115bdfe2a2fb8e7a4543b8d1c0cc4cf6478c1", + "sha256:608cce1da6f2672a56b24a015b42db4ac612ee709f3d29f27a00c943d9e851de", + "sha256:63f13bf09cc3336eb04a837490b8f332e0db41da66995c9fd1ba04552e516354", + "sha256:662dd62358bdeaca0aee5761de8727cfd6861432e3bb828dc2a693aa0471a563", + "sha256:676135dcf3262c9c5081cc8f19ad55c8a64e3f7282a21266d05544450bffc3a5", + "sha256:67aeb72e0f482709991aa91345a831d0b707d16b0257e8ef88a2ad246a7280bf", + "sha256:67b69535c870670c9f9b14a75d28baa32221d06f6b6fa6f77a0a13c5a7b0a5b9", + "sha256:682a7c79a2fbf40f5dbb1eb6bfe2cd865376deeac65acf9beb607505dced9e12", + "sha256:6994984550eaf25dd7fc7bd1b700ff45c894149341725bb4edc67f0ffa94efa4", + "sha256:69d3a98eebae99a420d4b28756c8ce6ea5a29291baf2dc9ff9414b42676f61d5", + "sha256:6e2e54267980349b723cff366d1e29b138b9a60fa376664a157a342689553f71", + "sha256:73e4b40ea0eda421b115248d7e79b59214411109a5bc47d0d48e4c73e3b8fcf9", + "sha256:74acd6e291f885678631b7ebc85d2d4aec458dd849b8c841b57ef04047833bed", + "sha256:7665f04d0c7f26ff8bb534e1c65068409bf4687aa2534faf7104d7182debb336", + "sha256:7735e82e3498c27bcb2d17cb65d62c14f1100b71723b68362872bca7d0913d90", + "sha256:77a86c261679ea5f3896ec060be9dc8e365788248cc1e049632a1be682442063", + "sha256:7cf18abf9764746b9c8704774d8b06714bcb0a63641518a3a89c7f85cc02c2ad", + "sha256:83928404adf8fb3d26793665633ea79b7361efa0287dfbd372a7e74311d51ee6", + "sha256:8e40876731f99b6f3c897b66b803c9e1c07a989b366c6b5b475fafd1f7ba3fb8", + "sha256:8f188cfcc64fb1266f4684206c9de0e80f54622c3f22a910cbd200478aeae61e", + "sha256:91997d9cb4a325b60d4e3f20967f8eb08dfcb32b22554d5ef78e6fd1dda743a2", + "sha256:91ee8fc02ca52e24bcb77b234f22afc03288e1dafbb1f88fe24db308910c4ac7", + "sha256:92fe151145a990c22cbccf9ae15cae8ae9eddabfc949a219c9f667877e40853d", + "sha256:945db8ee295d3af9dbdbb698cce9bbc5c59b5c3fe328bbc4387f59a8a35f998d", + "sha256:9517d5e9e0731957468c29dbfd0f976736a0e55afaea843726e887f36fe017df", + "sha256:952e0d9d07609d9c5be361f33b0d6d650cd2bae393aabb11d9b719364521984b", + "sha256:97a58a28bcf63284e8b4d7b460cbee1edaab24634e82059c7b8c09e65284f178", + "sha256:97e48e8875e6c13909c800fa344cd54cc4b2b0db1d5f911f840458a500fde2c2", + "sha256:9e0f07b42d2a50c7dd2d8675d50f7343d998c64008f1da5fef888396b7f84630", + "sha256:a3dc1a4b165283bd865e8f8cb5f0c64c05001e0718ed06250d8cac9bec115b48", + "sha256:a3ebe9a75be7ab0b7da2464a77bb27febcb4fab46a34f9288f39d74833db7f61", + "sha256:a64e32f8bd94c105cc27f42d3b658902b5bcc947ece3c8fe7bc1b05982f60e89", + "sha256:a6ed8db0a556343d566a5c124ee483ae113acc9a557a807d439bcecc44e7dfbb", + "sha256:ad9c9b99b05f163109466638bd30ada1722abb01bbb85c739c50b6dc11f92dc3", + "sha256:b33d7a286c0dc1a15f5fc864cc48ae92a846df287ceac2dd499926c3801054a6", + "sha256:bc092ba439d91df90aea38168e11f75c655880c12782facf5cf9c00f3d42b562", + "sha256:c436130cc779806bdf5d5fae0d848713105472b8566b75ff70048c47d3961c5b", + "sha256:c5869b8fd70b81835a6f187c5fdbe67917a04d7e52b6e7cc4e5fe39d55c39d58", + "sha256:c5ecca8f9bab618340c8e848d340baf68bcd8ad90a8ecd7a4524a81c1764b3db", + "sha256:cfac69017ef97db2438efb854edf24f5a29fd09a536ff3a992b75990720cdc99", + "sha256:d2f0d0f976985f85dfb5f3d685697ef769faa6b71993b46b295cdbbd6be8cc37", + "sha256:d5bed7f9805cc29c780f3aee05de3262ee7ce1f47083cfe9f77471e9d6777e83", + "sha256:d6a21ef516d36909931a2967621eecb256018aeb11fc48656e3257e73e2e247a", + "sha256:d9b6ddac6408194e934002a69bcaadbc88c10b5f38fb9307779d1c629181815d", + "sha256:db47514ffdbd91ccdc7e6f8407aac4ee94cc871b15b577c1c324236b013ddd04", + "sha256:df81779732feb9d01e5d513fad0122efb3d53bbc75f61b2a4f29a020bc985e70", + "sha256:e4a91d44379f45f5e540971d41e4626dacd7f01004826a18cb048e7da7e96544", + "sha256:e63e3e1e0271f374ed489ff5ee73d4b6e7c60710e1f76af5f0e1a6117cd26394", + "sha256:e70fac33e8b4ac63dfc4c956fd7d85a0b1139adcfc0d964ce288b7c527537fea", + "sha256:ecddc221a077a8132cf7c747d5352a15ed763b674c0448d811f408bf803d9ad7", + "sha256:f45eec587dafd4b2d41ac189c2156461ebd0c1082d2fe7013571598abb8505d1", + "sha256:f52a68c21363c45297aca15561812d542f8fc683c85201df0bebe209e349f793", + "sha256:f571aea50ba5623c308aa146eb650eebf7dbe0fd8c5d946e28343cb3b5aad577", + "sha256:f60f0ac7005b9f5a6091009b09a419ace1610e163fa5deaba5ce3484341840e7", + "sha256:f6475a1b2ecb310c98c28d271a30df74f9dd436ee46d09236a6b750a7599ce57", + "sha256:f6d5749fdd33d90e34c2efb174c7e236829147a2713334d708746e94c4bde40d", + "sha256:f902804113e032e2cdf8c71015651c97af6418363bea8d78dc0911d56c335032", + "sha256:fa1076244f54bb76e65e22cb6910365779d5c3d71d1f18b275f1dfc7b0d71b4d", + "sha256:fc2db02409338bf36590aa985a461b2c96fce91f8e7e0f14c50c5fcc4f229016", + "sha256:ffcad6c564fe6b9b8916c1aefbb37a362deebf9394bd2974e9d84232e3e08504" + ], + "markers": "python_version >= '3.8'", + "version": "==0.2.0" }, "proto-plus": { "hashes": [ @@ -710,20 +1004,20 @@ }, "protobuf": { "hashes": [ - "sha256:2c69461a7fcc8e24be697624c09a839976d82ae75062b11a0972e41fd2cd9132", - "sha256:35cfcb15f213449af7ff6198d6eb5f739c37d7e4f1c09b5d0641babf2cc0c68f", - "sha256:52235802093bd8a2811abbe8bf0ab9c5f54cca0a751fdd3f6ac2a21438bffece", - "sha256:59379674ff119717404f7454647913787034f03fe7049cbef1d74a97bb4593f0", - "sha256:5e8a95246d581eef20471b5d5ba010d55f66740942b95ba9b872d918c459452f", - "sha256:87317e9bcda04a32f2ee82089a204d3a2f0d3c8aeed16568c7daf4756e4f1fe0", - "sha256:8ddc60bf374785fb7cb12510b267f59067fa10087325b8e1855b898a0d81d276", - "sha256:a8b9403fc70764b08d2f593ce44f1d2920c5077bf7d311fefec999f8c40f78b7", - "sha256:c0ea0123dac3399a2eeb1a1443d82b7afc9ff40241433296769f7da42d142ec3", - "sha256:ca53faf29896c526863366a52a8f4d88e69cd04ec9571ed6082fa117fac3ab36", - "sha256:eeea10f3dc0ac7e6b4933d32db20662902b4ab81bf28df12218aa389e9c2102d" + "sha256:0c4eec6f987338617072592b97943fdbe30d019c56126493111cf24344c1cc24", + "sha256:135658402f71bbd49500322c0f736145731b16fc79dc8f367ab544a17eab4535", + "sha256:27b246b3723692bf1068d5734ddaf2fccc2cdd6e0c9b47fe099244d80200593b", + "sha256:3e6101d095dfd119513cde7259aa703d16c6bbdfae2554dfe5cfdbe94e32d548", + "sha256:3fa2de6b8b29d12c61911505d893afe7320ce7ccba4df913e2971461fa36d584", + "sha256:64badbc49180a5e401f373f9ce7ab1d18b63f7dd4a9cdc43c92b9f0b481cef7b", + "sha256:70585a70fc2dd4818c51287ceef5bdba6387f88a578c86d47bb34669b5552c36", + "sha256:712319fbdddb46f21abb66cd33cb9e491a5763b2febd8f228251add221981135", + "sha256:91fba8f445723fcf400fdbe9ca796b19d3b1242cd873907979b9ed71e4afe868", + "sha256:a3f6857551e53ce35e60b403b8a27b0295f7d6eb63d10484f12bc6879c715687", + "sha256:cee1757663fa32a1ee673434fcf3bf24dd54763c79690201208bafec62f19eed" ], "markers": "python_version >= '3.8'", - "version": "==5.28.2" + "version": "==5.28.3" }, "pyasn1": { "hashes": [ @@ -741,6 +1035,14 @@ "markers": "python_version >= '3.8'", "version": "==0.4.1" }, + "pycparser": { + "hashes": [ + "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", + "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc" + ], + "markers": "python_version >= '3.8'", + "version": "==2.22" + }, "pydantic": { "hashes": [ "sha256:069b9c9fc645474d5ea3653788b544a9e0ccd3dca3ad8c900c4c6eac844b4620", @@ -790,13 +1092,29 @@ "markers": "python_version >= '3.7'", "version": "==1.10.18" }, + "pynacl": { + "hashes": [ + "sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858", + "sha256:0c84947a22519e013607c9be43706dd42513f9e6ae5d39d3613ca1e142fba44d", + "sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93", + "sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1", + "sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92", + "sha256:61f642bf2378713e2c2e1de73444a3778e5f0a38be6fee0fe532fe30060282ff", + "sha256:8ac7448f09ab85811607bdd21ec2464495ac8b7c66d146bf545b0f08fb9220ba", + "sha256:a36d4a9dda1f19ce6e03c9a784a2921a4b726b02e1c736600ca9c22029474394", + "sha256:a422368fc821589c228f4c49438a368831cb5bbc0eab5ebe1d7fac9dded6567b", + "sha256:e46dae94e34b085175f8abb3b0aaa7da40767865ac82c928eeb9e57e1ea8a543" + ], + "markers": "python_version >= '3.6'", + "version": "==1.5.0" + }, "pyparsing": { "hashes": [ - "sha256:a6a7ee4235a3f944aa1fa2249307708f893fe5717dc603503c6c7969c070fb7c", - "sha256:f86ec8d1a83f11977c9a6ea7598e8c27fc5cddfa5b07ea2241edbbde1d7bc032" + "sha256:93d9577b88da0bbea8cc8334ee8b918ed014968fd2ec383e868fb8afb1ccef84", + "sha256:cbf74e27246d595d9a74b186b810f6fbb86726dbf3b9532efb343f6d7294fe9c" ], "markers": "python_version >= '3.1'", - "version": "==3.1.4" + "version": "==3.2.0" }, "pyrsistent": { "hashes": [ @@ -876,23 +1194,27 @@ }, "pywin32": { "hashes": [ - "sha256:06d3420a5155ba65f0b72f2699b5bacf3109f36acbe8923765c22938a69dfc8d", - "sha256:1c73ea9a0d2283d889001998059f5eaaba3b6238f767c9cf2833b13e6a685f65", - "sha256:37257794c1ad39ee9be652da0462dc2e394c8159dfd913a8a4e8eb6fd346da0e", - "sha256:383229d515657f4e3ed1343da8be101000562bf514591ff383ae940cad65458b", - "sha256:39b61c15272833b5c329a2989999dcae836b1eed650252ab1b7bfbe1d59f30f4", - "sha256:5821ec52f6d321aa59e2db7e0a35b997de60c201943557d108af9d4ae1ec7040", - "sha256:70dba0c913d19f942a2db25217d9a1b726c278f483a919f1abfed79c9cf64d3a", - "sha256:72c5f621542d7bdd4fdb716227be0dd3f8565c11b280be6315b06ace35487d36", - "sha256:84f4471dbca1887ea3803d8848a1616429ac94a4a8d05f4bc9c5dcfd42ca99c8", - "sha256:a7639f51c184c0272e93f244eb24dafca9b1855707d94c192d4a0b4c01e1100e", - "sha256:e25fd5b485b55ac9c057f67d94bc203f3f6595078d1fb3b458c9c28b7153a802", - "sha256:e4c092e2589b5cf0d365849e73e02c391c1349958c5ac3e9d5ccb9a28e017b3a", - "sha256:e65028133d15b64d2ed8f06dd9fbc268352478d4f9289e69c190ecd6818b6407", - "sha256:e8ac1ae3601bee6ca9f7cb4b5363bf1c0badb935ef243c4733ff9a393b1690c0" + "sha256:00b3e11ef09ede56c6a43c71f2d31857cf7c54b0ab6e78ac659497abd2834f47", + "sha256:100a5442b7332070983c4cd03f2e906a5648a5104b8a7f50175f7906efd16bb6", + "sha256:13dcb914ed4347019fbec6697a01a0aec61019c1046c2b905410d197856326a6", + "sha256:1c44539a37a5b7b21d02ab34e6a4d314e0788f1690d65b48e9b0b89f31abbbed", + "sha256:1f696ab352a2ddd63bd07430080dd598e6369152ea13a25ebcdd2f503a38f1ff", + "sha256:3b92622e29d651c6b783e368ba7d6722b1634b8e70bd376fd7610fe1992e19de", + "sha256:4fc888c59b3c0bef905ce7eb7e2106a07712015ea1c8234b703a088d46110e8e", + "sha256:575621b90f0dc2695fec346b2d6302faebd4f0f45c05ea29404cefe35d89442b", + "sha256:5794e764ebcabf4ff08c555b31bd348c9025929371763b2183172ff4708152f0", + "sha256:587f3e19696f4bf96fde9d8a57cec74a57021ad5f204c9e627e15c33ff568897", + "sha256:5d8c8015b24a7d6855b1550d8e660d8daa09983c80e5daf89a273e5c6fb5095a", + "sha256:71b3322d949b4cc20776436a9c9ba0eeedcbc9c650daa536df63f0ff111bb920", + "sha256:7873ca4dc60ab3287919881a7d4f88baee4a6e639aa6962de25a98ba6b193341", + "sha256:796ff4426437896550d2981b9c2ac0ffd75238ad9ea2d3bfa67a1abd546d262e", + "sha256:9b4de86c8d909aed15b7011182c8cab38c8850de36e6afb1f0db22b8959e3091", + "sha256:a5ab5381813b40f264fa3495b98af850098f814a25a63589a8e9eb12560f450c", + "sha256:ef313c46d4c18dfb82a2431e3051ac8f112ccee1a34f29c263c583c568db63cd", + "sha256:fd380990e792eaf6827fcb7e187b2b4b1cede0585e3d0c9e84201ec27b9905e4" ], "markers": "platform_system == 'Windows' and platform_python_implementation == 'CPython'", - "version": "==306" + "version": "==308" }, "requests": { "hashes": [ @@ -920,11 +1242,11 @@ }, "setuptools": { "hashes": [ - "sha256:35ab7fd3bcd95e6b7fd704e4a1539513edad446c097797f2985e0e4b960772f2", - "sha256:d59a21b17a275fb872a9c3dae73963160ae079f1049ed956880cd7c09b120538" + "sha256:753bb6ebf1f465a1912e19ed1d41f403a79173a9acf66a42e7e6aec45c3c16ec", + "sha256:a7fcb66f68b4d9e8e66b42f9876150a3371558f98fa32222ffaa5bced76406f8" ], "markers": "python_version >= '3.8'", - "version": "==75.1.0" + "version": "==75.2.0" }, "six": { "hashes": [ @@ -936,12 +1258,12 @@ }, "slack-sdk": { "hashes": [ - "sha256:070eb1fb355c149a5f80fa0be6eeb5f5588e4ddff4dd76acf060454435cb037e", - "sha256:853bb55154115d080cae342c4099f2ccb559a78ae8d0f5109b49842401a920fa" + "sha256:e328bb661d95db5f66b993b1d64288ac7c72201a745b4c7cf8848dafb7b74e40", + "sha256:ef93beec3ce9c8f64da02fd487598a05ec4bc9c92ceed58f122dbe632691cbe2" ], "index": "pypi", "markers": "python_version >= '3.6'", - "version": "==3.33.0" + "version": "==3.33.1" }, "slackclient": { "hashes": [ @@ -978,11 +1300,11 @@ }, "types-pytz": { "hashes": [ - "sha256:4433b5df4a6fc587bbed41716d86a5ba5d832b4378e506f40d34bc9c81df2c24", - "sha256:a1eebf57ebc6e127a99d2fa2ba0a88d2b173784ef9b3defcc2004ab6855a44df" + "sha256:3e22df1336c0c6ad1d29163c8fda82736909eb977281cb823c57f8bae07118b7", + "sha256:575dc38f385a922a212bac00a7d6d2e16e141132a3c955078f4a4fd13ed6cb44" ], "markers": "python_version >= '3.8'", - "version": "==2024.2.0.20240913" + "version": "==2024.2.0.20241003" }, "typing-extensions": { "hashes": [ @@ -994,11 +1316,11 @@ }, "tzdata": { "hashes": [ - "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd", - "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252" + "sha256:7d85cc416e9382e69095b7bdf4afd9e3880418a2413feec7069d533d6b4e31cc", + "sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd" ], "markers": "python_version >= '2'", - "version": "==2024.1" + "version": "==2024.2" }, "uritemplate": { "hashes": [ @@ -1094,101 +1416,91 @@ }, "yarl": { "hashes": [ - "sha256:01a8697ec24f17c349c4f655763c4db70eebc56a5f82995e5e26e837c6eb0e49", - "sha256:02da8759b47d964f9173c8675710720b468aa1c1693be0c9c64abb9d8d9a4867", - "sha256:04293941646647b3bfb1719d1d11ff1028e9c30199509a844da3c0f5919dc520", - "sha256:067b961853c8e62725ff2893226fef3d0da060656a9827f3f520fb1d19b2b68a", - "sha256:077da604852be488c9a05a524068cdae1e972b7dc02438161c32420fb4ec5e14", - "sha256:09696438cb43ea6f9492ef237761b043f9179f455f405279e609f2bc9100212a", - "sha256:0b8486f322d8f6a38539136a22c55f94d269addb24db5cb6f61adc61eabc9d93", - "sha256:0ea9682124fc062e3d931c6911934a678cb28453f957ddccf51f568c2f2b5e05", - "sha256:0f351fa31234699d6084ff98283cb1e852270fe9e250a3b3bf7804eb493bd937", - "sha256:14438dfc5015661f75f85bc5adad0743678eefee266ff0c9a8e32969d5d69f74", - "sha256:15061ce6584ece023457fb8b7a7a69ec40bf7114d781a8c4f5dcd68e28b5c53b", - "sha256:15439f3c5c72686b6c3ff235279630d08936ace67d0fe5c8d5bbc3ef06f5a420", - "sha256:17b5a386d0d36fb828e2fb3ef08c8829c1ebf977eef88e5367d1c8c94b454639", - "sha256:18ac56c9dd70941ecad42b5a906820824ca72ff84ad6fa18db33c2537ae2e089", - "sha256:1bb2d9e212fb7449b8fb73bc461b51eaa17cc8430b4a87d87be7b25052d92f53", - "sha256:1e969fa4c1e0b1a391f3fcbcb9ec31e84440253325b534519be0d28f4b6b533e", - "sha256:1fa2e7a406fbd45b61b4433e3aa254a2c3e14c4b3186f6e952d08a730807fa0c", - "sha256:2164cd9725092761fed26f299e3f276bb4b537ca58e6ff6b252eae9631b5c96e", - "sha256:21a7c12321436b066c11ec19c7e3cb9aec18884fe0d5b25d03d756a9e654edfe", - "sha256:238a21849dd7554cb4d25a14ffbfa0ef380bb7ba201f45b144a14454a72ffa5a", - "sha256:250e888fa62d73e721f3041e3a9abf427788a1934b426b45e1b92f62c1f68366", - "sha256:25861303e0be76b60fddc1250ec5986c42f0a5c0c50ff57cc30b1be199c00e63", - "sha256:267b24f891e74eccbdff42241c5fb4f974de2d6271dcc7d7e0c9ae1079a560d9", - "sha256:27fcb271a41b746bd0e2a92182df507e1c204759f460ff784ca614e12dd85145", - "sha256:2909fa3a7d249ef64eeb2faa04b7957e34fefb6ec9966506312349ed8a7e77bf", - "sha256:3257978c870728a52dcce8c2902bf01f6c53b65094b457bf87b2644ee6238ddc", - "sha256:327c724b01b8641a1bf1ab3b232fb638706e50f76c0b5bf16051ab65c868fac5", - "sha256:3de5292f9f0ee285e6bd168b2a77b2a00d74cbcfa420ed078456d3023d2f6dff", - "sha256:3fce4da3703ee6048ad4138fe74619c50874afe98b1ad87b2698ef95bf92c96d", - "sha256:3ff6b1617aa39279fe18a76c8d165469c48b159931d9b48239065767ee455b2b", - "sha256:400cd42185f92de559d29eeb529e71d80dfbd2f45c36844914a4a34297ca6f00", - "sha256:4179522dc0305c3fc9782549175c8e8849252fefeb077c92a73889ccbcd508ad", - "sha256:4307d9a3417eea87715c9736d050c83e8c1904e9b7aada6ce61b46361b733d92", - "sha256:476e20c433b356e16e9a141449f25161e6b69984fb4cdbd7cd4bd54c17844998", - "sha256:489fa8bde4f1244ad6c5f6d11bb33e09cf0d1d0367edb197619c3e3fc06f3d91", - "sha256:48a28bed68ab8fb7e380775f0029a079f08a17799cb3387a65d14ace16c12e2b", - "sha256:48dfd117ab93f0129084577a07287376cc69c08138694396f305636e229caa1a", - "sha256:4973eac1e2ff63cf187073cd4e1f1148dcd119314ab79b88e1b3fad74a18c9d5", - "sha256:498442e3af2a860a663baa14fbf23fb04b0dd758039c0e7c8f91cb9279799bff", - "sha256:501c503eed2bb306638ccb60c174f856cc3246c861829ff40eaa80e2f0330367", - "sha256:504cf0d4c5e4579a51261d6091267f9fd997ef58558c4ffa7a3e1460bd2336fa", - "sha256:61a5f2c14d0a1adfdd82258f756b23a550c13ba4c86c84106be4c111a3a4e413", - "sha256:637c7ddb585a62d4469f843dac221f23eec3cbad31693b23abbc2c366ad41ff4", - "sha256:66b63c504d2ca43bf7221a1f72fbe981ff56ecb39004c70a94485d13e37ebf45", - "sha256:67459cf8cf31da0e2cbdb4b040507e535d25cfbb1604ca76396a3a66b8ba37a6", - "sha256:688654f8507464745ab563b041d1fb7dab5d9912ca6b06e61d1c4708366832f5", - "sha256:6907daa4b9d7a688063ed098c472f96e8181733c525e03e866fb5db480a424df", - "sha256:69721b8effdb588cb055cc22f7c5105ca6fdaa5aeb3ea09021d517882c4a904c", - "sha256:6d23754b9939cbab02c63434776df1170e43b09c6a517585c7ce2b3d449b7318", - "sha256:7175a87ab8f7fbde37160a15e58e138ba3b2b0e05492d7351314a250d61b1591", - "sha256:72bf26f66456baa0584eff63e44545c9f0eaed9b73cb6601b647c91f14c11f38", - "sha256:74db2ef03b442276d25951749a803ddb6e270d02dda1d1c556f6ae595a0d76a8", - "sha256:750f656832d7d3cb0c76be137ee79405cc17e792f31e0a01eee390e383b2936e", - "sha256:75e0ae31fb5ccab6eda09ba1494e87eb226dcbd2372dae96b87800e1dcc98804", - "sha256:768ecc550096b028754ea28bf90fde071c379c62c43afa574edc6f33ee5daaec", - "sha256:7d51324a04fc4b0e097ff8a153e9276c2593106a811704025bbc1d6916f45ca6", - "sha256:7e975a2211952a8a083d1b9d9ba26472981ae338e720b419eb50535de3c02870", - "sha256:8215f6f21394d1f46e222abeb06316e77ef328d628f593502d8fc2a9117bde83", - "sha256:8258c86f47e080a258993eed877d579c71da7bda26af86ce6c2d2d072c11320d", - "sha256:8418c053aeb236b20b0ab8fa6bacfc2feaaf7d4683dd96528610989c99723d5f", - "sha256:87f020d010ba80a247c4abc335fc13421037800ca20b42af5ae40e5fd75e7909", - "sha256:884eab2ce97cbaf89f264372eae58388862c33c4f551c15680dd80f53c89a269", - "sha256:8a336eaa7ee7e87cdece3cedb395c9657d227bfceb6781295cf56abcd3386a26", - "sha256:8aef1b64da41d18026632d99a06b3fefe1d08e85dd81d849fa7c96301ed22f1b", - "sha256:8aef97ba1dd2138112890ef848e17d8526fe80b21f743b4ee65947ea184f07a2", - "sha256:8ed653638ef669e0efc6fe2acb792275cb419bf9cb5c5049399f3556995f23c7", - "sha256:9361628f28f48dcf8b2f528420d4d68102f593f9c2e592bfc842f5fb337e44fd", - "sha256:946eedc12895873891aaceb39bceb484b4977f70373e0122da483f6c38faaa68", - "sha256:94d0caaa912bfcdc702a4204cd5e2bb01eb917fc4f5ea2315aa23962549561b0", - "sha256:964a428132227edff96d6f3cf261573cb0f1a60c9a764ce28cda9525f18f7786", - "sha256:999bfee0a5b7385a0af5ffb606393509cfde70ecca4f01c36985be6d33e336da", - "sha256:a08ea567c16f140af8ddc7cb58e27e9138a1386e3e6e53982abaa6f2377b38cc", - "sha256:a28b70c9e2213de425d9cba5ab2e7f7a1c8ca23a99c4b5159bf77b9c31251447", - "sha256:a34e1e30f1774fa35d37202bbeae62423e9a79d78d0874e5556a593479fdf239", - "sha256:a4264515f9117be204935cd230fb2a052dd3792789cc94c101c535d349b3dab0", - "sha256:a7915ea49b0c113641dc4d9338efa9bd66b6a9a485ffe75b9907e8573ca94b84", - "sha256:aac44097d838dda26526cffb63bdd8737a2dbdf5f2c68efb72ad83aec6673c7e", - "sha256:b91044952da03b6f95fdba398d7993dd983b64d3c31c358a4c89e3c19b6f7aef", - "sha256:ba444bdd4caa2a94456ef67a2f383710928820dd0117aae6650a4d17029fa25e", - "sha256:c2dc4250fe94d8cd864d66018f8344d4af50e3758e9d725e94fecfa27588ff82", - "sha256:c35f493b867912f6fda721a59cc7c4766d382040bdf1ddaeeaa7fa4d072f4675", - "sha256:c92261eb2ad367629dc437536463dc934030c9e7caca861cc51990fe6c565f26", - "sha256:ce928c9c6409c79e10f39604a7e214b3cb69552952fbda8d836c052832e6a979", - "sha256:d95b52fbef190ca87d8c42f49e314eace4fc52070f3dfa5f87a6594b0c1c6e46", - "sha256:dae7bd0daeb33aa3e79e72877d3d51052e8b19c9025ecf0374f542ea8ec120e4", - "sha256:e286580b6511aac7c3268a78cdb861ec739d3e5a2a53b4809faef6b49778eaff", - "sha256:e4b53f73077e839b3f89c992223f15b1d2ab314bdbdf502afdc7bb18e95eae27", - "sha256:e8f63904df26d1a66aabc141bfd258bf738b9bc7bc6bdef22713b4f5ef789a4c", - "sha256:f3a6d90cab0bdf07df8f176eae3a07127daafcf7457b997b2bf46776da2c7eb7", - "sha256:f41fa79114a1d2eddb5eea7b912d6160508f57440bd302ce96eaa384914cd265", - "sha256:f46f81501160c28d0c0b7333b4f7be8983dbbc161983b6fb814024d1b4952f79", - "sha256:f61db3b7e870914dbd9434b560075e0366771eecbe6d2b5561f5bc7485f39efd" + "sha256:019f5d58093402aa8f6661e60fd82a28746ad6d156f6c5336a70a39bd7b162b9", + "sha256:0fd9c227990f609c165f56b46107d0bc34553fe0387818c42c02f77974402c36", + "sha256:1208ca14eed2fda324042adf8d6c0adf4a31522fa95e0929027cd487875f0240", + "sha256:122d8e7986043d0549e9eb23c7fd23be078be4b70c9eb42a20052b3d3149c6f2", + "sha256:147b0fcd0ee33b4b5f6edfea80452d80e419e51b9a3f7a96ce98eaee145c1581", + "sha256:178ccb856e265174a79f59721031060f885aca428983e75c06f78aa24b91d929", + "sha256:1a5cf32539373ff39d97723e39a9283a7277cbf1224f7aef0c56c9598b6486c3", + "sha256:1a5e9d8ce1185723419c487758d81ac2bde693711947032cce600ca7c9cda7d6", + "sha256:1bc22e00edeb068f71967ab99081e9406cd56dbed864fc3a8259442999d71552", + "sha256:1cf936ba67bc6c734f3aa1c01391da74ab7fc046a9f8bbfa230b8393b90cf472", + "sha256:234f3a3032b505b90e65b5bc6652c2329ea7ea8855d8de61e1642b74b4ee65d2", + "sha256:26768342f256e6e3c37533bf9433f5f15f3e59e3c14b2409098291b3efaceacb", + "sha256:27e11db3f1e6a51081a981509f75617b09810529de508a181319193d320bc5c7", + "sha256:2bd6a51010c7284d191b79d3b56e51a87d8e1c03b0902362945f15c3d50ed46b", + "sha256:2f1fe2b2e3ee418862f5ebc0c0083c97f6f6625781382f828f6d4e9b614eba9b", + "sha256:32468f41242d72b87ab793a86d92f885355bcf35b3355aa650bfa846a5c60058", + "sha256:35b4f7842154176523e0a63c9b871168c69b98065d05a4f637fce342a6a2693a", + "sha256:38fec8a2a94c58bd47c9a50a45d321ab2285ad133adefbbadf3012c054b7e656", + "sha256:3a91654adb7643cb21b46f04244c5a315a440dcad63213033826549fa2435f71", + "sha256:3ab3ed42c78275477ea8e917491365e9a9b69bb615cb46169020bd0aa5e2d6d3", + "sha256:3d375a19ba2bfe320b6d873f3fb165313b002cef8b7cc0a368ad8b8a57453837", + "sha256:4199db024b58a8abb2cfcedac7b1292c3ad421684571aeb622a02f242280e8d6", + "sha256:4f32c4cb7386b41936894685f6e093c8dfaf0960124d91fe0ec29fe439e201d0", + "sha256:4ffb7c129707dd76ced0a4a4128ff452cecf0b0e929f2668ea05a371d9e5c104", + "sha256:504e1fe1cc4f170195320eb033d2b0ccf5c6114ce5bf2f617535c01699479bca", + "sha256:542fa8e09a581bcdcbb30607c7224beff3fdfb598c798ccd28a8184ffc18b7eb", + "sha256:5570e6d47bcb03215baf4c9ad7bf7c013e56285d9d35013541f9ac2b372593e7", + "sha256:571f781ae8ac463ce30bacebfaef2c6581543776d5970b2372fbe31d7bf31a07", + "sha256:595ca5e943baed31d56b33b34736461a371c6ea0038d3baec399949dd628560b", + "sha256:5b8e265a0545637492a7e12fd7038370d66c9375a61d88c5567d0e044ded9202", + "sha256:5b9101f528ae0f8f65ac9d64dda2bb0627de8a50344b2f582779f32fda747c1d", + "sha256:5ff96da263740779b0893d02b718293cc03400c3a208fc8d8cd79d9b0993e532", + "sha256:621280719c4c5dad4c1391160a9b88925bb8b0ff6a7d5af3224643024871675f", + "sha256:62c7da0ad93a07da048b500514ca47b759459ec41924143e2ddb5d7e20fd3db5", + "sha256:649bddcedee692ee8a9b7b6e38582cb4062dc4253de9711568e5620d8707c2a3", + "sha256:66ea8311422a7ba1fc79b4c42c2baa10566469fe5a78500d4e7754d6e6db8724", + "sha256:676d96bafc8c2d0039cea0cd3fd44cee7aa88b8185551a2bb93354668e8315c2", + "sha256:707ae579ccb3262dfaef093e202b4c3fb23c3810e8df544b1111bd2401fd7b09", + "sha256:7118bdb5e3ed81acaa2095cba7ec02a0fe74b52a16ab9f9ac8e28e53ee299732", + "sha256:789a3423f28a5fff46fbd04e339863c169ece97c827b44de16e1a7a42bc915d2", + "sha256:7ace71c4b7a0c41f317ae24be62bb61e9d80838d38acb20e70697c625e71f120", + "sha256:7c7c30fb38c300fe8140df30a046a01769105e4cf4282567a29b5cdb635b66c4", + "sha256:7d7aaa8ff95d0840e289423e7dc35696c2b058d635f945bf05b5cd633146b027", + "sha256:7f8713717a09acbfee7c47bfc5777e685539fefdd34fa72faf504c8be2f3df4e", + "sha256:858728086914f3a407aa7979cab743bbda1fe2bdf39ffcd991469a370dd7414d", + "sha256:8791d66d81ee45866a7bb15a517b01a2bcf583a18ebf5d72a84e6064c417e64b", + "sha256:87dd10bc0618991c66cee0cc65fa74a45f4ecb13bceec3c62d78ad2e42b27a16", + "sha256:8994c42f4ca25df5380ddf59f315c518c81df6a68fed5bb0c159c6cb6b92f120", + "sha256:8a0296040e5cddf074c7f5af4a60f3fc42c0237440df7bcf5183be5f6c802ed5", + "sha256:8b37d5ec034e668b22cf0ce1074d6c21fd2a08b90d11b1b73139b750a8b0dd97", + "sha256:8c42998fd1cbeb53cd985bff0e4bc25fbe55fd6eb3a545a724c1012d69d5ec84", + "sha256:8f639e3f5795a6568aa4f7d2ac6057c757dcd187593679f035adbf12b892bb00", + "sha256:921b81b8d78f0e60242fb3db615ea3f368827a76af095d5a69f1c3366db3f596", + "sha256:995d0759004c08abd5d1b81300a91d18c8577c6389300bed1c7c11675105a44d", + "sha256:99a9dcd4b71dd5f5f949737ab3f356cfc058c709b4f49833aeffedc2652dac56", + "sha256:9a91217208306d82357c67daeef5162a41a28c8352dab7e16daa82e3718852a7", + "sha256:a5ace0177520bd4caa99295a9b6fb831d0e9a57d8e0501a22ffaa61b4c024283", + "sha256:a5b6c09b9b4253d6a208b0f4a2f9206e511ec68dce9198e0fbec4f160137aa67", + "sha256:a9394c65ae0ed95679717d391c862dece9afacd8fa311683fc8b4362ce8a410c", + "sha256:aa7943f04f36d6cafc0cf53ea89824ac2c37acbdb4b316a654176ab8ffd0f968", + "sha256:ab2b2ac232110a1fdb0d3ffcd087783edd3d4a6ced432a1bf75caf7b7be70916", + "sha256:ad7a852d1cd0b8d8b37fc9d7f8581152add917a98cfe2ea6e241878795f917ae", + "sha256:b140e532fe0266003c936d017c1ac301e72ee4a3fd51784574c05f53718a55d8", + "sha256:b439cae82034ade094526a8f692b9a2b5ee936452de5e4c5f0f6c48df23f8604", + "sha256:b6f687ced5510a9a2474bbae96a4352e5ace5fa34dc44a217b0537fec1db00b4", + "sha256:b9ca7b9147eb1365c8bab03c003baa1300599575effad765e0b07dd3501ea9af", + "sha256:bdcf667a5dec12a48f669e485d70c54189f0639c2157b538a4cffd24a853624f", + "sha256:cdcffe1dbcb4477d2b4202f63cd972d5baa155ff5a3d9e35801c46a415b7f71a", + "sha256:d1aab176dd55b59f77a63b27cffaca67d29987d91a5b615cbead41331e6b7428", + "sha256:d1b0796168b953bca6600c5f97f5ed407479889a36ad7d17183366260f29a6b9", + "sha256:d3f1cc3d3d4dc574bebc9b387f6875e228ace5748a7c24f49d8f01ac1bc6c31b", + "sha256:d743e3118b2640cef7768ea955378c3536482d95550222f908f392167fe62059", + "sha256:d8643975a0080f361639787415a038bfc32d29208a4bf6b783ab3075a20b1ef3", + "sha256:d9525f03269e64310416dbe6c68d3b23e5d34aaa8f47193a1c45ac568cecbc49", + "sha256:de6c14dd7c7c0badba48157474ea1f03ebee991530ba742d381b28d4f314d6f3", + "sha256:e49e0fd86c295e743fd5be69b8b0712f70a686bc79a16e5268386c2defacaade", + "sha256:e6980a558d8461230c457218bd6c92dfc1d10205548215c2c21d79dc8d0a96f3", + "sha256:e8be3aff14f0120ad049121322b107f8a759be76a6a62138322d4c8a337a9e2c", + "sha256:e9951afe6557c75a71045148890052cb942689ee4c9ec29f5436240e1fcc73b7", + "sha256:ed097b26f18a1f5ff05f661dc36528c5f6735ba4ce8c9645e83b064665131349", + "sha256:f1d1f45e3e8d37c804dca99ab3cf4ab3ed2e7a62cd82542924b14c0a4f46d243", + "sha256:fe8bba2545427418efc1929c5c42852bdb4143eb8d0a46b09de88d1fe99258e7" ], - "markers": "python_version >= '3.8'", - "version": "==1.11.1" + "markers": "python_version >= '3.9'", + "version": "==1.16.0" } }, "develop": { @@ -1255,99 +1567,114 @@ }, "charset-normalizer": { "hashes": [ - "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027", - "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087", - "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786", - "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8", - "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09", - "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185", - "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574", - "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e", - "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519", - "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898", - "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269", - "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3", - "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f", - "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6", - "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8", - "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a", - "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73", - "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc", - "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714", - "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2", - "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc", - "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce", - "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d", - "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e", - "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6", - "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269", - "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96", - "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d", - "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a", - "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4", - "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77", - "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d", - "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0", - "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed", - "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068", - "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac", - "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25", - "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8", - "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab", - "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26", - "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2", - "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db", - "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f", - "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5", - "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99", - "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c", - "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d", - "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811", - "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa", - "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a", - "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03", - "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b", - "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04", - "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c", - "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001", - "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458", - "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389", - "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99", - "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985", - "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537", - "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238", - "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f", - "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d", - "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796", - "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a", - "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143", - "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8", - "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c", - "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5", - "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5", - "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711", - "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4", - "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6", - "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c", - "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7", - "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4", - "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b", - "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae", - "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12", - "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c", - "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae", - "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8", - "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887", - "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b", - "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4", - "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f", - "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5", - "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33", - "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519", - "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561" + "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621", + "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6", + "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8", + "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912", + "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c", + "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b", + "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d", + "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d", + "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95", + "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e", + "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565", + "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64", + "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab", + "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be", + "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e", + "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907", + "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0", + "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2", + "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62", + "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62", + "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23", + "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc", + "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284", + "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca", + "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455", + "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858", + "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b", + "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594", + "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc", + "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db", + "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b", + "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea", + "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6", + "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920", + "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749", + "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7", + "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd", + "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99", + "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242", + "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee", + "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129", + "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2", + "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51", + "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee", + "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8", + "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b", + "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613", + "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742", + "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe", + "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3", + "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5", + "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631", + "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7", + "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15", + "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c", + "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea", + "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417", + "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250", + "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88", + "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca", + "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa", + "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99", + "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149", + "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41", + "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574", + "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0", + "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f", + "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d", + "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654", + "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3", + "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19", + "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90", + "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578", + "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9", + "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1", + "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51", + "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719", + "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236", + "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a", + "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c", + "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade", + "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944", + "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc", + "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6", + "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6", + "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27", + "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6", + "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2", + "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12", + "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf", + "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114", + "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7", + "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf", + "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d", + "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b", + "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed", + "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03", + "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4", + "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67", + "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365", + "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a", + "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748", + "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b", + "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", + "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482" ], "markers": "python_full_version >= '3.7.0'", - "version": "==3.3.2" + "version": "==3.4.0" }, "click": { "hashes": [ @@ -1367,81 +1694,71 @@ }, "coverage": { "hashes": [ - "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca", - "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d", - "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6", - "sha256:0dbde0f4aa9a16fa4d754356a8f2e36296ff4d83994b2c9d8398aa32f222f989", - "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c", - "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b", - "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223", - "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f", - "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56", - "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3", - "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8", - "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb", - "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388", - "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0", - "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a", - "sha256:3f1156e3e8f2872197af3840d8ad307a9dd18e615dc64d9ee41696f287c57ad8", - "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f", - "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a", - "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962", - "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8", - "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391", - "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc", - "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2", - "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155", - "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb", - "sha256:6db04803b6c7291985a761004e9060b2bca08da6d04f26a7f2294b8623a0c1a0", - "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c", - "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a", - "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004", - "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060", - "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232", - "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93", - "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129", - "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163", - "sha256:8929543a7192c13d177b770008bc4e8119f2e1f881d563fc6b6305d2d0ebe9de", - "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6", - "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23", - "sha256:9054a0754de38d9dbd01a46621636689124d666bad1936d76c0341f7d71bf569", - "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d", - "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778", - "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d", - "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36", - "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a", - "sha256:a09ece4a69cf399510c8ab25e0950d9cf2b42f7b3cb0374f95d2e2ff594478a6", - "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34", - "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704", - "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106", - "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9", - "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862", - "sha256:a95324a9de9650a729239daea117df21f4b9868ce32e63f8b650ebe6cef5595b", - "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255", - "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16", - "sha256:b43c03669dc4618ec25270b06ecd3ee4fa94c7f9b3c14bae6571ca00ef98b0d3", - "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133", - "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb", - "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657", - "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d", - "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca", - "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36", - "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c", - "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e", - "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff", - "sha256:da511e6ad4f7323ee5702e6633085fb76c2f893aaf8ce4c51a0ba4fc07580ea7", - "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5", - "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02", - "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c", - "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df", - "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3", - "sha256:f1adfc8ac319e1a348af294106bc6a8458a0f1633cc62a1446aebc30c5fa186a", - "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959", - "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234", - "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc" + "sha256:00a1d69c112ff5149cabe60d2e2ee948752c975d95f1e1096742e6077affd376", + "sha256:023bf8ee3ec6d35af9c1c6ccc1d18fa69afa1cb29eaac57cb064dbb262a517f9", + "sha256:0294ca37f1ba500667b1aef631e48d875ced93ad5e06fa665a3295bdd1d95111", + "sha256:06babbb8f4e74b063dbaeb74ad68dfce9186c595a15f11f5d5683f748fa1d172", + "sha256:0809082ee480bb8f7416507538243c8863ac74fd8a5d2485c46f0f7499f2b491", + "sha256:0b3fb02fe73bed561fa12d279a417b432e5b50fe03e8d663d61b3d5990f29546", + "sha256:0b58c672d14f16ed92a48db984612f5ce3836ae7d72cdd161001cc54512571f2", + "sha256:0bcd1069e710600e8e4cf27f65c90c7843fa8edfb4520fb0ccb88894cad08b11", + "sha256:1032e178b76a4e2b5b32e19d0fd0abbce4b58e77a1ca695820d10e491fa32b08", + "sha256:11a223a14e91a4693d2d0755c7a043db43d96a7450b4f356d506c2562c48642c", + "sha256:12394842a3a8affa3ba62b0d4ab7e9e210c5e366fbac3e8b2a68636fb19892c2", + "sha256:182e6cd5c040cec0a1c8d415a87b67ed01193ed9ad458ee427741c7d8513d963", + "sha256:1d5b8007f81b88696d06f7df0cb9af0d3b835fe0c8dbf489bad70b45f0e45613", + "sha256:1f76846299ba5c54d12c91d776d9605ae33f8ae2b9d1d3c3703cf2db1a67f2c0", + "sha256:27fb4a050aaf18772db513091c9c13f6cb94ed40eacdef8dad8411d92d9992db", + "sha256:29155cd511ee058e260db648b6182c419422a0d2e9a4fa44501898cf918866cf", + "sha256:29fc0f17b1d3fea332f8001d4558f8214af7f1d87a345f3a133c901d60347c73", + "sha256:2b6b4c83d8e8ea79f27ab80778c19bc037759aea298da4b56621f4474ffeb117", + "sha256:2fdef0d83a2d08d69b1f2210a93c416d54e14d9eb398f6ab2f0a209433db19e1", + "sha256:3c65d37f3a9ebb703e710befdc489a38683a5b152242664b973a7b7b22348a4e", + "sha256:4f704f0998911abf728a7783799444fcbbe8261c4a6c166f667937ae6a8aa522", + "sha256:51b44306032045b383a7a8a2c13878de375117946d68dcb54308111f39775a25", + "sha256:53d202fd109416ce011578f321460795abfe10bb901b883cafd9b3ef851bacfc", + "sha256:58809e238a8a12a625c70450b48e8767cff9eb67c62e6154a642b21ddf79baea", + "sha256:5915fcdec0e54ee229926868e9b08586376cae1f5faa9bbaf8faf3561b393d52", + "sha256:5beb1ee382ad32afe424097de57134175fea3faf847b9af002cc7895be4e2a5a", + "sha256:5f8ae553cba74085db385d489c7a792ad66f7f9ba2ee85bfa508aeb84cf0ba07", + "sha256:5fbd612f8a091954a0c8dd4c0b571b973487277d26476f8480bfa4b2a65b5d06", + "sha256:6bd818b7ea14bc6e1f06e241e8234508b21edf1b242d49831831a9450e2f35fa", + "sha256:6f01ba56b1c0e9d149f9ac85a2f999724895229eb36bd997b61e62999e9b0901", + "sha256:73d2b73584446e66ee633eaad1a56aad577c077f46c35ca3283cd687b7715b0b", + "sha256:7bb92c539a624cf86296dd0c68cd5cc286c9eef2d0c3b8b192b604ce9de20a17", + "sha256:8165b796df0bd42e10527a3f493c592ba494f16ef3c8b531288e3d0d72c1f6f0", + "sha256:862264b12ebb65ad8d863d51f17758b1684560b66ab02770d4f0baf2ff75da21", + "sha256:8902dd6a30173d4ef09954bfcb24b5d7b5190cf14a43170e386979651e09ba19", + "sha256:8cf717ee42012be8c0cb205dbbf18ffa9003c4cbf4ad078db47b95e10748eec5", + "sha256:8ed9281d1b52628e81393f5eaee24a45cbd64965f41857559c2b7ff19385df51", + "sha256:99b41d18e6b2a48ba949418db48159d7a2e81c5cc290fc934b7d2380515bd0e3", + "sha256:9cb7fa111d21a6b55cbf633039f7bc2749e74932e3aa7cb7333f675a58a58bf3", + "sha256:a181e99301a0ae128493a24cfe5cfb5b488c4e0bf2f8702091473d033494d04f", + "sha256:a413a096c4cbac202433c850ee43fa326d2e871b24554da8327b01632673a076", + "sha256:a6b1e54712ba3474f34b7ef7a41e65bd9037ad47916ccb1cc78769bae324c01a", + "sha256:ade3ca1e5f0ff46b678b66201f7ff477e8fa11fb537f3b55c3f0568fbfe6e718", + "sha256:b0ac3d42cb51c4b12df9c5f0dd2f13a4f24f01943627120ec4d293c9181219ba", + "sha256:b369ead6527d025a0fe7bd3864e46dbee3aa8f652d48df6174f8d0bac9e26e0e", + "sha256:b57b768feb866f44eeed9f46975f3d6406380275c5ddfe22f531a2bf187eda27", + "sha256:b8d3a03d9bfcaf5b0141d07a88456bb6a4c3ce55c080712fec8418ef3610230e", + "sha256:bc66f0bf1d7730a17430a50163bb264ba9ded56739112368ba985ddaa9c3bd09", + "sha256:bf20494da9653f6410213424f5f8ad0ed885e01f7e8e59811f572bdb20b8972e", + "sha256:c48167910a8f644671de9f2083a23630fbf7a1cb70ce939440cd3328e0919f70", + "sha256:c481b47f6b5845064c65a7bc78bc0860e635a9b055af0df46fdf1c58cebf8e8f", + "sha256:c7c8b95bf47db6d19096a5e052ffca0a05f335bc63cef281a6e8fe864d450a72", + "sha256:c9b8e184898ed014884ca84c70562b4a82cbc63b044d366fedc68bc2b2f3394a", + "sha256:cc8ff50b50ce532de2fa7a7daae9dd12f0a699bfcd47f20945364e5c31799fef", + "sha256:d541423cdd416b78626b55f123412fcf979d22a2c39fce251b350de38c15c15b", + "sha256:dab4d16dfef34b185032580e2f2f89253d302facba093d5fa9dbe04f569c4f4b", + "sha256:dacbc52de979f2823a819571f2e3a350a7e36b8cb7484cdb1e289bceaf35305f", + "sha256:df57bdbeffe694e7842092c5e2e0bc80fff7f43379d465f932ef36f027179806", + "sha256:ed8fe9189d2beb6edc14d3ad19800626e1d9f2d975e436f84e19efb7fa19469b", + "sha256:f3ddf056d3ebcf6ce47bdaf56142af51bb7fad09e4af310241e9db7a3a8022e1", + "sha256:f8fe4984b431f8621ca53d9380901f62bfb54ff759a1348cd140490ada7b693c", + "sha256:fe439416eb6380de434886b00c859304338f8b19f6f54811984f3420a2e03858" ], - "markers": "python_version >= '3.8'", - "version": "==7.6.1" + "markers": "python_version >= '3.9'", + "version": "==7.6.4" }, "flake8": { "hashes": [ @@ -1480,37 +1797,37 @@ }, "google-api-core": { "hashes": [ - "sha256:53ec0258f2837dd53bbd3d3df50f5359281b3cc13f800c941dd15a9b5a415af4", - "sha256:ca07de7e8aa1c98a8bfca9321890ad2340ef7f2eb136e558cee68f24b94b0a8f" + "sha256:4a152fd11a9f774ea606388d423b68aa7e6d6a0ffe4c8266f74979613ec09f81", + "sha256:6869eacb2a37720380ba5898312af79a4d30b8bca1548fb4093e0697dc4bdf5d" ], "markers": "python_version >= '3.7'", - "version": "==2.19.2" + "version": "==2.21.0" }, "google-api-python-client": { "hashes": [ - "sha256:41f671be10fa077ee5143ee9f0903c14006d39dc644564f4e044ae96b380bf68", - "sha256:b1e62c9889c5ef6022f11d30d7ef23dc55100300f0e8aaf8aa09e8e92540acad" + "sha256:1a5232e9cfed8c201799d9327e4d44dc7ea7daa3c6e1627fca41aa201539c0da", + "sha256:b9d68c6b14ec72580d66001bd33c5816b78e2134b93ccc5cf8f624516b561750" ], "index": "pypi", "markers": "python_version >= '3.7'", - "version": "==2.146.0" + "version": "==2.149.0" }, "google-api-python-client-stubs": { "hashes": [ - "sha256:148e16613e070969727f39691e23a73cdb87c65a4fc8133abd4c41d17b80b313", - "sha256:3c1f9f2a7cac8d1e9a7e84ed24e6c29cf4c643b0f94e39ed09ac1b7e91ab239a" + "sha256:7327c058fb5ba975309922f962f17931b9c82af51d95a5dc04061ed0c20b9f06", + "sha256:75b3dfe67b9d74ac3b58d78725326836769d0b2df1cbef354a5455a5cc57d68d" ], "index": "pypi", - "markers": "python_version >= '3.7' and python_version < '4.0'", - "version": "==1.27.0" + "markers": "python_version >= '3.7'", + "version": "==1.28.0" }, "google-auth": { "hashes": [ - "sha256:72fd4733b80b6d777dcde515628a9eb4a577339437012874ea286bca7261ee65", - "sha256:8eb87396435c19b20d32abd2f984e31c191a15284af72eb922f10e5bde9c04cc" + "sha256:25df55f327ef021de8be50bad0dfd4a916ad0de96da86cd05661c9297723ad3f", + "sha256:f4c64ed4e01e8e8b646ef34c018f8bf3338df0c8e37d8b3bba40e7f574a3278a" ], "markers": "python_version >= '3.7'", - "version": "==2.34.0" + "version": "==2.35.0" }, "google-auth-httplib2": { "hashes": [ @@ -1643,20 +1960,20 @@ }, "protobuf": { "hashes": [ - "sha256:2c69461a7fcc8e24be697624c09a839976d82ae75062b11a0972e41fd2cd9132", - "sha256:35cfcb15f213449af7ff6198d6eb5f739c37d7e4f1c09b5d0641babf2cc0c68f", - "sha256:52235802093bd8a2811abbe8bf0ab9c5f54cca0a751fdd3f6ac2a21438bffece", - "sha256:59379674ff119717404f7454647913787034f03fe7049cbef1d74a97bb4593f0", - "sha256:5e8a95246d581eef20471b5d5ba010d55f66740942b95ba9b872d918c459452f", - "sha256:87317e9bcda04a32f2ee82089a204d3a2f0d3c8aeed16568c7daf4756e4f1fe0", - "sha256:8ddc60bf374785fb7cb12510b267f59067fa10087325b8e1855b898a0d81d276", - "sha256:a8b9403fc70764b08d2f593ce44f1d2920c5077bf7d311fefec999f8c40f78b7", - "sha256:c0ea0123dac3399a2eeb1a1443d82b7afc9ff40241433296769f7da42d142ec3", - "sha256:ca53faf29896c526863366a52a8f4d88e69cd04ec9571ed6082fa117fac3ab36", - "sha256:eeea10f3dc0ac7e6b4933d32db20662902b4ab81bf28df12218aa389e9c2102d" + "sha256:0c4eec6f987338617072592b97943fdbe30d019c56126493111cf24344c1cc24", + "sha256:135658402f71bbd49500322c0f736145731b16fc79dc8f367ab544a17eab4535", + "sha256:27b246b3723692bf1068d5734ddaf2fccc2cdd6e0c9b47fe099244d80200593b", + "sha256:3e6101d095dfd119513cde7259aa703d16c6bbdfae2554dfe5cfdbe94e32d548", + "sha256:3fa2de6b8b29d12c61911505d893afe7320ce7ccba4df913e2971461fa36d584", + "sha256:64badbc49180a5e401f373f9ce7ab1d18b63f7dd4a9cdc43c92b9f0b481cef7b", + "sha256:70585a70fc2dd4818c51287ceef5bdba6387f88a578c86d47bb34669b5552c36", + "sha256:712319fbdddb46f21abb66cd33cb9e491a5763b2febd8f228251add221981135", + "sha256:91fba8f445723fcf400fdbe9ca796b19d3b1242cd873907979b9ed71e4afe868", + "sha256:a3f6857551e53ce35e60b403b8a27b0295f7d6eb63d10484f12bc6879c715687", + "sha256:cee1757663fa32a1ee673434fcf3bf24dd54763c79690201208bafec62f19eed" ], "markers": "python_version >= '3.8'", - "version": "==5.28.2" + "version": "==5.28.3" }, "py": { "hashes": [ @@ -1708,11 +2025,11 @@ }, "pyparsing": { "hashes": [ - "sha256:a6a7ee4235a3f944aa1fa2249307708f893fe5717dc603503c6c7969c070fb7c", - "sha256:f86ec8d1a83f11977c9a6ea7598e8c27fc5cddfa5b07ea2241edbbde1d7bc032" + "sha256:93d9577b88da0bbea8cc8334ee8b918ed014968fd2ec383e868fb8afb1ccef84", + "sha256:cbf74e27246d595d9a74b186b810f6fbb86726dbf3b9532efb343f6d7294fe9c" ], "markers": "python_version >= '3.1'", - "version": "==3.1.4" + "version": "==3.2.0" }, "pytest": { "hashes": [ @@ -1757,11 +2074,11 @@ }, "tomli": { "hashes": [ - "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", - "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" + "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38", + "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed" ], "markers": "python_version < '3.11'", - "version": "==2.0.1" + "version": "==2.0.2" }, "types-httplib2": { "hashes": [ diff --git a/abr-testing/abr_testing/automation/google_sheets_tool.py b/abr-testing/abr_testing/automation/google_sheets_tool.py index 3ca3bd38f9b..d284a13a241 100644 --- a/abr-testing/abr_testing/automation/google_sheets_tool.py +++ b/abr-testing/abr_testing/automation/google_sheets_tool.py @@ -167,6 +167,7 @@ def column_letter_to_index(column_letter: str) -> int: self.spread_sheet.batch_update(body=body) except gspread.exceptions.APIError as e: print(f"ERROR MESSAGE: {e}") + raise def update_cell( self, sheet_title: str, row: int, column: int, single_data: Any diff --git a/abr-testing/abr_testing/data_collection/abr_calibration_logs.py b/abr-testing/abr_testing/data_collection/abr_calibration_logs.py index 82d9d9c45bc..75b73b8f16b 100644 --- a/abr-testing/abr_testing/data_collection/abr_calibration_logs.py +++ b/abr-testing/abr_testing/data_collection/abr_calibration_logs.py @@ -1,129 +1,332 @@ """Get Calibration logs from robots.""" -from typing import Dict, Any, List, Union +from typing import Dict, Any, List, Set import argparse import os import json import sys -import time as t +import traceback +import hashlib from abr_testing.data_collection import read_robot_logs from abr_testing.automation import google_drive_tool, google_sheets_tool -def check_for_duplicates( - sheet_location: str, - google_sheet: Any, - col_1: int, - col_2: int, - row: List[str], - headers: List[str], -) -> Union[List[str], None]: - """Check google sheet for duplicates.""" - t.sleep(5) - serials = google_sheet.get_column(col_1) - modify_dates = google_sheet.get_column(col_2) - # Check for calibration time stamp. - if row[-1] is not None: - if len(row[-1]) > 0: - for serial, modify_date in zip(serials, modify_dates): - if row[col_1 - 1] == serial and row[col_2 - 1] == modify_date: - print( - f"Skipped row for instrument {serial}. Already on Google Sheet." - ) - return None - read_robot_logs.write_to_sheets(sheet_location, google_sheet, row, headers) - print(f"Writing calibration for: {row[7]}") - return row - - -def upload_calibration_offsets( - calibration: Dict[str, Any], storage_directory: str -) -> None: - """Upload calibration data to google_sheet.""" - # Common Headers - headers_beg = list(calibration.keys())[:4] - headers_end = list(["X", "Y", "Z", "lastModified"]) +def instrument_helper( + headers_beg: List[str], + headers_end: List[str], + calibration_log: Dict[Any, Any], + google_sheet_name: str, + inst_sheet_serials: Set[str], + inst_sheet_modify_dates: Set[str], + storage_directory: str, +) -> List[Any]: + """Helper for parsing instrument calibration data.""" + # Populate Instruments # INSTRUMENT SHEET + instruments_upload_rows: List[Any] = [] instrument_headers = ( - headers_beg + list(calibration["Instruments"][0].keys())[:7] + headers_end + headers_beg + list(calibration_log["Instruments"][0].keys())[:7] + headers_end ) local_instrument_file = google_sheet_name + "-Instruments" - instrument_sheet_location = read_robot_logs.create_abr_data_sheet( + read_robot_logs.create_abr_data_sheet( storage_directory, local_instrument_file, instrument_headers ) # INSTRUMENTS DATA - instruments = calibration["Instruments"] + instruments = calibration_log["Instruments"] for instrument in range(len(instruments)): one_instrument = instruments[instrument] + inst_serial = one_instrument["serialNumber"] + modified = one_instrument["data"]["calibratedOffset"].get("last_modified", "") + if inst_serial in inst_sheet_serials and modified in inst_sheet_modify_dates: + continue x = one_instrument["data"]["calibratedOffset"]["offset"].get("x", "") y = one_instrument["data"]["calibratedOffset"]["offset"].get("y", "") z = one_instrument["data"]["calibratedOffset"]["offset"].get("z", "") - modified = one_instrument["data"]["calibratedOffset"].get("last_modified", "") instrument_row = ( - list(calibration.values())[:4] + list(calibration_log.values())[:4] + list(one_instrument.values())[:7] + list([x, y, z, modified]) ) - check_for_duplicates( - instrument_sheet_location, - google_sheet_instruments, - 8, - 15, - instrument_row, - instrument_headers, - ) + instruments_upload_rows.append(instrument_row) + return instruments_upload_rows + +def module_helper( + headers_beg: List[str], + headers_end: List[str], + calibration_log: Dict[Any, Any], + google_sheet_name: str, + module_sheet_serials: Set[str], + module_modify_dates: Set[str], + storage_directory: str, +) -> List[Any]: + """Helper for parsing module calibration data.""" + # Populate Modules # MODULE SHEET - if len(calibration.get("Modules", "")) > 0: + modules_upload_rows: List[Any] = [] + if len(calibration_log.get("Modules", "")) > 0: module_headers = ( - headers_beg + list(calibration["Modules"][0].keys())[:7] + headers_end + headers_beg + list(calibration_log["Modules"][0].keys())[:7] + headers_end ) local_modules_file = google_sheet_name + "-Modules" - modules_sheet_location = read_robot_logs.create_abr_data_sheet( + read_robot_logs.create_abr_data_sheet( storage_directory, local_modules_file, module_headers ) # MODULES DATA - modules = calibration["Modules"] + modules = calibration_log["Modules"] for module in range(len(modules)): one_module = modules[module] - x = one_module["moduleOffset"]["offset"].get("x", "") - y = one_module["moduleOffset"]["offset"].get("y", "") - z = one_module["moduleOffset"]["offset"].get("z", "") - modified = one_module["moduleOffset"].get("last_modified", "") + mod_serial = one_module["serialNumber"] + try: + modified = one_module["moduleOffset"].get("last_modified", "") + x = one_module["moduleOffset"]["offset"].get("x", "") + y = one_module["moduleOffset"]["offset"].get("y", "") + z = one_module["moduleOffset"]["offset"].get("z", "") + except KeyError: + continue + if mod_serial in module_sheet_serials and modified in module_modify_dates: + continue module_row = ( - list(calibration.values())[:4] + list(calibration_log.values())[:4] + list(one_module.values())[:7] + list([x, y, z, modified]) ) - check_for_duplicates( - modules_sheet_location, - google_sheet_modules, - 8, - 15, - module_row, - module_headers, - ) + modules_upload_rows.append(module_row) + return modules_upload_rows + + +def create_hash( + robot_name: str, deck_slot: str, pipette_calibrated_with: str, last_modified: str +) -> str: + """Create unique hash identifier for deck calibrations.""" + combined_string = robot_name + deck_slot + pipette_calibrated_with + last_modified + hashed_obj = hashlib.sha256(combined_string.encode()) + return hashed_obj.hexdigest() + + +def deck_helper( + headers_beg: List[str], + headers_end: List[str], + calibration_log: Dict[Any, Any], + google_sheet_name: str, + deck_sheet_hashes: Set[str], + storage_directory: str, +) -> List[Any]: + """Helper for parsing deck calibration data.""" + deck_upload_rows: List[Any] = [] + # Populate Deck # DECK SHEET local_deck_file = google_sheet_name + "-Deck" deck_headers = headers_beg + list(["pipetteCalibratedWith", "Slot"]) + headers_end - deck_sheet_location = read_robot_logs.create_abr_data_sheet( + read_robot_logs.create_abr_data_sheet( storage_directory, local_deck_file, deck_headers ) # DECK DATA - deck = calibration["Deck"] + deck = calibration_log["Deck"] + deck_modified = str(deck["data"].get("lastModified")) slots = ["D3", "D1", "A1"] - deck_modified = deck["data"].get("lastModified", "") - pipette_calibrated_with = deck["data"].get("pipetteCalibratedWith", "") + pipette_calibrated_with = str(deck["data"].get("pipetteCalibratedWith", "")) for i in range(len(deck["data"]["matrix"])): + robot = calibration_log["Robot"] + deck_slot = slots[i] + unique_hash = create_hash( + robot, deck_slot, pipette_calibrated_with, deck_modified + ) + if unique_hash in deck_sheet_hashes: + continue coords = deck["data"]["matrix"][i] x = coords[0] y = coords[1] z = coords[2] - deck_row = list(calibration.values())[:4] + list( + deck_row = list(calibration_log.values())[:4] + list( [pipette_calibrated_with, slots[i], x, y, z, deck_modified] ) - check_for_duplicates( - deck_sheet_location, google_sheet_deck, 6, 10, deck_row, deck_headers + deck_upload_rows.append(deck_row) + return deck_upload_rows + + +def send_batch_update( + instruments_upload_rows: List[str], + google_sheet_instruments: google_sheets_tool.google_sheet, + modules_upload_rows: List[str], + google_sheet_modules: google_sheets_tool.google_sheet, + deck_upload_rows: List[str], + google_sheet_deck: google_sheets_tool.google_sheet, +) -> None: + """Executes batch updates.""" + # Prepare data for batch update + try: + transposed_instruments_upload_rows = list( + map(list, zip(*instruments_upload_rows)) + ) + google_sheet_instruments.batch_update_cells( + transposed_instruments_upload_rows, + "A", + google_sheet_instruments.get_index_row() + 1, + "0", + ) + except Exception: + print("No new instrument data") + try: + transposed_module_upload_rows = list(map(list, zip(*modules_upload_rows))) + google_sheet_modules.batch_update_cells( + transposed_module_upload_rows, + "A", + google_sheet_modules.get_index_row() + 1, + "1020695883", + ) + except Exception: + print("No new module data") + try: + transposed_deck_upload_rows = list(map(list, zip(*deck_upload_rows))) + google_sheet_deck.batch_update_cells( + transposed_deck_upload_rows, + "A", + google_sheet_deck.get_index_row() + 1, + "1332568460", + ) + except Exception: + print("No new deck data") + + +def upload_calibration_offsets( + calibration_data: List[Dict[str, Any]], + storage_directory: str, + google_sheet_instruments: google_sheets_tool.google_sheet, + google_sheet_modules: google_sheets_tool.google_sheet, + google_sheet_deck: google_sheets_tool.google_sheet, + google_sheet_name: str, +) -> None: + """Upload calibration data to google_sheet.""" + # Common Headers + headers_beg = list(calibration_data[0].keys())[:4] + headers_end = list(["X", "Y", "Z", "lastModified"]) + sheets = [google_sheet_instruments, google_sheet_modules, google_sheet_deck] + instruments_upload_rows: List[Any] = [] + modules_upload_rows: List[Any] = [] + deck_upload_rows: List[Any] = [] + inst_sheet_serials: Set[str] = set() + inst_sheet_modify_dates: Set[str] = set() + module_sheet_serials: Set[str] = set() + deck_sheet_hashes: Set[str] = set() + # Get current serials, and modified info from google sheet + for i, sheet in enumerate(sheets): + if i == 0: + inst_sheet_serials = sheet.get_column(8) + inst_sheet_modify_dates = sheet.get_column(15) + if i == 1: + module_sheet_serials = sheet.get_column(6) + module_modify_dates = sheet.get_column(15) + elif i == 2: + deck_sheet_hashes = sheet.get_column(11) + + # Iterate through calibration logs and accumulate data + for calibration_log in calibration_data: + for sheet_ind, sheet in enumerate(sheets): + if sheet_ind == 0: + instruments_upload_rows += instrument_helper( + headers_beg, + headers_end, + calibration_log, + google_sheet_name, + inst_sheet_serials, + inst_sheet_modify_dates, + storage_directory, + ) + elif sheet_ind == 1: + modules_upload_rows += module_helper( + headers_beg, + headers_end, + calibration_log, + google_sheet_name, + module_sheet_serials, + module_modify_dates, + storage_directory, + ) + elif sheet_ind == 2: + deck_upload_rows += deck_helper( + headers_beg, + headers_end, + calibration_log, + google_sheet_name, + deck_sheet_hashes, + storage_directory, + ) + send_batch_update( + instruments_upload_rows, + google_sheet_instruments, + modules_upload_rows, + google_sheet_modules, + deck_upload_rows, + google_sheet_deck, + ) + + +def run( + storage_directory: str, folder_name: str, google_sheet_name_param: str, email: str +) -> None: + """Main control function.""" + # Connect to google drive. + google_sheet_name = google_sheet_name_param + try: + credentials_path = os.path.join(storage_directory, "credentials.json") + except FileNotFoundError: + print(f"Add credentials.json file to: {storage_directory}.") + sys.exit() + google_drive = google_drive_tool.google_drive(credentials_path, folder_name, email) + # Connect to google sheet + google_sheet_instruments = google_sheets_tool.google_sheet( + credentials_path, google_sheet_name, 0 + ) + google_sheet_modules = google_sheets_tool.google_sheet( + credentials_path, google_sheet_name, 1 + ) + google_sheet_deck = google_sheets_tool.google_sheet( + credentials_path, google_sheet_name, 2 + ) + ip_json_file = os.path.join(storage_directory, "IPs.json") + try: + ip_file = json.load(open(ip_json_file)) + except FileNotFoundError: + print(f"Add .json file with robot IPs to: {storage_directory}.") + sys.exit() + ip_or_all = "" + while not ip_or_all: + ip_or_all = input("IP Address or ALL: ") + calibration_data = [] + if ip_or_all.upper() == "ALL": + ip_address_list = ip_file["ip_address_list"] + for ip in ip_address_list: + saved_file_path, calibration = read_robot_logs.get_calibration_offsets( + ip, storage_directory + ) + calibration_data.append(calibration) + else: + try: + ( + saved_file_path, + calibration, + ) = read_robot_logs.get_calibration_offsets( + ip_or_all, storage_directory + ) + calibration_data.append(calibration) + except Exception: + print("Invalid IP try again") + ip_or_all = "" + try: + upload_calibration_offsets( + calibration_data, + storage_directory, + google_sheet_instruments, + google_sheet_modules, + google_sheet_deck, + google_sheet_name, ) + print("Successfully uploaded calibration data!") + except Exception: + print("No calibration data to upload: ") + traceback.print_exc() + sys.exit(1) + google_drive.upload_missing_files(storage_directory) if __name__ == "__main__": @@ -160,42 +363,4 @@ def upload_calibration_offsets( folder_name = args.folder_name[0] google_sheet_name = args.google_sheet_name[0] email = args.email[0] - # Connect to google drive. - try: - credentials_path = os.path.join(storage_directory, "credentials.json") - except FileNotFoundError: - print(f"Add credentials.json file to: {storage_directory}.") - sys.exit() - google_drive = google_drive_tool.google_drive(credentials_path, folder_name, email) - # Connect to google sheet - google_sheet_instruments = google_sheets_tool.google_sheet( - credentials_path, google_sheet_name, 0 - ) - google_sheet_modules = google_sheets_tool.google_sheet( - credentials_path, google_sheet_name, 1 - ) - google_sheet_deck = google_sheets_tool.google_sheet( - credentials_path, google_sheet_name, 2 - ) - ip_json_file = os.path.join(storage_directory, "IPs.json") - try: - ip_file = json.load(open(ip_json_file)) - except FileNotFoundError: - print(f"Add .json file with robot IPs to: {storage_directory}.") - sys.exit() - ip_or_all = input("IP Address or ALL: ") - - if ip_or_all == "ALL": - ip_address_list = ip_file["ip_address_list"] - for ip in ip_address_list: - saved_file_path, calibration = read_robot_logs.get_calibration_offsets( - ip, storage_directory - ) - upload_calibration_offsets(calibration, storage_directory) - else: - saved_file_path, calibration = read_robot_logs.get_calibration_offsets( - ip_or_all, storage_directory - ) - upload_calibration_offsets(calibration, storage_directory) - - google_drive.upload_missing_files(storage_directory) + run(storage_directory, folder_name, google_sheet_name, email) diff --git a/abr-testing/abr_testing/data_collection/abr_google_drive.py b/abr-testing/abr_testing/data_collection/abr_google_drive.py index e1924e3c53e..88ed55cab82 100644 --- a/abr-testing/abr_testing/data_collection/abr_google_drive.py +++ b/abr-testing/abr_testing/data_collection/abr_google_drive.py @@ -158,38 +158,10 @@ def create_data_dictionary( return transposed_runs_and_robots, headers, transposed_runs_and_lpc, headers_lpc -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Read run logs on google drive.") - parser.add_argument( - "storage_directory", - metavar="STORAGE_DIRECTORY", - type=str, - nargs=1, - help="Path to long term storage directory for run logs.", - ) - parser.add_argument( - "folder_name", - metavar="FOLDER_NAME", - type=str, - nargs=1, - help="Google Drive folder name. Open desired folder and copy string after drive/folders/.", - ) - parser.add_argument( - "google_sheet_name", - metavar="GOOGLE_SHEET_NAME", - type=str, - nargs=1, - help="Google sheet name.", - ) - parser.add_argument( - "email", metavar="EMAIL", type=str, nargs=1, help="opentrons gmail." - ) - args = parser.parse_args() - folder_name = args.folder_name[0] - storage_directory = args.storage_directory[0] - google_sheet_name = args.google_sheet_name[0] - email = args.email[0] - +def run( + storage_directory: str, folder_name: str, google_sheet_name: str, email: str +) -> None: + """Main control function.""" try: credentials_path = os.path.join(storage_directory, "credentials.json") except FileNotFoundError: @@ -203,7 +175,6 @@ def create_data_dictionary( # Get run ids on google sheet run_ids_on_gs = set(google_sheet.get_column(2)) # Get robots on google sheet - robots = list(set(google_sheet.get_column(1))) # Uploads files that are not in google drive directory google_drive.upload_missing_files(storage_directory) @@ -229,7 +200,6 @@ def create_data_dictionary( hellma_plate_standards=file_values, ) start_row = google_sheet.get_index_row() + 1 - print(start_row) google_sheet.batch_update_cells(transposed_runs_and_robots, "A", start_row, "0") # Add LPC to google sheet @@ -238,6 +208,40 @@ def create_data_dictionary( google_sheet_lpc.batch_update_cells( transposed_runs_and_lpc, "A", start_row_lpc, "0" ) - robots = list(set(google_sheet.get_column(1))) # Calculate Robot Lifetimes sync_abr_sheet.determine_lifetime(google_sheet) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Read run logs on google drive.") + parser.add_argument( + "storage_directory", + metavar="STORAGE_DIRECTORY", + type=str, + nargs=1, + help="Path to long term storage directory for run logs.", + ) + parser.add_argument( + "folder_name", + metavar="FOLDER_NAME", + type=str, + nargs=1, + help="Google Drive folder name. Open desired folder and copy string after drive/folders/.", + ) + parser.add_argument( + "google_sheet_name", + metavar="GOOGLE_SHEET_NAME", + type=str, + nargs=1, + help="Google sheet name.", + ) + parser.add_argument( + "email", metavar="EMAIL", type=str, nargs=1, help="opentrons gmail." + ) + args = parser.parse_args() + folder_name = args.folder_name[0] + storage_directory = args.storage_directory[0] + google_sheet_name = args.google_sheet_name[0] + email = args.email[0] + + run(storage_directory, folder_name, google_sheet_name, email) diff --git a/abr-testing/abr_testing/data_collection/get_run_logs.py b/abr-testing/abr_testing/data_collection/get_run_logs.py index 3d8eb851197..24d5aaf4f3b 100644 --- a/abr-testing/abr_testing/data_collection/get_run_logs.py +++ b/abr-testing/abr_testing/data_collection/get_run_logs.py @@ -92,7 +92,9 @@ def save_runs(runs_to_save: Set[str], ip: str, storage_directory: str) -> Set[st return saved_file_paths -def get_all_run_logs(storage_directory: str) -> None: +def get_all_run_logs( + storage_directory: str, google_drive: google_drive_tool.google_drive +) -> None: """GET ALL RUN LOGS. Connect to each ABR robot to read run log data. @@ -114,6 +116,17 @@ def get_all_run_logs(storage_directory: str) -> None: google_drive.upload_missing_files(storage_directory) +def run(storage_directory: str, folder_name: str, email: str) -> None: + """Main control function.""" + try: + credentials_path = os.path.join(storage_directory, "credentials.json") + except FileNotFoundError: + print(f"Add credentials.json file to: {storage_directory}.") + sys.exit() + google_drive = google_drive_tool.google_drive(credentials_path, folder_name, email) + get_all_run_logs(storage_directory, google_drive) + + if __name__ == "__main__": """Get run logs.""" parser = argparse.ArgumentParser(description="Pulls run logs from ABR robots.") @@ -138,10 +151,4 @@ def get_all_run_logs(storage_directory: str) -> None: storage_directory = args.storage_directory[0] folder_name = args.folder_name[0] email = args.email[0] - try: - credentials_path = os.path.join(storage_directory, "credentials.json") - except FileNotFoundError: - print(f"Add credentials.json file to: {storage_directory}.") - sys.exit() - google_drive = google_drive_tool.google_drive(credentials_path, folder_name, email) - get_all_run_logs(storage_directory) + run(storage_directory, folder_name, email) diff --git a/abr-testing/abr_testing/data_collection/read_robot_logs.py b/abr-testing/abr_testing/data_collection/read_robot_logs.py index be74294fbe5..9fd9f0e7d71 100644 --- a/abr-testing/abr_testing/data_collection/read_robot_logs.py +++ b/abr-testing/abr_testing/data_collection/read_robot_logs.py @@ -13,7 +13,6 @@ import time as t import json import requests -import sys from abr_testing.tools import plate_reader @@ -111,6 +110,7 @@ def identify_labware_ids( file_results: Dict[str, Any], labware_name: Optional[str] ) -> List[str]: """Determine what type of labware is being picked up.""" + list_of_labware_ids: List[str] = [] if labware_name: labwares = file_results.get("labware", "") list_of_labware_ids = [] @@ -342,8 +342,9 @@ def hs_commands(file_results: Dict[str, Any]) -> Dict[str, float]: ) if temp_time is not None and deactivate_time is None: # If heater shaker module is not deactivated, protocol completedAt time stamp used. + default = commandData[len(commandData) - 1].get("completedAt") protocol_end = datetime.strptime( - file_results.get("completedAt", ""), "%Y-%m-%dT%H:%M:%S.%f%z" + file_results.get("completedAt", default), "%Y-%m-%dT%H:%M:%S.%f%z" ) temp_duration = (protocol_end - temp_time).total_seconds() hs_temps[hs_temp] = hs_temps.get(hs_temp, 0.0) + temp_duration @@ -390,8 +391,9 @@ def temperature_module_commands(file_results: Dict[str, Any]) -> Dict[str, Any]: tm_temps[tm_temp] = tm_temps.get(tm_temp, 0.0) + temp_duration if temp_time is not None and deactivate_time is None: # If temperature module is not deactivated, protocol completedAt time stamp used. + default = commandData[len(commandData) - 1].get("completedAt") protocol_end = datetime.strptime( - file_results.get("completedAt", ""), "%Y-%m-%dT%H:%M:%S.%f%z" + file_results.get("completedAt", default), "%Y-%m-%dT%H:%M:%S.%f%z" ) temp_duration = (protocol_end - temp_time).total_seconds() tm_temps[tm_temp] = tm_temps.get(tm_temp, 0.0) + temp_duration @@ -474,15 +476,17 @@ def thermocycler_commands(file_results: Dict[str, Any]) -> Dict[str, float]: block_temps[block_temp] = block_temps.get(block_temp, 0.0) + block_time if block_on_time is not None and block_off_time is None: # If thermocycler block not deactivated protocol completedAt time stamp used + default = commandData[len(commandData) - 1].get("completedAt") protocol_end = datetime.strptime( - file_results.get("completedAt", ""), "%Y-%m-%dT%H:%M:%S.%f%z" + file_results.get("completedAt", default), "%Y-%m-%dT%H:%M:%S.%f%z" ) temp_duration = (protocol_end - block_on_time).total_seconds() - block_temps[block_temp] = block_temps.get(block_temp, 0.0) + temp_duration + if lid_on_time is not None and lid_off_time is None: # If thermocycler lid not deactivated protocol completedAt time stamp used + default = commandData[len(commandData) - 1].get("completedAt") protocol_end = datetime.strptime( - file_results.get("completedAt", ""), "%Y-%m-%dT%H:%M:%S.%f%z" + file_results.get("completedAt", default), "%Y-%m-%dT%H:%M:%S.%f%z" ) temp_duration = (protocol_end - lid_on_time).total_seconds() lid_temps[lid_temp] = block_temps.get(lid_temp, 0.0) + temp_duration @@ -695,7 +699,7 @@ def get_calibration_offsets( print(f"Connected to {ip}") except Exception: print(f"ERROR: Failed to read IP address: {ip}") - sys.exit() + pass health_data = response.json() robot_name = health_data.get("name", "") api_version = health_data.get("api_version", "") diff --git a/abr-testing/abr_testing/protocol_simulation/__init__.py b/abr-testing/abr_testing/protocol_simulation/__init__.py new file mode 100644 index 00000000000..d3776c77fad --- /dev/null +++ b/abr-testing/abr_testing/protocol_simulation/__init__.py @@ -0,0 +1 @@ +"""The package holding code for simulating protocols.""" diff --git a/abr-testing/abr_testing/protocol_simulation/abr_sim_check.py b/abr-testing/abr_testing/protocol_simulation/abr_sim_check.py new file mode 100644 index 00000000000..23575165eff --- /dev/null +++ b/abr-testing/abr_testing/protocol_simulation/abr_sim_check.py @@ -0,0 +1,34 @@ +"""Check ABR Protocols Simulate Successfully.""" +from abr_testing.protocol_simulation import simulation_metrics +import os +import traceback +from pathlib import Path + + +def run(file_to_simulate: Path) -> None: + """Simulate protocol and raise errors.""" + protocol_name = file_to_simulate.stem + try: + simulation_metrics.main(file_to_simulate, False) + except Exception: + print(f"Error in protocol: {protocol_name}") + traceback.print_exc() + + +if __name__ == "__main__": + # Directory to search + root_dir = "abr_testing/protocols" + + exclude = [ + "__init__.py", + "shared_vars_and_funcs.py", + ] + # Walk through the root directory and its subdirectories + for root, dirs, files in os.walk(root_dir): + for file in files: + if file.endswith(".py"): # If it's a Python file + if file in exclude: + continue + file_path = Path(os.path.join(root, file)) + print(f"Simulating protocol: {file_path.stem}") + run(file_path) diff --git a/abr-testing/abr_testing/protocol_simulation/simulation_metrics.py b/abr-testing/abr_testing/protocol_simulation/simulation_metrics.py new file mode 100644 index 00000000000..9d21109f37e --- /dev/null +++ b/abr-testing/abr_testing/protocol_simulation/simulation_metrics.py @@ -0,0 +1,519 @@ +"""Creates google sheet to display metrics of protocol.""" +import sys +import os +from pathlib import Path +from click import Context +from opentrons.cli import analyze +import json +import argparse +from datetime import datetime +from abr_testing.automation import google_sheets_tool +from abr_testing.data_collection import read_robot_logs +from typing import Any, Tuple, List, Dict, Union, NoReturn +from abr_testing.tools import plate_reader + + +def set_api_level(protocol_file_path: str) -> None: + """Set API level for analysis.""" + with open(protocol_file_path, "r") as file: + file_contents = file.readlines() + # Look for current'apiLevel:' + for i, line in enumerate(file_contents): + print(line) + if "apiLevel" in line: + print(f"The current API level of this protocol is: {line}") + change = ( + input("Would you like to simulate with a different API level? (Y/N) ") + .strip() + .upper() + ) + + if change == "Y": + api_level = input("Protocol API Level to Simulate with: ") + # Update new API level + file_contents[i] = f"apiLevel: {api_level}\n" + print(f"Updated line: {file_contents[i]}") + break + with open(protocol_file_path, "w") as file: + file.writelines(file_contents) + print("File updated successfully.") + + +def look_for_air_gaps(protocol_file_path: str) -> int: + """Search Protocol for Air Gaps.""" + instances = 0 + try: + with open(protocol_file_path, "r") as open_file: + protocol_lines = open_file.readlines() + for line in protocol_lines: + if "air_gap" in line: + print(line) + instances += 1 + print(f"Found {instances} instance(s) of the air gap function") + open_file.close() + except Exception as error: + print("Error reading protocol:", error) + raise error.with_traceback(error.__traceback__) + return instances + + +# Mock sys.exit to avoid program termination +original_exit = sys.exit # Save the original sys.exit function + + +def mock_exit(code: Union[str, int, None] = None) -> NoReturn: + """Prevents program from exiting after analysis.""" + print(f"sys.exit() called with code: {code}") + raise SystemExit(code) # Raise the exception but catch it to prevent termination + + +def get_labware_name(id: str, object_dict: dict, json_data: dict) -> str: + """Recursively find the labware_name.""" + slot = "" + for obj in object_dict: + if obj["id"] == id: + try: + # Try to get the slotName from the location + slot = obj["location"]["slotName"] + return " SLOT: " + slot + except KeyError: + # Handle KeyError when location or slotName is missing + location = obj.get("location", {}) + + # Check if location contains 'moduleId' + if "moduleId" in location: + return get_labware_name( + location["moduleId"], json_data["modules"], json_data + ) + + # Check if location contains 'labwareId' + elif "labwareId" in location: + return get_labware_name( + location["labwareId"], json_data["labware"], json_data + ) + + return " Labware not found" + + +def determine_liquid_movement_volumes( + commands: List[Dict[str, Any]], json_data: Dict[str, Any] +) -> Dict[str, Any]: + """Determine where liquid is moved during protocol.""" + labware_well_dict: Dict[str, Any] = {} + for x, command in enumerate(commands): + if x != 0: + if command["commandType"] == "aspirate": + labware_id = command["params"]["labwareId"] + labware_name = "" + for labware in json_data.get("labware", {}): + if labware["id"] == labware_id: + labware_name = (labware["loadName"]) + get_labware_name( + labware["id"], json_data["labware"], json_data + ) + well_name = command["params"]["wellName"] + + if labware_id not in labware_well_dict: + labware_well_dict[labware_id] = {} + + if well_name not in labware_well_dict[labware_id]: + labware_well_dict[labware_id][well_name] = (labware_name, 0, 0, "") + + vol = int(command["params"]["volume"]) + + ( + labware_name, + added_volumes, + subtracted_volumes, + log, + ) = labware_well_dict[labware_id][well_name] + + subtracted_volumes += vol + log += f"aspirated {vol} " + labware_well_dict[labware_id][well_name] = ( + labware_name, + added_volumes, + subtracted_volumes, + log, + ) + + elif command["commandType"] == "dispense": + labware_id = command["params"]["labwareId"] + labware_name = "" + for labware in json_data.get("labware", {}): + if labware["id"] == labware_id: + labware_name = (labware["loadName"]) + get_labware_name( + labware["id"], json_data["labware"], json_data + ) + well_name = command["params"]["wellName"] + + if labware_id not in labware_well_dict: + labware_well_dict[labware_id] = {} + + if well_name not in labware_well_dict[labware_id]: + labware_well_dict[labware_id][well_name] = (labware_name, 0, 0, "") + + vol = int(command["params"]["volume"]) + + ( + labware_name, + added_volumes, + subtracted_volumes, + log, + ) = labware_well_dict[labware_id][well_name] + + added_volumes += vol + log += f"dispensed {vol} " + labware_well_dict[labware_id][well_name] = ( + labware_name, + added_volumes, + subtracted_volumes, + log, + ) + return labware_well_dict + + +def parse_results_volume( + json_data_file: str, + protocol_name: str, + file_date: datetime, + file_date_formatted: str, + hellma_plate_standards: List[Any], +) -> Tuple[ + List[str], + List[str], + List[str], + List[str], + List[str], + List[str], + List[str], + List[str], + List[str], + List[str], + List[str], +]: + """Parse run log and extract necessary information.""" + json_data = {} + with open(json_data_file, "r") as json_file: + json_data = json.load(json_file) + if isinstance(json_data, dict): + commands = json_data.get("commands", {}) + else: + print(f"Expected JSON object (dict) but got {type(json_data).__name__}.") + commands = {} + + start_time = datetime.fromisoformat(commands[0]["createdAt"]) + end_time = datetime.fromisoformat(commands[len(commands) - 1]["completedAt"]) + header = ["", "Protocol Name", "Date", "Time"] + header_fill_row = ["", protocol_name, str(file_date.date()), str(file_date.time())] + labware_names_row = ["Labware Name"] + volume_dispensed_row = ["Total Volume Dispensed uL"] + volume_aspirated_row = ["Total Volume Aspirated uL"] + change_in_volume_row = ["Total Change in Volume uL"] + start_time_row = ["Start Time"] + end_time_row = ["End Time"] + total_time_row = ["Total Time of Execution"] + metrics_row = [ + "Metric", + "Heatershaker # of Latch Open/Close", + "Heatershaker # of Homes", + "Heatershaker # of Rotations", + "Heatershaker Temp On Time (sec)", + "Temp Module # of Temp Changes", + "Temp Module Temp On Time (sec)", + "Temp Mod Time to 4C (sec)", + "Thermocycler # of Lid Open/Close", + "Thermocycler Block # of Temp Changes", + "Thermocycler Block Temp On Time (sec)", + "Thermocycler Block Time to 4C (sec)", + "Thermocycler Lid # of Temp Changes", + "Thermocycler Lid Temp On Time (sec)", + "Thermocycler Lid Time to 105C (sec)", + "Plate Reader # of Reads", + "Plate Reader Avg Read Time (sec)", + "Plate Reader # of Initializations", + "Plate Reader Avg Initialize Time (sec)", + "Plate Reader # of Lid Movements", + "Plate Reader Result", + "Left Pipette Total Tip Pick Up(s)", + "Left Pipette Total Aspirates", + "Left Pipette Total Dispenses", + "Right Pipette Total Tip Pick Up(s)", + "Right Pipette Total Aspirates", + "Right Pipette Total Dispenses", + "Gripper Pick Ups", + "Gripper Pick Ups of opentrons_tough_pcr_auto_sealing_lid", + "Total Liquid Probes", + "Average Liquid Probe Time (sec)", + ] + values_row = ["Value"] + ( + hs_dict, + temp_module_dict, + thermo_cycler_dict, + plate_reader_dict, + instrument_dict, + ) = ({}, {}, {}, {}, {}) + try: + hs_dict = read_robot_logs.hs_commands(json_data) + temp_module_dict = read_robot_logs.temperature_module_commands(json_data) + thermo_cycler_dict = read_robot_logs.thermocycler_commands(json_data) + plate_reader_dict = read_robot_logs.plate_reader_commands( + json_data, hellma_plate_standards + ) + instrument_dict = read_robot_logs.instrument_commands( + json_data, labware_name=None + ) + except KeyError: + pass + + metrics = [ + hs_dict, + temp_module_dict, + thermo_cycler_dict, + plate_reader_dict, + instrument_dict, + ] + # Determine liquid moved to and from labware + labware_well_dict = determine_liquid_movement_volumes(commands, json_data) + file_name_to_open = f"{protocol_name}_well_volumes_{file_date_formatted}.json" + with open( + f"{os.path.dirname(json_data_file)}\\{file_name_to_open}", + "w", + ) as output_file: + json.dump(labware_well_dict, output_file) + output_file.close() + + # populate row lists + for labware_id in labware_well_dict.keys(): + volume_added = 0 + volume_subtracted = 0 + labware_name = "" + for well in labware_well_dict[labware_id].keys(): + labware_name, added_volumes, subtracted_volumes, log = labware_well_dict[ + labware_id + ][well] + volume_added += added_volumes + volume_subtracted += subtracted_volumes + labware_names_row.append(labware_name) + volume_dispensed_row.append(str(volume_added)) + volume_aspirated_row.append(str(volume_subtracted)) + change_in_volume_row.append(str(volume_added - volume_subtracted)) + start_time_row.append(str(start_time.time())) + end_time_row.append(str(end_time.time())) + total_time_row.append(str(end_time - start_time)) + + for metric in metrics: + print(f"Dictionary: {metric}\n\n") + for cmd in metric.keys(): + values_row.append(str(metric[cmd])) + return ( + header, + header_fill_row, + labware_names_row, + volume_dispensed_row, + volume_aspirated_row, + change_in_volume_row, + start_time_row, + end_time_row, + total_time_row, + metrics_row, + values_row, + ) + + +def main( + protocol_file_path: Path, + save: bool, + storage_directory: str = os.curdir, + google_sheet_name: str = "", + parameters: List[str] = [], +) -> None: + """Main module control.""" + sys.exit = mock_exit # Replace sys.exit with the mock function + # Read file path from arguments + # protocol_file_path = Path(protocol_file_path_name) + protocol_name = protocol_file_path.stem + print("Simulating", protocol_name) + file_date = datetime.now() + file_date_formatted = file_date.strftime("%Y-%m-%d_%H-%M-%S") + error_output = f"{storage_directory}\\test_debug" + # Run protocol simulation + try: + with Context(analyze) as ctx: + if save: + # Prepare output file + json_file_path = ( + f"{storage_directory}\\{protocol_name}_{file_date_formatted}.json" + ) + json_file_output = open(json_file_path, "wb+") + # log_output_file = f"{protocol_name}_log" + if parameters: + print(f"Parameter: {parameters[0]}\n") + csv_params = {} + csv_params["parameters_csv"] = parameters[0] + rtp_json = json.dumps(csv_params) + ctx.invoke( + analyze, + files=[protocol_file_path], + rtp_files=rtp_json, + json_output=json_file_output, + human_json_output=None, + log_output=error_output, + log_level="ERROR", + check=False, + ) + + else: + ctx.invoke( + analyze, + files=[protocol_file_path], + json_output=json_file_output, + human_json_output=None, + log_output=error_output, + log_level="ERROR", + check=False, + ) + json_file_output.close() + else: + if parameters: + csv_params = {} + csv_params["parameters_csv"] = parameters[0] + rtp_json = json.dumps(csv_params) + ctx.invoke( + analyze, + files=[protocol_file_path], + rtp_files=rtp_json, + json_output=None, + human_json_output=None, + log_output=error_output, + log_level="ERROR", + check=True, + ) + else: + ctx.invoke( + analyze, + files=[protocol_file_path], + json_output=None, + human_json_output=None, + log_output=error_output, + log_level="ERROR", + check=True, + ) + + except SystemExit as e: + print(f"SystemExit caught with code: {e}") + finally: + # Reset sys.exit to the original behavior + sys.exit = original_exit + with open(error_output, "r") as open_file: + try: + errors = open_file.readlines() + if not errors: + pass + else: + print(errors) + sys.exit(1) + except FileNotFoundError: + print("error simulating ...") + sys.exit() + if save: + try: + credentials_path = os.path.join(storage_directory, "credentials.json") + print(credentials_path) + except FileNotFoundError: + print(f"Add credentials.json file to: {storage_directory}.") + sys.exit() + hellma_plate_standards = plate_reader.read_hellma_plate_files( + storage_directory, 101934 + ) + google_sheet = google_sheets_tool.google_sheet( + credentials_path, google_sheet_name, 0 + ) + google_sheet.write_to_row([]) + + for row in parse_results_volume( + json_file_path, + protocol_name, + file_date, + file_date_formatted, + hellma_plate_standards, + ): + print("Writing results to", google_sheet_name) + print(str(row)) + google_sheet.write_to_row(row) + + +if __name__ == "__main__": + CLEAN_PROTOCOL = True + parser = argparse.ArgumentParser(description="Read run logs on google drive.") + parser.add_argument( + "storage_directory", + metavar="STORAGE_DIRECTORY", + type=str, + nargs=1, + help="Path to long term storage directory for run logs.", + ) + parser.add_argument( + "sheet_name", + metavar="SHEET_NAME", + type=str, + nargs=1, + help="Name of sheet to upload results to", + ) + parser.add_argument( + "protocol_file_path", + metavar="PROTOCOL_FILE_PATH", + type=str, + nargs="*", + help="Path to protocol file", + ) + args = parser.parse_args() + storage_directory = args.storage_directory[0] + sheet_name = args.sheet_name[0] + protocol_file_path: str = args.protocol_file_path[0] + parameters: List[str] = args.protocol_file_path[1:] + print(parameters) + SETUP = True + while SETUP: + print( + "Current version cannot handle air gap calls. Simulation results may be inaccurate." + ) + air_gaps = look_for_air_gaps(protocol_file_path) + if air_gaps > 0: + choice = "" + while not choice: + choice = input( + "Remove air_gap commands to ensure accurate results: (continue)? (Y/N): " + ) + if choice.upper() == "Y": + SETUP = False + CLEAN_PROTOCOL = True + elif choice.upper() == "N": + CLEAN_PROTOCOL = False + SETUP = False + print("Please remove air gaps then re-run") + else: + choice = "" + print("Please enter a valid response.") + SETUP = False + + # Change api level + if CLEAN_PROTOCOL: + set_api_level(protocol_file_path) + if parameters: + main( + Path(protocol_file_path), + True, + storage_directory, + sheet_name, + parameters=parameters, + ) + else: + main( + protocol_file_path=Path(protocol_file_path), + save=True, + storage_directory=storage_directory, + google_sheet_name=sheet_name, + ) + else: + sys.exit(0) diff --git a/abr-testing/abr_testing/tools/abr_setup.py b/abr-testing/abr_testing/tools/abr_setup.py new file mode 100644 index 00000000000..67ed5bfb333 --- /dev/null +++ b/abr-testing/abr_testing/tools/abr_setup.py @@ -0,0 +1,154 @@ +"""Automate ABR data collection.""" +import os +import time +import configparser +import traceback +import sys +from hardware_testing.scripts import ABRAsairScript # type: ignore +from abr_testing.data_collection import ( + get_run_logs, + abr_google_drive, + abr_calibration_logs, +) +from abr_testing.tools import sync_abr_sheet + + +def run_sync_abr_sheet( + storage_directory: str, abr_data_sheet: str, room_conditions_sheet: str +) -> None: + """Sync ABR sheet with temp and lifetime percents.""" + sync_abr_sheet.run(storage_directory, abr_data_sheet, room_conditions_sheet) + + +def run_temp_sensor(ip_file: str) -> None: + """Run temperature sensors on all robots.""" + processes = ABRAsairScript.run(ip_file) + for process in processes: + process.start() + time.sleep(20) + for process in processes: + process.join() + + +def get_abr_logs(storage_directory: str, folder_name: str, email: str) -> None: + """Retrieve run logs on all robots and record missing run logs in google drive.""" + try: + get_run_logs.run(storage_directory, folder_name, email) + except Exception as e: + print("Cannot Get Run Logs", e) + traceback.print_exc + + +def record_abr_logs( + storage_directory: str, folder_name: str, google_sheet_name: str, email: str +) -> None: + """Write run logs to ABR run logs in sheets.""" + try: + abr_google_drive.run(storage_directory, folder_name, google_sheet_name, email) + except Exception as e: + print(e) + + +def get_calibration_data( + storage_directory: str, folder_name: str, google_sheet_name: str, email: str +) -> None: + """Download calibration logs and write to ABR-calibration-data in sheets.""" + try: + abr_calibration_logs.run( + storage_directory, folder_name, google_sheet_name, email + ) + except Exception as e: + print("Cannot get calibration data", e) + traceback.print_exc() + + +def main(configurations: configparser.ConfigParser) -> None: + """Main function.""" + ip_file = None + storage_directory = None + email = None + drive_folder = None + sheet_name = None + ambient_conditions_sheet = None + sheet_url = None + + has_defaults = False + # If default is not specified get all values + default = configurations["DEFAULT"] + if len(default) > 0: + has_defaults = True + try: + if has_defaults: + storage_directory = default["Storage"] + email = default["Email"] + drive_folder = default["Drive_Folder"] + sheet_name = default["Sheet_Name"] + sheet_url = default["Sheet_Url"] + except KeyError as e: + print("Cannot read config file\n" + str(e)) + + # Run Temperature Sensors + if not has_defaults: + ip_file = configurations["TEMP-SENSOR"]["Robo_List"] + ambient_conditions_sheet = configurations["TEMP-SENSOR"]["Sheet_Url"] + print("Starting temp sensors...") + if ip_file: + run_temp_sensor(ip_file) + print("Temp Sensors Started") + else: + print("Missing ip_file location, please fix configs") + sys.exit(1) + # Get Run Logs and Record + if not has_defaults: + storage_directory = configurations["RUN-LOG"]["Storage"] + email = configurations["RUN-LOG"]["Email"] + drive_folder = configurations["RUN-LOG"]["Drive_Folder"] + sheet_name = configurations["RUN-LOG"]["Sheet_Name"] + sheet_url = configurations["RUN-LOG"]["Sheet_Url"] + print(sheet_name) + if storage_directory and drive_folder and sheet_name and email: + print("Retrieving robot run logs...") + get_abr_logs(storage_directory, drive_folder, email) + print("Recording robot run logs...") + record_abr_logs(storage_directory, drive_folder, sheet_name, email) + print("Run logs updated") + else: + print("Storage, Email, or Drive Folder is missing, please fix configs") + sys.exit(1) + # Update Google Sheet with missing temp/rh + if storage_directory and sheet_url and ambient_conditions_sheet: + run_sync_abr_sheet(storage_directory, sheet_url, ambient_conditions_sheet) + # Collect calibration data + if not has_defaults: + storage_directory = configurations["CALIBRATION"]["Storage"] + email = configurations["CALIBRATION"]["Email"] + drive_folder = configurations["CALIBRATION"]["Drive_Folder"] + sheet_name = configurations["CALIBRATION"]["Sheet_Name"] + if storage_directory and drive_folder and sheet_name and email: + print("Retrieving and recording robot calibration data...") + get_calibration_data(storage_directory, drive_folder, sheet_name, email) + print("Calibration logs updated") + else: + print( + "Storage, Email, Drive Folder, or Sheet name is missing, please fix configs" + ) + sys.exit(1) + + +if __name__ == "__main__": + configurations = None + configs_file = None + while not configs_file: + configs_file = input("Please enter path to config.ini: ") + if os.path.exists(configs_file): + break + else: + configs_file = None + print("Please enter a valid path") + try: + configurations = configparser.ConfigParser() + configurations.read(configs_file) + except configparser.ParsingError as e: + print("Cannot read configuration file\n" + str(e)) + if configurations: + main(configurations) diff --git a/abr-testing/abr_testing/tools/sync_abr_sheet.py b/abr-testing/abr_testing/tools/sync_abr_sheet.py index aca116292a8..2ae0769dec1 100644 --- a/abr-testing/abr_testing/tools/sync_abr_sheet.py +++ b/abr-testing/abr_testing/tools/sync_abr_sheet.py @@ -7,6 +7,8 @@ import csv import sys import os +import time +import traceback from typing import Dict, Tuple, Any, List from statistics import mean, StatisticsError @@ -27,76 +29,94 @@ def determine_lifetime(abr_google_sheet: Any) -> None: ) # Goes through dataframe per robot for index, run in df_sheet_data.iterrows(): - end_time = run["End_Time"] - robot = run["Robot"] - robot_lifetime = ( - float(run["Robot Lifetime (%)"]) if run["Robot Lifetime (%)"] != "" else 0 + max_retries = 5 + retries = 0 + while retries < max_retries: + try: + update_df(abr_google_sheet, lifetime_index, df_sheet_data, dict(run)) + break + except Exception as e: + if "Quota exceeded for quota metric" in str(e): + retries += 1 + print( + f"Read/write limit reached on attempt: {retries}, pausing then retrying..." + ) + time.sleep(65) + else: + print("unrecoverable error:", e) + traceback.print_exc() + sys.exit(1) + + +def update_df( + abr_google_sheet: Any, lifetime_index: int, df_sheet_data: Any, run: Dict[Any, Any] +) -> None: + """Update google sheets with new run log data.""" + end_time = run["End_Time"] + robot = run["Robot"] + robot_lifetime = ( + float(run["Robot Lifetime (%)"]) if run["Robot Lifetime (%)"] != "" else 0 + ) + if robot_lifetime < 1 and len(run["Run_ID"]) > 1: + # Get Robot % Lifetime + robot_runs_before = df_sheet_data[ + (df_sheet_data["End_Time"] <= end_time) & (df_sheet_data["Robot"] == robot) + ] + robot_percent_lifetime = ( + (robot_runs_before["Run_Time (min)"].sum() / 60) / 3750 * 100 ) - if robot_lifetime < 1 and len(run["Run_ID"]) > 1: - # Get Robot % Lifetime - robot_runs_before = df_sheet_data[ + # Get Left Pipette % Lifetime + left_pipette = run["Left Mount"] + if len(left_pipette) > 1: + left_pipette_runs_before = df_sheet_data[ (df_sheet_data["End_Time"] <= end_time) - & (df_sheet_data["Robot"] == robot) + & ( + (df_sheet_data["Left Mount"] == left_pipette) + | (df_sheet_data["Right Mount"] == left_pipette) + ) ] - robot_percent_lifetime = ( - (robot_runs_before["Run_Time (min)"].sum() / 60) / 3750 * 100 + left_pipette_percent_lifetime = ( + (left_pipette_runs_before["Run_Time (min)"].sum() / 60) / 1248 * 100 ) - # Get Left Pipette % Lifetime - left_pipette = run["Left Mount"] - if len(left_pipette) > 1: - left_pipette_runs_before = df_sheet_data[ - (df_sheet_data["End_Time"] <= end_time) - & ( - (df_sheet_data["Left Mount"] == left_pipette) - | (df_sheet_data["Right Mount"] == left_pipette) - ) - ] - left_pipette_percent_lifetime = ( - (left_pipette_runs_before["Run_Time (min)"].sum() / 60) / 1248 * 100 - ) - else: - left_pipette_percent_lifetime = "" - # Get Right Pipette % Lifetime - right_pipette = run["Right Mount"] - if len(right_pipette) > 1: - right_pipette_runs_before = df_sheet_data[ - (df_sheet_data["End_Time"] <= end_time) - & ( - (df_sheet_data["Left Mount"] == right_pipette) - | (df_sheet_data["Right Mount"] == right_pipette) - ) - ] - right_pipette_percent_lifetime = ( - (right_pipette_runs_before["Run_Time (min)"].sum() / 60) - / 1248 - * 100 - ) - else: - right_pipette_percent_lifetime = "" - # Get Gripper % Lifetime - gripper = run["Extension"] - if len(gripper) > 1: - gripper_runs_before = df_sheet_data[ - (df_sheet_data["End_Time"] <= end_time) - & (df_sheet_data["Extension"] == gripper) - ] - gripper_percent_lifetime = ( - (gripper_runs_before["Run_Time (min)"].sum() / 60) / 3750 * 100 + else: + left_pipette_percent_lifetime = "" + # Get Right Pipette % Lifetime + right_pipette = run["Right Mount"] + if len(right_pipette) > 1: + right_pipette_runs_before = df_sheet_data[ + (df_sheet_data["End_Time"] <= end_time) + & ( + (df_sheet_data["Left Mount"] == right_pipette) + | (df_sheet_data["Right Mount"] == right_pipette) ) - else: - gripper_percent_lifetime = "" - run_id = run["Run_ID"] - row_num = abr_google_sheet.get_row_index_with_value(run_id, 2) - update_list = [ - [robot_percent_lifetime], - [left_pipette_percent_lifetime], - [right_pipette_percent_lifetime], - [gripper_percent_lifetime], ] - abr_google_sheet.batch_update_cells( - update_list, lifetime_index, row_num, "0" + right_pipette_percent_lifetime = ( + (right_pipette_runs_before["Run_Time (min)"].sum() / 60) / 1248 * 100 ) - print(f"Updated row {row_num} for run: {run_id}") + else: + right_pipette_percent_lifetime = "" + # Get Gripper % Lifetime + gripper = run["Extension"] + if len(gripper) > 1: + gripper_runs_before = df_sheet_data[ + (df_sheet_data["End_Time"] <= end_time) + & (df_sheet_data["Extension"] == gripper) + ] + gripper_percent_lifetime = ( + (gripper_runs_before["Run_Time (min)"].sum() / 60) / 3750 * 100 + ) + else: + gripper_percent_lifetime = "" + run_id = run["Run_ID"] + row_num = abr_google_sheet.get_row_index_with_value(run_id, 2) + update_list = [ + [robot_percent_lifetime], + [left_pipette_percent_lifetime], + [right_pipette_percent_lifetime], + [gripper_percent_lifetime], + ] + abr_google_sheet.batch_update_cells(update_list, lifetime_index, row_num, "0") + print(f"Updated row {row_num} for run: {run_id}") def compare_run_to_temp_data( @@ -182,6 +202,30 @@ def connect_and_download( return file_paths, credentials_path +def run( + storage_directory: str, abr_data_sheet_url: str, abr_room_conditions_sheet: str +) -> None: + """Connect to storage and google sheets and update.""" + google_sheets_to_download = { + "ABR-run-data": abr_data_sheet_url, + "ABR Ambient Conditions": abr_room_conditions_sheet, + } + # Download google sheets. + + file_paths, credentials_path = connect_and_download( + google_sheets_to_download, storage_directory + ) + # Read csvs. + abr_data = read_csv_as_dict(file_paths[0]) + temp_data = read_csv_as_dict(file_paths[1]) + # Compare robot and timestamps. + abr_google_sheet = google_sheets_tool.google_sheet( + credentials_path, "ABR-run-data", 0 + ) + determine_lifetime(abr_google_sheet) + compare_run_to_temp_data(abr_data, temp_data, abr_google_sheet) + + if __name__ == "__main__": parser = argparse.ArgumentParser( description="Adds average robot ambient conditions to run sheet." @@ -205,21 +249,7 @@ def connect_and_download( help="Path to long term storage directory for run logs.", ) args = parser.parse_args() - google_sheets_to_download = { - "ABR-run-data": args.abr_data_sheet, - "ABR Ambient Conditions": args.room_conditions_sheet, - } - storage_directory = args.storage_directory - # Download google sheets. - file_paths, credentials_path = connect_and_download( - google_sheets_to_download, storage_directory - ) - # TODO: read csvs. - abr_data = read_csv_as_dict(file_paths[0]) - temp_data = read_csv_as_dict(file_paths[1]) - # TODO: compare robot and timestamps. - abr_google_sheet = google_sheets_tool.google_sheet( - credentials_path, "ABR-run-data", 0 - ) - determine_lifetime(abr_google_sheet) - compare_run_to_temp_data(abr_data, temp_data, abr_google_sheet) + storage_directory = args.storage_directory[0] + abr_data_sheet_url = args.abr_data_sheet[0] + room_conditions_sheet_url = args.room_conditions_sheet[0] + run(storage_directory, abr_data_sheet_url, room_conditions_sheet_url) diff --git a/abr-testing/protocol_simulation/__init__.py b/abr-testing/protocol_simulation/__init__.py deleted file mode 100644 index 157c21fd93e..00000000000 --- a/abr-testing/protocol_simulation/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The package holding code for simulating protocols.""" \ No newline at end of file diff --git a/abr-testing/protocol_simulation/simulation_metrics.py b/abr-testing/protocol_simulation/simulation_metrics.py deleted file mode 100644 index 544bc3fb4bc..00000000000 --- a/abr-testing/protocol_simulation/simulation_metrics.py +++ /dev/null @@ -1,353 +0,0 @@ -import re -import sys -import os -from pathlib import Path -from click import Context -from opentrons.cli import analyze -import json -import argparse -from datetime import datetime -from abr_testing.automation import google_sheets_tool -from abr_testing.data_collection import read_robot_logs -from typing import Set, Dict, Any, Tuple, List, Union -from abr_testing.tools import plate_reader - -def look_for_air_gaps(protocol_file_path: str) -> int: - instances = 0 - try: - with open(protocol_file_path, "r") as open_file: - protocol_lines = open_file.readlines() - for line in protocol_lines: - if "air_gap" in line: - print(line) - instances += 1 - print(f'Found {instances} instance(s) of the air gap function') - open_file.close() - except Exception as error: - print("Error reading protocol:", error.with_traceback()) - return instances - -def set_api_level(protocol_file_path) -> None: - with open(protocol_file_path, "r") as file: - file_contents = file.readlines() - # Look for current'apiLevel:' - for i, line in enumerate(file_contents): - print(line) - if 'apiLevel' in line: - print(f"The current API level of this protocol is: {line}") - change = input("Would you like to simulate with a different API level? (Y/N) ").strip().upper() - - if change == "Y": - api_level = input("Protocol API Level to Simulate with: ") - # Update new API level - file_contents[i] = f'apiLevel: {api_level}\n' - print(f"Updated line: {file_contents[i]}") - break - with open(protocol_file_path, "w") as file: - file.writelines(file_contents) - print("File updated successfully.") - -original_exit = sys.exit - -def mock_exit(code=None) -> None: - print(f"sys.exit() called with code: {code}") - raise SystemExit(code) - -def get_labware_name(id: str, object_dict: dict, json_data: dict) -> str: - slot = "" - for obj in object_dict: - if obj['id'] == id: - try: - # Try to get the slotName from the location - slot = obj['location']['slotName'] - return " SLOT: " + slot - except KeyError: - location = obj.get('location', {}) - - # Check if location contains 'moduleId' - if 'moduleId' in location: - return get_labware_name(location['moduleId'], json_data['modules'], json_data) - - # Check if location contains 'labwareId' - elif 'labwareId' in location: - return get_labware_name(location['labwareId'], json_data['labware'], json_data) - - return " Labware not found" - -def parse_results_volume(json_data_file: str) -> Tuple[ - List[str], List[str], List[str], List[str], - List[str], List[str], List[str], List[str], - List[str], List[str], List[str] - ]: - json_data = [] - with open(json_data_file, "r") as json_file: - json_data = json.load(json_file) - commands = json_data.get("commands", []) - start_time = datetime.fromisoformat(commands[0]["createdAt"]) - end_time = datetime.fromisoformat(commands[len(commands)-1]["completedAt"]) - header = ["", "Protocol Name", "Date", "Time"] - header_fill_row = ["", protocol_name, str(file_date.date()), str(file_date.time())] - labware_names_row =["Labware Name"] - volume_dispensed_row =["Total Volume Dispensed uL"] - volume_aspirated_row =["Total Volume Aspirated uL"] - change_in_volume_row = ["Total Change in Volume uL"] - start_time_row = ["Start Time"] - end_time_row = ["End Time"] - total_time_row = ["Total Time of Execution"] - metrics_row = [ - "Metric", - "Heatershaker # of Latch Open/Close", - "Heatershaker # of Homes", - "Heatershaker # of Rotations", - "Heatershaker Temp On Time (sec)", - "Temp Module # of Temp Changes", - "Temp Module Temp On Time (sec)", - "Temp Mod Time to 4C (sec)", - "Thermocycler # of Lid Open/Close", - "Thermocycler Block # of Temp Changes", - "Thermocycler Block Temp On Time (sec)", - "Thermocycler Block Time to 4C (sec)", - "Thermocycler Lid # of Temp Changes", - "Thermocycler Lid Temp On Time (sec)", - "Thermocycler Lid Time to 105C (sec)", - "Plate Reader # of Reads", - "Plate Reader Avg Read Time (sec)", - "Plate Reader # of Initializations", - "Plate Reader Avg Initialize Time (sec)", - "Plate Reader # of Lid Movements", - "Plate Reader Result", - "Left Pipette Total Tip Pick Up(s)", - "Left Pipette Total Aspirates", - "Left Pipette Total Dispenses", - "Right Pipette Total Tip Pick Up(s)", - "Right Pipette Total Aspirates", - "Right Pipette Total Dispenses", - "Gripper Pick Ups", - "Total Liquid Probes", - "Average Liquid Probe Time (sec)", - ] - values_row = ["Value"] - labware_well_dict = {} - hs_dict, temp_module_dict, thermo_cycler_dict, plate_reader_dict, instrument_dict = {}, {}, {}, {}, {} - try: - hs_dict = read_robot_logs.hs_commands(json_data) - temp_module_dict = read_robot_logs.temperature_module_commands(json_data) - thermo_cycler_dict = read_robot_logs.thermocycler_commands(json_data) - plate_reader_dict = read_robot_logs.plate_reader_commands(json_data, hellma_plate_standards) - instrument_dict = read_robot_logs.instrument_commands(json_data) - except: - pass - - metrics = [hs_dict, temp_module_dict, thermo_cycler_dict, plate_reader_dict, instrument_dict] - - # Iterate through all the commands executed in the protocol run log - for x, command in enumerate(commands): - if x != 0: - prev_command = commands[x-1] - if command["commandType"] == "aspirate": - if not (prev_command["commandType"] == "comment" and (prev_command['params']['message'] == "AIR GAP" or prev_command['params']['message'] == "MIXING")): - labware_id = command["params"]["labwareId"] - labware_name = "" - for labware in json_data.get("labware"): - if labware["id"] == labware_id: - labware_name = (labware["loadName"]) + get_labware_name(labware["id"], json_data["labware"], json_data) - well_name = command["params"]["wellName"] - - if labware_id not in labware_well_dict: - labware_well_dict[labware_id] = {} - - if well_name not in labware_well_dict[labware_id]: - labware_well_dict[labware_id][well_name] = (labware_name, 0, 0, "") - - vol = int(command["params"]["volume"]) - - labware_name, added_volumes, subtracted_volumes, log = labware_well_dict[labware_id][well_name] - - subtracted_volumes += vol - log+=(f"aspirated {vol} ") - labware_well_dict[labware_id][well_name] = (labware_name, added_volumes, subtracted_volumes, log) - elif command["commandType"] == "dispense": - if not (prev_command["commandType"] == "comment" and (prev_command['params']['message'] == "MIXING")): - labware_id = command["params"]["labwareId"] - labware_name = "" - for labware in json_data.get("labware"): - if labware["id"] == labware_id: - labware_name = (labware["loadName"]) + get_labware_name(labware["id"], json_data["labware"], json_data) - well_name = command["params"]["wellName"] - - if labware_id not in labware_well_dict: - labware_well_dict[labware_id] = {} - - if well_name not in labware_well_dict[labware_id]: - labware_well_dict[labware_id][well_name] = (labware_name, 0, 0, "") - - vol = int(command["params"]["volume"]) - labware_name, added_volumes, subtracted_volumes, log = labware_well_dict[labware_id][well_name] - added_volumes += vol - log+=(f"dispensed {vol} ") - labware_well_dict[labware_id][well_name] = (labware_name, added_volumes, subtracted_volumes, log) - # file_date_formatted = file_date.strftime("%Y-%m-%d_%H-%M-%S") - with open(f"{os.path.dirname(json_data_file)}\\{protocol_name}_well_volumes_{file_date_formatted}.json", "w") as output_file: - json.dump(labware_well_dict, output_file) - output_file.close() - - # populate row lists - for labware_id in labware_well_dict.keys(): - volume_added = 0 - volume_subtracted = 0 - labware_name ="" - for well in labware_well_dict[labware_id].keys(): - labware_name, added_volumes, subtracted_volumes, log = labware_well_dict[labware_id][well] - volume_added += added_volumes - volume_subtracted += subtracted_volumes - labware_names_row.append(labware_name) - volume_dispensed_row.append(str(volume_added)) - volume_aspirated_row.append(str(volume_subtracted)) - change_in_volume_row.append(str(volume_added - volume_subtracted)) - start_time_row.append(str(start_time.time())) - end_time_row.append(str(end_time.time())) - total_time_row.append(str(end_time - start_time)) - - for metric in metrics: - for cmd in metric.keys(): - values_row.append(str(metric[cmd])) - return( - header, - header_fill_row, - labware_names_row, - volume_dispensed_row, - volume_aspirated_row, - change_in_volume_row, - start_time_row, - end_time_row, - total_time_row, - metrics_row, - values_row) - -def main(storage_directory, google_sheet_name, protocol_file_path): - sys.exit = mock_exit - - # Read file path from arguments - protocol_file_path = Path(protocol_file_path) - global protocol_name - protocol_name = protocol_file_path.stem - print("Simulating", protocol_name) - global file_date - file_date = datetime.now() - global file_date_formatted - file_date_formatted = file_date.strftime("%Y-%m-%d_%H-%M-%S") - # Prepare output file - json_file_path = f"{storage_directory}\\{protocol_name}_{file_date_formatted}.json" - json_file_output = open(json_file_path, "wb+") - error_output = f"{storage_directory}\\error_log" - # Run protocol simulation - try: - with Context(analyze) as ctx: - ctx.invoke( - analyze, - files=[protocol_file_path], - json_output=json_file_output, - human_json_output=None, - log_output=error_output, - log_level="ERROR", - check=False - ) - except SystemExit as e: - print(f"SystemExit caught with code: {e}") - finally: - sys.exit = original_exit - json_file_output.close() - with open(error_output, "r") as open_file: - try: - errors = open_file.readlines() - if not errors: pass - else: - print(errors) - sys.exit(1) - except: - print("error simulating ...") - sys.exit() - - try: - credentials_path = os.path.join(storage_directory, "credentials.json") - print(credentials_path) - except FileNotFoundError: - print(f"Add credentials.json file to: {storage_directory}.") - sys.exit() - - global hellma_plate_standards - - try: - hellma_plate_standards = plate_reader.read_hellma_plate_files(storage_directory, 101934) - except: - print(f"Add helma plate standard files to {storage_directory}.") - sys.exit() - - google_sheet = google_sheets_tool.google_sheet( - credentials_path, google_sheet_name, 0 - ) - - google_sheet.write_to_row([]) - - for row in parse_results_volume(json_file_path): - print("Writing results to", google_sheet_name) - print(str(row)) - google_sheet.write_to_row(row) - -if __name__ == "__main__": - CLEAN_PROTOCOL = True - parser = argparse.ArgumentParser(description="Read run logs on google drive.") - parser.add_argument( - "storage_directory", - metavar="STORAGE_DIRECTORY", - type=str, - nargs=1, - help="Path to long term storage directory for run logs.", - ) - parser.add_argument( - "sheet_name", - metavar="SHEETNAME", - type=str, - nargs=1, - help="Name of sheet to upload results to", - ) - parser.add_argument( - "protocol_file_path", - metavar="PROTOCOL_FILE_PATH", - type=str, - nargs=1, - help="Path to protocol file" - - ) - args = parser.parse_args() - storage_directory = args.storage_directory[0] - sheet_name = args.sheet_name[0] - protocol_file_path = args.protocol_file_path[0] - - SETUP = True - while(SETUP): - print("This current version cannot properly handle air gap calls.\nThese may cause simulation results to be inaccurate") - air_gaps = look_for_air_gaps(protocol_file_path) - if air_gaps > 0: - choice = "" - while not choice: - choice = input("This protocol contains air gaps, results may be innacurate, would you like to continue? (Y/N): ") - if choice.upper() == "Y": - SETUP = False - CLEAN_PROTOCOL = True - elif choice.upper() == "N": - CLEAN_PROTOCOL = False - SETUP = False - print("Please remove air gaps then re-run") - else: - choice = "" - print("Please enter a valid response.") - SETUP = False - - if CLEAN_PROTOCOL: - main( - storage_directory, - sheet_name, - protocol_file_path, - ) - else: sys.exit(0) \ No newline at end of file diff --git a/analyses-snapshot-testing/citools/generate_analyses.py b/analyses-snapshot-testing/citools/generate_analyses.py index 7e4aa2281ea..7d550b47776 100644 --- a/analyses-snapshot-testing/citools/generate_analyses.py +++ b/analyses-snapshot-testing/citools/generate_analyses.py @@ -186,6 +186,7 @@ def analyze(protocol: TargetProtocol, container: docker.models.containers.Contai start_time = time.time() result = None exit_code = None + console.print(f"Beginning analysis of {protocol.host_protocol_file.name}") try: command_result = container.exec_run(cmd=command) exit_code = command_result.exit_code diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[057de2035d][OT2_S_v2_19_P300M_P20S_HS_TC_TM_SmokeTestV3].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[057de2035d][OT2_S_v2_19_P300M_P20S_HS_TC_TM_SmokeTestV3].json index a73a19e4c88..a2aca7e252a 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[057de2035d][OT2_S_v2_19_P300M_P20S_HS_TC_TM_SmokeTestV3].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[057de2035d][OT2_S_v2_19_P300M_P20S_HS_TC_TM_SmokeTestV3].json @@ -9383,7 +9383,14 @@ }, "id": "UUID", "key": "08e16a2cac011d4bef561f8b0854d19e", - "notes": [], + "notes": [ + { + "longMessage": "Handling this command failure with FAIL_RUN.", + "noteKind": "debugErrorRecovery", + "shortMessage": "Handling this command failure with FAIL_RUN.", + "source": "execution" + } + ], "params": { "displayName": "4 custom tubes", "loadName": "cpx_4_tuberack_100ul", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[0cbde10c37][OT2_S_v2_18_P300M_P20S_HS_TC_TM_SmokeTestV3].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[0cbde10c37][OT2_S_v2_18_P300M_P20S_HS_TC_TM_SmokeTestV3].json index 56d23e7468e..e4924262e1a 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[0cbde10c37][OT2_S_v2_18_P300M_P20S_HS_TC_TM_SmokeTestV3].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[0cbde10c37][OT2_S_v2_18_P300M_P20S_HS_TC_TM_SmokeTestV3].json @@ -9383,7 +9383,14 @@ }, "id": "UUID", "key": "08e16a2cac011d4bef561f8b0854d19e", - "notes": [], + "notes": [ + { + "longMessage": "Handling this command failure with FAIL_RUN.", + "noteKind": "debugErrorRecovery", + "shortMessage": "Handling this command failure with FAIL_RUN.", + "source": "execution" + } + ], "params": { "displayName": "4 custom tubes", "loadName": "cpx_4_tuberack_100ul", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[134037b2aa][OT2_X_v6_P300M_P20S_HS_MM_TM_TC_AllMods].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[134037b2aa][OT2_X_v6_P300M_P20S_HS_MM_TM_TC_AllMods].json index 0ba775ce4cb..f2c63721b33 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[134037b2aa][OT2_X_v6_P300M_P20S_HS_MM_TM_TC_AllMods].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[134037b2aa][OT2_X_v6_P300M_P20S_HS_MM_TM_TC_AllMods].json @@ -7895,7 +7895,14 @@ }, "id": "UUID", "key": "675eb8be-6c85-4204-9c87-d7fdd522f580", - "notes": [], + "notes": [ + { + "longMessage": "Handling this command failure with FAIL_RUN.", + "noteKind": "debugErrorRecovery", + "shortMessage": "Handling this command failure with FAIL_RUN.", + "source": "execution" + } + ], "params": { "moduleId": "UUID" }, diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[13ec753045][Flex_S_v2_18_Illumina_Stranded_total_RNA_Ribo_Zero].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[13ec753045][Flex_S_v2_18_Illumina_Stranded_total_RNA_Ribo_Zero].json index 99501a91cd3..0b2e524dee6 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[13ec753045][Flex_S_v2_18_Illumina_Stranded_total_RNA_Ribo_Zero].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[13ec753045][Flex_S_v2_18_Illumina_Stranded_total_RNA_Ribo_Zero].json @@ -4341,7 +4341,14 @@ }, "id": "UUID", "key": "bccdb28e967f574dfbe472004101d7f9", - "notes": [], + "notes": [ + { + "longMessage": "Handling this command failure with FAIL_RUN.", + "noteKind": "debugErrorRecovery", + "shortMessage": "Handling this command failure with FAIL_RUN.", + "source": "execution" + } + ], "params": { "displayName": "Index Anchors", "loadName": "eppendorf_96_wellplate_150ul", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[19c783e363][Flex_X_v8_P1000_96_HS_GRIP_TC_TM_GripperCollisionWithTips].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[19c783e363][Flex_X_v8_P1000_96_HS_GRIP_TC_TM_GripperCollisionWithTips].json index 9f6db189f50..b7c2e8d8c6a 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[19c783e363][Flex_X_v8_P1000_96_HS_GRIP_TC_TM_GripperCollisionWithTips].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[19c783e363][Flex_X_v8_P1000_96_HS_GRIP_TC_TM_GripperCollisionWithTips].json @@ -12587,7 +12587,14 @@ }, "id": "UUID", "key": "702caca4-12c8-4f26-a68e-138134723f09", - "notes": [], + "notes": [ + { + "longMessage": "Handling this command failure with FAIL_RUN.", + "noteKind": "debugErrorRecovery", + "shortMessage": "Handling this command failure with FAIL_RUN.", + "source": "execution" + } + ], "params": { "labwareId": "UUID", "newLocation": { diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[4389e3ab18][OT2_X_v6_P20S_None_SimpleTransfer].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[4389e3ab18][OT2_X_v6_P20S_None_SimpleTransfer].json index 77e30bb1865..351c26b64b4 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[4389e3ab18][OT2_X_v6_P20S_None_SimpleTransfer].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[4389e3ab18][OT2_X_v6_P20S_None_SimpleTransfer].json @@ -1772,7 +1772,14 @@ }, "id": "UUID", "key": "c2e4fa67-3c04-4d22-b3fa-5d61e956c488", - "notes": [], + "notes": [ + { + "longMessage": "Handling this command failure with FAIL_RUN.", + "noteKind": "debugErrorRecovery", + "shortMessage": "Handling this command failure with FAIL_RUN.", + "source": "execution" + } + ], "params": { "flowRate": 3.78, "labwareId": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[4b17883f74][OT2_S_v2_17_P300M_P20S_HS_TC_TM_dispense_changes].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[4b17883f74][OT2_S_v2_17_P300M_P20S_HS_TC_TM_dispense_changes].json index 8986f5e49cb..5af5922dada 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[4b17883f74][OT2_S_v2_17_P300M_P20S_HS_TC_TM_dispense_changes].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[4b17883f74][OT2_S_v2_17_P300M_P20S_HS_TC_TM_dispense_changes].json @@ -3035,7 +3035,14 @@ }, "id": "UUID", "key": "0bd3f36a944ee534e422ee69360a9501", - "notes": [], + "notes": [ + { + "longMessage": "Handling this command failure with FAIL_RUN.", + "noteKind": "debugErrorRecovery", + "shortMessage": "Handling this command failure with FAIL_RUN.", + "source": "execution" + } + ], "params": { "flowRate": 7.56, "labwareId": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[4cb705bdbf][Flex_X_v2_16_NO_PIPETTES_MM_MagneticModuleInFlexProtocol].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[4cb705bdbf][Flex_X_v2_16_NO_PIPETTES_MM_MagneticModuleInFlexProtocol].json index 60a3d0150e4..d810bd75c88 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[4cb705bdbf][Flex_X_v2_16_NO_PIPETTES_MM_MagneticModuleInFlexProtocol].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[4cb705bdbf][Flex_X_v2_16_NO_PIPETTES_MM_MagneticModuleInFlexProtocol].json @@ -32,7 +32,14 @@ }, "id": "UUID", "key": "8511b05ba5565bf0e6dcccd800e2ee23", - "notes": [], + "notes": [ + { + "longMessage": "Handling this command failure with FAIL_RUN.", + "noteKind": "debugErrorRecovery", + "shortMessage": "Handling this command failure with FAIL_RUN.", + "source": "execution" + } + ], "params": { "location": { "slotName": "C1" diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[53db9bf516][Flex_X_v2_20_96_and_8_Overrides_InvalidConfigs_Override_eight_partial_column_bottom_left].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[53db9bf516][Flex_X_v2_20_96_and_8_Overrides_InvalidConfigs_Override_eight_partial_column_bottom_left].json index 662d6cf0c4b..0aaa562c15c 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[53db9bf516][Flex_X_v2_20_96_and_8_Overrides_InvalidConfigs_Override_eight_partial_column_bottom_left].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[53db9bf516][Flex_X_v2_20_96_and_8_Overrides_InvalidConfigs_Override_eight_partial_column_bottom_left].json @@ -1205,7 +1205,14 @@ }, "id": "UUID", "key": "2c37ad797da7df791b57a7843a203e88", - "notes": [], + "notes": [ + { + "longMessage": "Handling this command failure with FAIL_RUN.", + "noteKind": "debugErrorRecovery", + "shortMessage": "Handling this command failure with FAIL_RUN.", + "source": "execution" + } + ], "params": { "configurationParams": { "backLeftNozzle": "A1", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[5e958b7c98][Flex_X_v2_16_P300MGen2_None_OT2PipetteInFlexProtocol].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[5e958b7c98][Flex_X_v2_16_P300MGen2_None_OT2PipetteInFlexProtocol].json index 75ea09b454d..c76b2aca7f9 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[5e958b7c98][Flex_X_v2_16_P300MGen2_None_OT2PipetteInFlexProtocol].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[5e958b7c98][Flex_X_v2_16_P300MGen2_None_OT2PipetteInFlexProtocol].json @@ -1181,7 +1181,14 @@ }, "id": "UUID", "key": "bd403a1c851a75b4b68ce34796d713fa", - "notes": [], + "notes": [ + { + "longMessage": "Handling this command failure with FAIL_RUN.", + "noteKind": "debugErrorRecovery", + "shortMessage": "Handling this command failure with FAIL_RUN.", + "source": "execution" + } + ], "params": { "liquidPresenceDetection": false, "mount": "left", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[604023f7f1][Flex_X_v2_16_NO_PIPETTES_TM_ModuleInStagingAreaCol3].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[604023f7f1][Flex_X_v2_16_NO_PIPETTES_TM_ModuleInStagingAreaCol3].json index 24e88e5454e..0de0eff0022 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[604023f7f1][Flex_X_v2_16_NO_PIPETTES_TM_ModuleInStagingAreaCol3].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[604023f7f1][Flex_X_v2_16_NO_PIPETTES_TM_ModuleInStagingAreaCol3].json @@ -121,7 +121,14 @@ }, "id": "UUID", "key": "a3a7eed460d8d94a91747f23820a180d", - "notes": [], + "notes": [ + { + "longMessage": "Handling this command failure with FAIL_RUN.", + "noteKind": "debugErrorRecovery", + "shortMessage": "Handling this command failure with FAIL_RUN.", + "source": "execution" + } + ], "params": { "location": { "slotName": "C3" diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[93b724671e][OT2_X_v2_20_8_Overrides_InvalidConfigs_Override_eight_partial_column_bottom_right].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[93b724671e][OT2_X_v2_20_8_Overrides_InvalidConfigs_Override_eight_partial_column_bottom_right].json index a59e4a3176f..2c3d142321b 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[93b724671e][OT2_X_v2_20_8_Overrides_InvalidConfigs_Override_eight_partial_column_bottom_right].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[93b724671e][OT2_X_v2_20_8_Overrides_InvalidConfigs_Override_eight_partial_column_bottom_right].json @@ -2366,7 +2366,14 @@ }, "id": "UUID", "key": "4b1d27a6f17f312dd76668f0c48ed406", - "notes": [], + "notes": [ + { + "longMessage": "Handling this command failure with FAIL_RUN.", + "noteKind": "debugErrorRecovery", + "shortMessage": "Handling this command failure with FAIL_RUN.", + "source": "execution" + } + ], "params": { "configurationParams": { "backLeftNozzle": "G12", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[9e56ee92f6][Flex_X_v2_16_P1000_96_GRIP_DropLabwareIntoTrashBin].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[9e56ee92f6][Flex_X_v2_16_P1000_96_GRIP_DropLabwareIntoTrashBin].json index eedcd721687..8d4e3a960dd 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[9e56ee92f6][Flex_X_v2_16_P1000_96_GRIP_DropLabwareIntoTrashBin].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[9e56ee92f6][Flex_X_v2_16_P1000_96_GRIP_DropLabwareIntoTrashBin].json @@ -1350,7 +1350,14 @@ }, "id": "UUID", "key": "4cca9753dc59d176eee1522349363a75", - "notes": [], + "notes": [ + { + "longMessage": "Handling this command failure with FAIL_RUN.", + "noteKind": "debugErrorRecovery", + "shortMessage": "Handling this command failure with FAIL_RUN.", + "source": "execution" + } + ], "params": { "labwareId": "UUID", "newLocation": { diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[ac886d7768][Flex_S_v2_15_P1000_96_GRIP_HS_MB_TC_TM_IDTXgen96Part1to3].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[ac886d7768][Flex_S_v2_15_P1000_96_GRIP_HS_MB_TC_TM_IDTXgen96Part1to3].json index 25cba8c59b8..ef9acd1b1a3 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[ac886d7768][Flex_S_v2_15_P1000_96_GRIP_HS_MB_TC_TM_IDTXgen96Part1to3].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[ac886d7768][Flex_S_v2_15_P1000_96_GRIP_HS_MB_TC_TM_IDTXgen96Part1to3].json @@ -15103,7 +15103,14 @@ }, "id": "UUID", "key": "c3eacf39e9a35058cac9f69100549344", - "notes": [], + "notes": [ + { + "longMessage": "Handling this command failure with FAIL_RUN.", + "noteKind": "debugErrorRecovery", + "shortMessage": "Handling this command failure with FAIL_RUN.", + "source": "execution" + } + ], "params": { "forceDirect": false, "labwareId": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[acefe91275][OT2_X_v2_20_8_Overrides_InvalidConfigs_Override_eight_partial_column_bottom_left].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[acefe91275][OT2_X_v2_20_8_Overrides_InvalidConfigs_Override_eight_partial_column_bottom_left].json index d70c634dcc6..c8389b97d75 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[acefe91275][OT2_X_v2_20_8_Overrides_InvalidConfigs_Override_eight_partial_column_bottom_left].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[acefe91275][OT2_X_v2_20_8_Overrides_InvalidConfigs_Override_eight_partial_column_bottom_left].json @@ -2351,7 +2351,14 @@ }, "id": "UUID", "key": "4b1d27a6f17f312dd76668f0c48ed406", - "notes": [], + "notes": [ + { + "longMessage": "Handling this command failure with FAIL_RUN.", + "noteKind": "debugErrorRecovery", + "shortMessage": "Handling this command failure with FAIL_RUN.", + "source": "execution" + } + ], "params": { "configurationParams": { "backLeftNozzle": "A1", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[b777168ac1][Flex_S_v2_19_Illumina_Stranded_total_RNA_Ribo_Zero].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[b777168ac1][Flex_S_v2_19_Illumina_Stranded_total_RNA_Ribo_Zero].json index e542e8191b2..7005e6011ab 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[b777168ac1][Flex_S_v2_19_Illumina_Stranded_total_RNA_Ribo_Zero].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[b777168ac1][Flex_S_v2_19_Illumina_Stranded_total_RNA_Ribo_Zero].json @@ -4341,7 +4341,14 @@ }, "id": "UUID", "key": "bccdb28e967f574dfbe472004101d7f9", - "notes": [], + "notes": [ + { + "longMessage": "Handling this command failure with FAIL_RUN.", + "noteKind": "debugErrorRecovery", + "shortMessage": "Handling this command failure with FAIL_RUN.", + "source": "execution" + } + ], "params": { "displayName": "Index Anchors", "loadName": "eppendorf_96_wellplate_150ul", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[d0154b1493][Flex_X_v2_20_96_and_8_Overrides_InvalidConfigs_Override_eight_partial_column_bottom_right].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[d0154b1493][Flex_X_v2_20_96_and_8_Overrides_InvalidConfigs_Override_eight_partial_column_bottom_right].json index 13e42d0bd8b..1149640d8b1 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[d0154b1493][Flex_X_v2_20_96_and_8_Overrides_InvalidConfigs_Override_eight_partial_column_bottom_right].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[d0154b1493][Flex_X_v2_20_96_and_8_Overrides_InvalidConfigs_Override_eight_partial_column_bottom_right].json @@ -1220,7 +1220,14 @@ }, "id": "UUID", "key": "2c37ad797da7df791b57a7843a203e88", - "notes": [], + "notes": [ + { + "longMessage": "Handling this command failure with FAIL_RUN.", + "noteKind": "debugErrorRecovery", + "shortMessage": "Handling this command failure with FAIL_RUN.", + "source": "execution" + } + ], "params": { "configurationParams": { "backLeftNozzle": "G12", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[e0b0133ffb][pl_Illumina_Stranded_total_RNA_Ribo_Zero_protocol].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[e0b0133ffb][pl_Illumina_Stranded_total_RNA_Ribo_Zero_protocol].json index 2de55429a53..b22e56cb8ed 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[e0b0133ffb][pl_Illumina_Stranded_total_RNA_Ribo_Zero_protocol].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[e0b0133ffb][pl_Illumina_Stranded_total_RNA_Ribo_Zero_protocol].json @@ -39047,7 +39047,14 @@ }, "id": "UUID", "key": "f524340032354f66bf69110d530e98ad", - "notes": [], + "notes": [ + { + "longMessage": "Handling this command failure with FAIL_RUN.", + "noteKind": "debugErrorRecovery", + "shortMessage": "Handling this command failure with FAIL_RUN.", + "source": "execution" + } + ], "params": { "labwareId": "UUID", "newLocation": { diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[ed1e64c539][Flex_X_v2_16_NO_PIPETTES_TM_ModuleInCol2].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[ed1e64c539][Flex_X_v2_16_NO_PIPETTES_TM_ModuleInCol2].json index 5c1d9a41364..368bbe05d9b 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[ed1e64c539][Flex_X_v2_16_NO_PIPETTES_TM_ModuleInCol2].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[ed1e64c539][Flex_X_v2_16_NO_PIPETTES_TM_ModuleInCol2].json @@ -32,7 +32,14 @@ }, "id": "UUID", "key": "8511b05ba5565bf0e6dcccd800e2ee23", - "notes": [], + "notes": [ + { + "longMessage": "Handling this command failure with FAIL_RUN.", + "noteKind": "debugErrorRecovery", + "shortMessage": "Handling this command failure with FAIL_RUN.", + "source": "execution" + } + ], "params": { "location": { "slotName": "C2" diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[f24bb0b4d9][Flex_S_v2_15_P1000_96_GRIP_HS_MB_TC_TM_IlluminaDNAPrep96PART3].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[f24bb0b4d9][Flex_S_v2_15_P1000_96_GRIP_HS_MB_TC_TM_IlluminaDNAPrep96PART3].json index 6e5b9d8028b..d1feceae4d0 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[f24bb0b4d9][Flex_S_v2_15_P1000_96_GRIP_HS_MB_TC_TM_IlluminaDNAPrep96PART3].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[f24bb0b4d9][Flex_S_v2_15_P1000_96_GRIP_HS_MB_TC_TM_IlluminaDNAPrep96PART3].json @@ -17824,7 +17824,14 @@ }, "id": "UUID", "key": "7e96139ed2163fa7f870805d0b3d14b6", - "notes": [], + "notes": [ + { + "longMessage": "Handling this command failure with FAIL_RUN.", + "noteKind": "debugErrorRecovery", + "shortMessage": "Handling this command failure with FAIL_RUN.", + "source": "execution" + } + ], "params": { "forceDirect": false, "labwareId": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[f5f3b9c5bb][Flex_X_v2_16_P1000_96_TM_ModuleAndWasteChuteConflict].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[f5f3b9c5bb][Flex_X_v2_16_P1000_96_TM_ModuleAndWasteChuteConflict].json index 9fbcd62f394..d452cf7ab52 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[f5f3b9c5bb][Flex_X_v2_16_P1000_96_TM_ModuleAndWasteChuteConflict].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[f5f3b9c5bb][Flex_X_v2_16_P1000_96_TM_ModuleAndWasteChuteConflict].json @@ -1255,7 +1255,14 @@ }, "id": "UUID", "key": "c55807b45b6b1d4ea04e12b0ee553f78", - "notes": [], + "notes": [ + { + "longMessage": "Handling this command failure with FAIL_RUN.", + "noteKind": "debugErrorRecovery", + "shortMessage": "Handling this command failure with FAIL_RUN.", + "source": "execution" + } + ], "params": { "location": { "slotName": "D3" diff --git a/api-client/src/runs/types.ts b/api-client/src/runs/types.ts index 0415367f1e6..621443dce03 100644 --- a/api-client/src/runs/types.ts +++ b/api-client/src/runs/types.ts @@ -8,6 +8,7 @@ import type { RunTimeCommand, RunTimeParameter, NozzleLayoutConfig, + OnDeckLabwareLocation, } from '@opentrons/shared-data' import type { ResourceLink, ErrorDetails } from '../types' export * from './commands/types' @@ -60,6 +61,7 @@ export interface LegacyGoodRunData { export interface KnownGoodRunData extends LegacyGoodRunData { ok: true runTimeParameters: RunTimeParameter[] + outputFileIds: string[] } export interface KnownInvalidRunData extends LegacyGoodRunData { @@ -98,7 +100,7 @@ export interface RunsLinks { } export interface RunCommandLink { - current: CommandLinkNoMeta + lastCompleted: CommandLinkNoMeta } export interface CommandLinkNoMeta { @@ -111,12 +113,14 @@ export interface GetRunsParams { } export interface Runs { - data: RunData[] + data: readonly RunData[] links: RunsLinks } export interface RunCurrentStateData { + estopEngaged: boolean activeNozzleLayouts: Record // keyed by pipetteId + placeLabwareState?: PlaceLabwareState } export const RUN_ACTION_TYPE_PLAY: 'play' = 'play' @@ -124,12 +128,15 @@ export const RUN_ACTION_TYPE_PAUSE: 'pause' = 'pause' export const RUN_ACTION_TYPE_STOP: 'stop' = 'stop' export const RUN_ACTION_TYPE_RESUME_FROM_RECOVERY: 'resume-from-recovery' = 'resume-from-recovery' +export const RUN_ACTION_TYPE_RESUME_FROM_RECOVERY_ASSUMING_FALSE_POSITIVE: 'resume-from-recovery-assuming-false-positive' = + 'resume-from-recovery-assuming-false-positive' export type RunActionType = | typeof RUN_ACTION_TYPE_PLAY | typeof RUN_ACTION_TYPE_PAUSE | typeof RUN_ACTION_TYPE_STOP | typeof RUN_ACTION_TYPE_RESUME_FROM_RECOVERY + | typeof RUN_ACTION_TYPE_RESUME_FROM_RECOVERY_ASSUMING_FALSE_POSITIVE export interface RunAction { id: string @@ -171,7 +178,11 @@ export type RunError = RunCommandError * Error Policy */ -export type IfMatchType = 'ignoreAndContinue' | 'failRun' | 'waitForRecovery' +export type IfMatchType = + | 'assumeFalsePositiveAndContinue' + | 'ignoreAndContinue' + | 'failRun' + | 'waitForRecovery' export interface ErrorRecoveryPolicy { policyRules: Array<{ @@ -201,3 +212,9 @@ export interface NozzleLayoutValues { activeNozzles: string[] config: NozzleLayoutConfig } + +export interface PlaceLabwareState { + labwareId: string + location: OnDeckLabwareLocation + shouldPlaceDown: boolean +} diff --git a/api/docs/v2/new_examples.rst b/api/docs/v2/new_examples.rst index 28490e03135..1aae3b633d0 100644 --- a/api/docs/v2/new_examples.rst +++ b/api/docs/v2/new_examples.rst @@ -383,7 +383,7 @@ Opentrons electronic pipettes can do some things that a human cannot do with a p location=3) p300 = protocol.load_instrument( instrument_name="p300_single", - mount="right", + mount="left", tip_racks=[tiprack_1]) p300.pick_up_tip() @@ -442,13 +442,13 @@ This protocol dispenses diluent to all wells of a Corning 96-well plate. Next, i source = reservoir.wells()[i] row = plate.rows()[i] - # transfer 30 µL of source to first well in column - pipette.transfer(30, source, row[0], mix_after=(3, 25)) + # transfer 30 µL of source to first well in column + pipette.transfer(30, source, row[0], mix_after=(3, 25)) - # dilute the sample down the column - pipette.transfer( - 30, row[:11], row[1:], - mix_after=(3, 25)) + # dilute the sample down the column + pipette.transfer( + 30, row[:11], row[1:], + mix_after=(3, 25)) .. tab:: OT-2 @@ -474,7 +474,7 @@ This protocol dispenses diluent to all wells of a Corning 96-well plate. Next, i location=4) p300 = protocol.load_instrument( instrument_name="p300_single", - mount="right", + mount="left", tip_racks=[tiprack_1, tiprack_2]) # Dispense diluent p300.distribute(50, reservoir["A12"], plate.wells()) @@ -483,16 +483,15 @@ This protocol dispenses diluent to all wells of a Corning 96-well plate. Next, i for i in range(8): # save the source well and destination column to variables source = reservoir.wells()[i] - source = reservoir.wells()[i] row = plate.rows()[i] - # transfer 30 µL of source to first well in column - p300.transfer(30, source, row[0], mix_after=(3, 25)) + # transfer 30 µL of source to first well in column + p300.transfer(30, source, row[0], mix_after=(3, 25)) - # dilute the sample down the column - p300.transfer( - 30, row[:11], row[1:], - mix_after=(3, 25)) + # dilute the sample down the column + p300.transfer( + 30, row[:11], row[1:], + mix_after=(3, 25)) Notice here how the code sample loops through the rows and uses slicing to distribute the diluent. For information about these features, see the Loops and Air Gaps examples above. See also, the :ref:`tutorial-commands` section of the Tutorial. diff --git a/api/release-notes.md b/api/release-notes.md index 2a528502bef..e41d415d83e 100644 --- a/api/release-notes.md +++ b/api/release-notes.md @@ -8,6 +8,24 @@ By installing and using Opentrons software, you agree to the Opentrons End-User --- +## Opentrons Robot Software Changes in 8.2.0 + +Welcome to the v8.2.0 release of the Opentrons robot software! This release adds support for the Opentrons Absorbance Plate Reader Module. + +### New Features + +- Create and run Python protocols that use the Opentrons Absorbance Plate Reader. + +### Improved Features + +- Liquid presence detection no longer checks for liquid before every aspiration in a `mix()` command. + +### Bug Fixes + +- Error recovery no longer causes an `AssertionError` when a Python protocol changes the pipette speed. + +--- + ## Opentrons Robot Software Changes in 8.1.0 Welcome to the v8.1.0 release of the Opentrons robot software! diff --git a/api/src/opentrons/config/__init__.py b/api/src/opentrons/config/__init__.py index a4571521211..71ba78d39b0 100644 --- a/api/src/opentrons/config/__init__.py +++ b/api/src/opentrons/config/__init__.py @@ -202,6 +202,15 @@ class ConfigElement(NamedTuple): " absolute path, it will be used directly. If it is a " "relative path it will be relative to log_dir", ), + ConfigElement( + "sensor_log_file", + "Sensor Log File", + Path("logs") / "sensor.log", + ConfigElementType.FILE, + "The location of the file to save sensor logs to. If this is an" + " absolute path, it will be used directly. If it is a " + "relative path it will be relative to log_dir", + ), ConfigElement( "serial_log_file", "Serial Log File", diff --git a/api/src/opentrons/config/defaults_ot3.py b/api/src/opentrons/config/defaults_ot3.py index 08b86f16c95..55565745d3a 100644 --- a/api/src/opentrons/config/defaults_ot3.py +++ b/api/src/opentrons/config/defaults_ot3.py @@ -15,7 +15,6 @@ LiquidProbeSettings, ZSenseSettings, EdgeSenseSettings, - OutputOptions, ) @@ -27,13 +26,11 @@ plunger_speed=15, plunger_impulse_time=0.2, sensor_threshold_pascals=15, - output_option=OutputOptions.sync_buffer_to_csv, aspirate_while_sensing=False, z_overlap_between_passes_mm=0.1, plunger_reset_offset=2.0, samples_for_baselining=20, sample_time_sec=0.004, - data_files={InstrumentProbeType.PRIMARY: "/data/pressure_sensor_data.csv"}, ) DEFAULT_CALIBRATION_SETTINGS: Final[OT3CalibrationSettings] = OT3CalibrationSettings( @@ -43,7 +40,6 @@ max_overrun_distance_mm=5.0, speed_mm_per_s=1.0, sensor_threshold_pf=3.0, - output_option=OutputOptions.sync_only, ), ), edge_sense=EdgeSenseSettings( @@ -54,7 +50,6 @@ max_overrun_distance_mm=0.5, speed_mm_per_s=1, sensor_threshold_pf=3.0, - output_option=OutputOptions.sync_only, ), search_initial_tolerance_mm=12.0, search_iteration_limit=8, @@ -195,23 +190,6 @@ ) -def _build_output_option_with_default( - from_conf: Any, default: OutputOptions -) -> OutputOptions: - if from_conf is None: - return default - else: - if isinstance(from_conf, OutputOptions): - return from_conf - else: - try: - enumval = OutputOptions[from_conf] - except KeyError: # not an enum entry - return default - else: - return enumval - - def _build_log_files_with_default( from_conf: Any, default: Optional[Dict[InstrumentProbeType, str]], @@ -316,24 +294,12 @@ def _build_default_cap_pass( sensor_threshold_pf=from_conf.get( "sensor_threshold_pf", default.sensor_threshold_pf ), - output_option=from_conf.get("output_option", default.output_option), ) def _build_default_liquid_probe( from_conf: Any, default: LiquidProbeSettings ) -> LiquidProbeSettings: - output_option = _build_output_option_with_default( - from_conf.get("output_option", None), default.output_option - ) - data_files: Optional[Dict[InstrumentProbeType, str]] = None - if ( - output_option is OutputOptions.sync_buffer_to_csv - or output_option is OutputOptions.stream_to_csv - ): - data_files = _build_log_files_with_default( - from_conf.get("data_files", None), default.data_files - ) return LiquidProbeSettings( mount_speed=from_conf.get("mount_speed", default.mount_speed), plunger_speed=from_conf.get("plunger_speed", default.plunger_speed), @@ -343,7 +309,6 @@ def _build_default_liquid_probe( sensor_threshold_pascals=from_conf.get( "sensor_threshold_pascals", default.sensor_threshold_pascals ), - output_option=from_conf.get("output_option", default.output_option), aspirate_while_sensing=from_conf.get( "aspirate_while_sensing", default.aspirate_while_sensing ), @@ -357,7 +322,6 @@ def _build_default_liquid_probe( "samples_for_baselining", default.samples_for_baselining ), sample_time_sec=from_conf.get("sample_time_sec", default.sample_time_sec), - data_files=data_files, ) diff --git a/api/src/opentrons/config/types.py b/api/src/opentrons/config/types.py index 5a6c67725d0..d35b58578ca 100644 --- a/api/src/opentrons/config/types.py +++ b/api/src/opentrons/config/types.py @@ -1,8 +1,8 @@ from enum import Enum from dataclasses import dataclass, asdict, fields -from typing import Dict, Tuple, TypeVar, Generic, List, cast, Optional +from typing import Dict, Tuple, TypeVar, Generic, List, cast from typing_extensions import TypedDict, Literal -from opentrons.hardware_control.types import OT3AxisKind, InstrumentProbeType +from opentrons.hardware_control.types import OT3AxisKind class AxisDict(TypedDict): @@ -103,25 +103,12 @@ def by_gantry_load( ) -class OutputOptions(int, Enum): - """Specifies where we should report sensor data to during a sensor pass.""" - - stream_to_csv = 0x1 # compile sensor data stream into a csv file, in addition to can_bus_only behavior - sync_buffer_to_csv = 0x2 # collect sensor data on pipette mcu, then stream to robot server and compile into a csv file, in addition to can_bus_only behavior - can_bus_only = ( - 0x4 # stream sensor data over CAN bus, in addition to sync_only behavior - ) - sync_only = 0x8 # trigger pipette sync line upon sensor's detection of something - - @dataclass(frozen=True) class CapacitivePassSettings: prep_distance_mm: float max_overrun_distance_mm: float speed_mm_per_s: float sensor_threshold_pf: float - output_option: OutputOptions - data_files: Optional[Dict[InstrumentProbeType, str]] = None @dataclass(frozen=True) @@ -135,13 +122,11 @@ class LiquidProbeSettings: plunger_speed: float plunger_impulse_time: float sensor_threshold_pascals: float - output_option: OutputOptions aspirate_while_sensing: bool z_overlap_between_passes_mm: float plunger_reset_offset: float samples_for_baselining: int sample_time_sec: float - data_files: Optional[Dict[InstrumentProbeType, str]] @dataclass(frozen=True) diff --git a/api/src/opentrons/hardware_control/api.py b/api/src/opentrons/hardware_control/api.py index 909a50a3d8c..ec019ef2f1d 100644 --- a/api/src/opentrons/hardware_control/api.py +++ b/api/src/opentrons/hardware_control/api.py @@ -1189,11 +1189,6 @@ async def tip_pickup_moves( await self.retract(mount, spec.retract_target) - def cache_tip(self, mount: top_types.Mount, tip_length: float) -> None: - instrument = self.get_pipette(mount) - instrument.add_tip(tip_length=tip_length) - instrument.set_current_volume(0) - async def pick_up_tip( self, mount: top_types.Mount, diff --git a/api/src/opentrons/hardware_control/backends/flex_protocol.py b/api/src/opentrons/hardware_control/backends/flex_protocol.py index 6f3299cf92d..466e7890026 100644 --- a/api/src/opentrons/hardware_control/backends/flex_protocol.py +++ b/api/src/opentrons/hardware_control/backends/flex_protocol.py @@ -15,7 +15,7 @@ from opentrons_shared_data.pipette.types import ( PipetteName, ) -from opentrons.config.types import GantryLoad, OutputOptions +from opentrons.config.types import GantryLoad from opentrons.hardware_control.types import ( BoardRevision, Axis, @@ -38,6 +38,8 @@ StatusBarState, ) from opentrons.hardware_control.module_control import AttachedModulesControl +from opentrons_hardware.firmware_bindings.constants import SensorId +from opentrons_hardware.sensors.types import SensorDataType from ..dev_types import OT3AttachedInstruments from .types import HWStopCondition @@ -152,10 +154,11 @@ async def liquid_probe( threshold_pascals: float, plunger_impulse_time: float, num_baseline_reads: int, - output_format: OutputOptions = OutputOptions.can_bus_only, - data_files: Optional[Dict[InstrumentProbeType, str]] = None, probe: InstrumentProbeType = InstrumentProbeType.PRIMARY, force_both_sensors: bool = False, + response_queue: Optional[ + asyncio.Queue[Dict[SensorId, List[SensorDataType]]] + ] = None, ) -> float: ... @@ -371,8 +374,6 @@ async def capacitive_probe( speed_mm_per_s: float, sensor_threshold_pf: float, probe: InstrumentProbeType = InstrumentProbeType.PRIMARY, - output_format: OutputOptions = OutputOptions.sync_only, - data_files: Optional[Dict[InstrumentProbeType, str]] = None, ) -> bool: ... diff --git a/api/src/opentrons/hardware_control/backends/ot3controller.py b/api/src/opentrons/hardware_control/backends/ot3controller.py index 84c95c8fbc4..48787e86933 100644 --- a/api/src/opentrons/hardware_control/backends/ot3controller.py +++ b/api/src/opentrons/hardware_control/backends/ot3controller.py @@ -25,7 +25,7 @@ Union, Mapping, ) -from opentrons.config.types import OT3Config, GantryLoad, OutputOptions +from opentrons.config.types import OT3Config, GantryLoad from opentrons.config import gripper_config from .ot3utils import ( axis_convert, @@ -102,7 +102,9 @@ NodeId, PipetteName as FirmwarePipetteName, ErrorCode, + SensorId, ) +from opentrons_hardware.sensors.types import SensorDataType from opentrons_hardware.firmware_bindings.messages.message_definitions import ( StopRequest, ) @@ -1368,28 +1370,14 @@ async def liquid_probe( threshold_pascals: float, plunger_impulse_time: float, num_baseline_reads: int, - output_option: OutputOptions = OutputOptions.can_bus_only, - data_files: Optional[Dict[InstrumentProbeType, str]] = None, probe: InstrumentProbeType = InstrumentProbeType.PRIMARY, force_both_sensors: bool = False, + response_queue: Optional[ + asyncio.Queue[Dict[SensorId, List[SensorDataType]]] + ] = None, ) -> float: head_node = axis_to_node(Axis.by_mount(mount)) tool = sensor_node_for_pipette(OT3Mount(mount.value)) - csv_output = bool(output_option.value & OutputOptions.stream_to_csv.value) - sync_buffer_output = bool( - output_option.value & OutputOptions.sync_buffer_to_csv.value - ) - can_bus_only_output = bool( - output_option.value & OutputOptions.can_bus_only.value - ) - data_files_transposed = ( - None - if data_files is None - else { - sensor_id_for_instrument(probe): data_files[probe] - for probe in data_files.keys() - } - ) positions = await liquid_probe( messenger=self._messenger, tool=tool, @@ -1400,12 +1388,9 @@ async def liquid_probe( threshold_pascals=threshold_pascals, plunger_impulse_time=plunger_impulse_time, num_baseline_reads=num_baseline_reads, - csv_output=csv_output, - sync_buffer_output=sync_buffer_output, - can_bus_only_output=can_bus_only_output, - data_files=data_files_transposed, sensor_id=sensor_id_for_instrument(probe), force_both_sensors=force_both_sensors, + response_queue=response_queue, ) for node, point in positions.items(): self._position.update({node: point.motor_position}) @@ -1432,41 +1417,13 @@ async def capacitive_probe( speed_mm_per_s: float, sensor_threshold_pf: float, probe: InstrumentProbeType = InstrumentProbeType.PRIMARY, - output_option: OutputOptions = OutputOptions.sync_only, - data_files: Optional[Dict[InstrumentProbeType, str]] = None, ) -> bool: - if output_option == OutputOptions.sync_buffer_to_csv: - assert ( - self._subsystem_manager.device_info[ - SubSystem.of_mount(mount) - ].revision.tertiary - == "1" - ) - csv_output = bool(output_option.value & OutputOptions.stream_to_csv.value) - sync_buffer_output = bool( - output_option.value & OutputOptions.sync_buffer_to_csv.value - ) - can_bus_only_output = bool( - output_option.value & OutputOptions.can_bus_only.value - ) - data_files_transposed = ( - None - if data_files is None - else { - sensor_id_for_instrument(probe): data_files[probe] - for probe in data_files.keys() - } - ) status = await capacitive_probe( messenger=self._messenger, tool=sensor_node_for_mount(mount), mover=axis_to_node(moving), distance=distance_mm, mount_speed=speed_mm_per_s, - csv_output=csv_output, - sync_buffer_output=sync_buffer_output, - can_bus_only_output=can_bus_only_output, - data_files=data_files_transposed, sensor_id=sensor_id_for_instrument(probe), relative_threshold_pf=sensor_threshold_pf, ) diff --git a/api/src/opentrons/hardware_control/backends/ot3simulator.py b/api/src/opentrons/hardware_control/backends/ot3simulator.py index 034531892d8..017c90c45b3 100644 --- a/api/src/opentrons/hardware_control/backends/ot3simulator.py +++ b/api/src/opentrons/hardware_control/backends/ot3simulator.py @@ -17,7 +17,7 @@ Mapping, ) -from opentrons.config.types import OT3Config, GantryLoad, OutputOptions +from opentrons.config.types import OT3Config, GantryLoad from opentrons.config import gripper_config from opentrons.hardware_control.module_control import AttachedModulesControl @@ -63,7 +63,8 @@ from opentrons.util.async_helpers import ensure_yield from .types import HWStopCondition from .flex_protocol import FlexBackend - +from opentrons_hardware.firmware_bindings.constants import SensorId +from opentrons_hardware.sensors.types import SensorDataType log = logging.getLogger(__name__) @@ -347,10 +348,11 @@ async def liquid_probe( threshold_pascals: float, plunger_impulse_time: float, num_baseline_reads: int, - output_format: OutputOptions = OutputOptions.can_bus_only, - data_files: Optional[Dict[InstrumentProbeType, str]] = None, probe: InstrumentProbeType = InstrumentProbeType.PRIMARY, force_both_sensors: bool = False, + response_queue: Optional[ + asyncio.Queue[Dict[SensorId, List[SensorDataType]]] + ] = None, ) -> float: z_axis = Axis.by_mount(mount) pos = self._position @@ -750,8 +752,6 @@ async def capacitive_probe( speed_mm_per_s: float, sensor_threshold_pf: float, probe: InstrumentProbeType = InstrumentProbeType.PRIMARY, - output_format: OutputOptions = OutputOptions.sync_only, - data_files: Optional[Dict[InstrumentProbeType, str]] = None, ) -> bool: self._position[moving] += distance_mm return True diff --git a/api/src/opentrons/hardware_control/instruments/ot2/pipette_handler.py b/api/src/opentrons/hardware_control/instruments/ot2/pipette_handler.py index 907788d6dda..931c99fd4c6 100644 --- a/api/src/opentrons/hardware_control/instruments/ot2/pipette_handler.py +++ b/api/src/opentrons/hardware_control/instruments/ot2/pipette_handler.py @@ -13,7 +13,6 @@ Sequence, Iterator, TypeVar, - overload, ) import numpy @@ -430,6 +429,14 @@ def add_tip(self, mount: MountType, tip_length: float) -> None: f"attach tip called while tip already attached to {instr}" ) + def cache_tip(self, mount: MountType, tip_length: float) -> None: + instrument = self.get_pipette(mount) + if instrument.has_tip: + # instrument.add_tip() would raise an AssertionError if we tried to overwrite an existing tip. + instrument.remove_tip() + instrument.add_tip(tip_length=tip_length) + instrument.set_current_volume(0) + def remove_tip(self, mount: MountType) -> None: instr = self._attached_instruments[mount] attached = self.attached_instruments @@ -495,25 +502,12 @@ def plunger_flowrate( ul_per_s = mm_per_s * instr.ul_per_mm(instr.liquid_class.max_volume, action) return round(ul_per_s, 6) - @overload - def plan_check_aspirate( - self, mount: top_types.Mount, volume: Optional[float], rate: float - ) -> Optional[LiquidActionSpec]: - ... - - @overload def plan_check_aspirate( - self, mount: OT3Mount, volume: Optional[float], rate: float - ) -> Optional[LiquidActionSpec]: - ... - - # note on this type ignore: see motion_utilities - def plan_check_aspirate( # type: ignore[no-untyped-def] self, - mount, - volume, - rate, - ): + mount: MountType, + volume: Optional[float], + rate: float, + ) -> Optional[LiquidActionSpec]: """Check preconditions for aspirate, parse args, and calculate positions. While the mechanics of issuing an aspirate move itself are left to child @@ -572,28 +566,12 @@ def plan_check_aspirate( # type: ignore[no-untyped-def] current=instrument.plunger_motor_current.run, ) - @overload def plan_check_dispense( self, - mount: top_types.Mount, - volume: Optional[float], - rate: float, - push_out: Optional[float], - ) -> Optional[LiquidActionSpec]: - ... - - @overload - def plan_check_dispense( - self, - mount: OT3Mount, + mount: MountType, volume: Optional[float], rate: float, push_out: Optional[float], - ) -> Optional[LiquidActionSpec]: - ... - - def plan_check_dispense( # type: ignore[no-untyped-def] - self, mount, volume, rate, push_out ) -> Optional[LiquidActionSpec]: """Check preconditions for dispense, parse args, and calculate positions. @@ -687,15 +665,7 @@ def plan_check_dispense( # type: ignore[no-untyped-def] current=instrument.plunger_motor_current.run, ) - @overload - def plan_check_blow_out(self, mount: top_types.Mount) -> LiquidActionSpec: - ... - - @overload - def plan_check_blow_out(self, mount: OT3Mount) -> LiquidActionSpec: - ... - - def plan_check_blow_out(self, mount): # type: ignore[no-untyped-def] + def plan_check_blow_out(self, mount: MountType) -> LiquidActionSpec: """Check preconditions and calculate values for blowout.""" instrument = self.get_pipette(mount) speed = self.plunger_speed( @@ -743,33 +713,13 @@ def build_one_shake() -> List[Tuple[top_types.Point, Optional[float]]]: else: return [] - @overload def plan_check_pick_up_tip( self, - mount: top_types.Mount, - presses: Optional[int], - increment: Optional[float], - tip_length: float = 0, - ) -> Tuple[PickUpTipSpec, Callable[[], None]]: - ... - - @overload - def plan_check_pick_up_tip( - self, - mount: OT3Mount, + mount: MountType, presses: Optional[int], increment: Optional[float], tip_length: float = 0, ) -> Tuple[PickUpTipSpec, Callable[[], None]]: - ... - - def plan_check_pick_up_tip( # type: ignore[no-untyped-def] - self, - mount, - presses, - increment, - tip_length=0, - ): # Prechecks: ready for pickup tip and press/increment are valid instrument = self.get_pipette(mount) if instrument.has_tip: @@ -917,25 +867,13 @@ def build() -> List[DropTipMove]: return build - @overload - def plan_check_drop_tip( - self, mount: top_types.Mount, home_after: bool - ) -> Tuple[DropTipSpec, Callable[[], None]]: - ... - - @overload - def plan_check_drop_tip( - self, mount: OT3Mount, home_after: bool - ) -> Tuple[DropTipSpec, Callable[[], None]]: - ... - # todo(mm, 2024-10-17): The returned _remove_tips() callable is not used by anything # anymore. Delete it. - def plan_check_drop_tip( # type: ignore[no-untyped-def] + def plan_check_drop_tip( self, - mount, - home_after, - ): + mount: MountType, + home_after: bool, + ) -> Tuple[DropTipSpec, Callable[[], None]]: instrument = self.get_pipette(mount) if not instrument.drop_configurations.plunger_eject: diff --git a/api/src/opentrons/hardware_control/instruments/ot3/pipette_handler.py b/api/src/opentrons/hardware_control/instruments/ot3/pipette_handler.py index 9f44f7b0ab8..f64078fcbff 100644 --- a/api/src/opentrons/hardware_control/instruments/ot3/pipette_handler.py +++ b/api/src/opentrons/hardware_control/instruments/ot3/pipette_handler.py @@ -440,6 +440,14 @@ def add_tip(self, mount: OT3Mount, tip_length: float) -> None: "attach tip called while tip already attached to {instr}" ) + def cache_tip(self, mount: OT3Mount, tip_length: float) -> None: + instrument = self.get_pipette(mount) + if instrument.has_tip: + # instrument.add_tip() would raise an AssertionError if we tried to overwrite an existing tip. + instrument.remove_tip() + instrument.add_tip(tip_length=tip_length) + instrument.set_current_volume(0) + def remove_tip(self, mount: OT3Mount) -> None: instr = self._attached_instruments[mount] attached = self.attached_instruments diff --git a/api/src/opentrons/hardware_control/modules/absorbance_reader.py b/api/src/opentrons/hardware_control/modules/absorbance_reader.py index da7c4746086..ab6ce1bb22b 100644 --- a/api/src/opentrons/hardware_control/modules/absorbance_reader.py +++ b/api/src/opentrons/hardware_control/modules/absorbance_reader.py @@ -272,12 +272,8 @@ def usb_port(self) -> USBPort: return self._usb_port async def deactivate(self, must_be_running: bool = True) -> None: - """Deactivate the module. - - Contains an override to the `wait_for_is_running` step in cases where the - module must be deactivated regardless of context.""" - await self._poller.stop() - await self._driver.disconnect() + """Deactivate the module.""" + pass async def wait_for_is_running(self) -> None: if not self.is_simulated: @@ -336,7 +332,8 @@ async def cleanup(self) -> None: Clean up, i.e. stop pollers, disconnect serial, etc in preparation for object destruction. """ - await self.deactivate() + await self._poller.stop() + await self._driver.disconnect() async def set_sample_wavelength( self, diff --git a/api/src/opentrons/hardware_control/ot3api.py b/api/src/opentrons/hardware_control/ot3api.py index 499592a10eb..f90a0a539dc 100644 --- a/api/src/opentrons/hardware_control/ot3api.py +++ b/api/src/opentrons/hardware_control/ot3api.py @@ -143,7 +143,8 @@ from .backends.flex_protocol import FlexBackend from .backends.ot3simulator import OT3Simulator from .backends.errors import SubsystemUpdating - +from opentrons_hardware.firmware_bindings.constants import SensorId +from opentrons_hardware.sensors.types import SensorDataType mod_log = logging.getLogger(__name__) @@ -2235,15 +2236,6 @@ async def _tip_motor_action( ) await self.home_gear_motors() - def cache_tip( - self, mount: Union[top_types.Mount, OT3Mount], tip_length: float - ) -> None: - realmount = OT3Mount.from_mount(mount) - instrument = self._pipette_handler.get_pipette(realmount) - - instrument.add_tip(tip_length=tip_length) - instrument.set_current_volume(0) - async def pick_up_tip( self, mount: Union[top_types.Mount, OT3Mount], @@ -2612,6 +2604,11 @@ def add_tip( ) -> None: self._pipette_handler.add_tip(OT3Mount.from_mount(mount), tip_length) + def cache_tip( + self, mount: Union[top_types.Mount, OT3Mount], tip_length: float + ) -> None: + self._pipette_handler.cache_tip(OT3Mount.from_mount(mount), tip_length) + def remove_tip(self, mount: Union[top_types.Mount, OT3Mount]) -> None: self._pipette_handler.remove_tip(OT3Mount.from_mount(mount)) @@ -2643,6 +2640,9 @@ async def _liquid_probe_pass( probe: InstrumentProbeType, p_travel: float, force_both_sensors: bool = False, + response_queue: Optional[ + asyncio.Queue[Dict[SensorId, List[SensorDataType]]] + ] = None, ) -> float: plunger_direction = -1 if probe_settings.aspirate_while_sensing else 1 end_z = await self._backend.liquid_probe( @@ -2653,10 +2653,9 @@ async def _liquid_probe_pass( probe_settings.sensor_threshold_pascals, probe_settings.plunger_impulse_time, probe_settings.samples_for_baselining, - probe_settings.output_option, - probe_settings.data_files, probe=probe, force_both_sensors=force_both_sensors, + response_queue=response_queue, ) machine_pos = await self._backend.update_position() machine_pos[Axis.by_mount(mount)] = end_z @@ -2677,6 +2676,9 @@ async def liquid_probe( # noqa: C901 probe_settings: Optional[LiquidProbeSettings] = None, probe: Optional[InstrumentProbeType] = None, force_both_sensors: bool = False, + response_queue: Optional[ + asyncio.Queue[Dict[SensorId, List[SensorDataType]]] + ] = None, ) -> float: """Search for and return liquid level height. @@ -2802,6 +2804,8 @@ async def prep_plunger_for_probe_move( probe_settings, checked_probe, plunger_travel_mm + sensor_baseline_plunger_move_mm, + force_both_sensors, + response_queue, ) # if we made it here without an error we found the liquid error = None @@ -2870,8 +2874,6 @@ async def capacitive_probe( pass_settings.speed_mm_per_s, pass_settings.sensor_threshold_pf, probe, - pass_settings.output_option, - pass_settings.data_files, ) end_pos = await self.gantry_position(mount, refresh=True) if retract_after: diff --git a/api/src/opentrons/hardware_control/protocols/instrument_configurer.py b/api/src/opentrons/hardware_control/protocols/instrument_configurer.py index c1292620b74..5cd85716e36 100644 --- a/api/src/opentrons/hardware_control/protocols/instrument_configurer.py +++ b/api/src/opentrons/hardware_control/protocols/instrument_configurer.py @@ -142,17 +142,24 @@ def get_instrument_max_height( """ ... - # todo(mm, 2024-10-17): Consider deleting this in favor of cache_tip(), which is - # the same except for `assert`s, if we can do so without breaking anything. + # todo(mm, 2024-10-17): Consider deleting this in favor of cache_tip() + # if we can do so without breaking anything. def add_tip(self, mount: MountArgType, tip_length: float) -> None: """Inform the hardware that a tip is now attached to a pipette. + If a tip is already attached, this no-ops. + This changes the critical point of the pipette to make sure that the end of the tip is what moves around, and allows liquid handling. """ ... def cache_tip(self, mount: MountArgType, tip_length: float) -> None: + """Inform the hardware that a tip is now attached to a pipette. + + This is like `add_tip()`, except that if a tip is already attached, + this replaces it instead of no-opping. + """ ... def remove_tip(self, mount: MountArgType) -> None: diff --git a/api/src/opentrons/motion_planning/__init__.py b/api/src/opentrons/motion_planning/__init__.py index 570d4250ebe..2b304ecb74d 100644 --- a/api/src/opentrons/motion_planning/__init__.py +++ b/api/src/opentrons/motion_planning/__init__.py @@ -6,6 +6,7 @@ MINIMUM_Z_MARGIN, get_waypoints, get_gripper_labware_movement_waypoints, + get_gripper_labware_placement_waypoints, ) from .types import Waypoint, MoveType @@ -27,4 +28,5 @@ "ArcOutOfBoundsError", "get_waypoints", "get_gripper_labware_movement_waypoints", + "get_gripper_labware_placement_waypoints", ] diff --git a/api/src/opentrons/motion_planning/waypoints.py b/api/src/opentrons/motion_planning/waypoints.py index b9c62114215..bcc56ad7eda 100644 --- a/api/src/opentrons/motion_planning/waypoints.py +++ b/api/src/opentrons/motion_planning/waypoints.py @@ -181,3 +181,35 @@ def get_gripper_labware_movement_waypoints( ) ) return waypoints_with_jaw_status + + +def get_gripper_labware_placement_waypoints( + to_labware_center: Point, + gripper_home_z: float, + drop_offset: Optional[Point], +) -> List[GripperMovementWaypointsWithJawStatus]: + """Get waypoints for placing labware using a gripper.""" + drop_offset = drop_offset or Point() + + drop_location = to_labware_center + Point( + drop_offset.x, drop_offset.y, drop_offset.z + ) + + post_drop_home_pos = Point(drop_location.x, drop_location.y, gripper_home_z) + + return [ + GripperMovementWaypointsWithJawStatus( + position=Point(drop_location.x, drop_location.y, gripper_home_z), + jaw_open=False, + dropping=False, + ), + GripperMovementWaypointsWithJawStatus( + position=drop_location, jaw_open=False, dropping=False + ), + # Gripper ungrips here + GripperMovementWaypointsWithJawStatus( + position=post_drop_home_pos, + jaw_open=True, + dropping=True, + ), + ] diff --git a/api/src/opentrons/protocol_api/_liquid.py b/api/src/opentrons/protocol_api/_liquid.py index 75e2c6fb6f2..12c9a140ce3 100644 --- a/api/src/opentrons/protocol_api/_liquid.py +++ b/api/src/opentrons/protocol_api/_liquid.py @@ -1,13 +1,15 @@ +from __future__ import annotations + from dataclasses import dataclass -from typing import Optional, Sequence +from typing import Optional, Dict from opentrons_shared_data.liquid_classes.liquid_class_definition import ( LiquidClassSchemaV1, - AspirateProperties, - SingleDispenseProperties, - MultiDispenseProperties, - ByPipetteSetting, - ByTipTypeSetting, +) + +from ._liquid_properties import ( + TransferProperties, + build_transfer_properties, ) @@ -29,46 +31,29 @@ class Liquid: display_color: Optional[str] -# TODO (spp, 2024-10-17): create PAPI-equivalent types for all the properties -# and have validation on value updates with user-facing error messages -@dataclass -class TransferProperties: - _aspirate: AspirateProperties - _dispense: SingleDispenseProperties - _multi_dispense: Optional[MultiDispenseProperties] - - @property - def aspirate(self) -> AspirateProperties: - """Aspirate properties.""" - return self._aspirate - - @property - def dispense(self) -> SingleDispenseProperties: - """Single dispense properties.""" - return self._dispense - - @property - def multi_dispense(self) -> Optional[MultiDispenseProperties]: - """Multi dispense properties.""" - return self._multi_dispense - - @dataclass class LiquidClass: """A data class that contains properties of a specific class of liquids.""" _name: str _display_name: str - _by_pipette_setting: Sequence[ByPipetteSetting] + _by_pipette_setting: Dict[str, Dict[str, TransferProperties]] @classmethod def create(cls, liquid_class_definition: LiquidClassSchemaV1) -> "LiquidClass": """Liquid class factory method.""" + by_pipette_settings: Dict[str, Dict[str, TransferProperties]] = {} + for by_pipette in liquid_class_definition.byPipette: + tip_settings: Dict[str, TransferProperties] = {} + for tip_type in by_pipette.byTipType: + tip_settings[tip_type.tiprack] = build_transfer_properties(tip_type) + by_pipette_settings[by_pipette.pipetteModel] = tip_settings + return cls( _name=liquid_class_definition.liquidClassName, _display_name=liquid_class_definition.displayName, - _by_pipette_setting=liquid_class_definition.byPipette, + _by_pipette_setting=by_pipette_settings, ) @property @@ -81,26 +66,16 @@ def display_name(self) -> str: def get_for(self, pipette: str, tiprack: str) -> TransferProperties: """Get liquid class transfer properties for the specified pipette and tip.""" - settings_for_pipette: Sequence[ByPipetteSetting] = [ - pip_setting - for pip_setting in self._by_pipette_setting - if pip_setting.pipetteModel == pipette - ] - if len(settings_for_pipette) == 0: + try: + settings_for_pipette = self._by_pipette_setting[pipette] + except KeyError: raise ValueError( f"No properties found for {pipette} in {self._name} liquid class" ) - settings_for_tip: Sequence[ByTipTypeSetting] = [ - tip_setting - for tip_setting in settings_for_pipette[0].byTipType - if tip_setting.tiprack == tiprack - ] - if len(settings_for_tip) == 0: + try: + transfer_properties = settings_for_pipette[tiprack] + except KeyError: raise ValueError( f"No properties found for {tiprack} in {self._name} liquid class" ) - return TransferProperties( - _aspirate=settings_for_tip[0].aspirate, - _dispense=settings_for_tip[0].singleDispense, - _multi_dispense=settings_for_tip[0].multiDispense, - ) + return transfer_properties diff --git a/api/src/opentrons/protocol_api/_liquid_properties.py b/api/src/opentrons/protocol_api/_liquid_properties.py new file mode 100644 index 00000000000..8bd7aa6cfd8 --- /dev/null +++ b/api/src/opentrons/protocol_api/_liquid_properties.py @@ -0,0 +1,540 @@ +from dataclasses import dataclass +from typing import Optional, Dict, Sequence + +from opentrons_shared_data.liquid_classes.liquid_class_definition import ( + AspirateProperties as SharedDataAspirateProperties, + SingleDispenseProperties as SharedDataSingleDispenseProperties, + MultiDispenseProperties as SharedDataMultiDispenseProperties, + DelayProperties as SharedDataDelayProperties, + TouchTipProperties as SharedDataTouchTipProperties, + MixProperties as SharedDataMixProperties, + BlowoutProperties as SharedDataBlowoutProperties, + ByTipTypeSetting as SharedByTipTypeSetting, + Submerge as SharedDataSubmerge, + RetractAspirate as SharedDataRetractAspirate, + RetractDispense as SharedDataRetractDispense, + BlowoutLocation, + PositionReference, + Coordinate, +) + +# TODO replace this with a class that can extrapolate given volumes to the correct float, +# also figure out how we want people to be able to set this +LiquidHandlingPropertyByVolume = Dict[str, float] + + +@dataclass +class DelayProperties: + + _enabled: bool + _duration: Optional[float] + + @property + def enabled(self) -> bool: + return self._enabled + + @enabled.setter + def enabled(self, enable: bool) -> None: + # TODO insert bool validation here + if enable and self._duration is None: + raise ValueError("duration must be set before enabling delay.") + self._enabled = enable + + @property + def duration(self) -> Optional[float]: + return self._duration + + @duration.setter + def duration(self, new_duration: float) -> None: + # TODO insert positive float validation here + self._duration = new_duration + + +@dataclass +class TouchTipProperties: + + _enabled: bool + _z_offset: Optional[float] + _mm_to_edge: Optional[float] + _speed: Optional[float] + + @property + def enabled(self) -> bool: + return self._enabled + + @enabled.setter + def enabled(self, enable: bool) -> None: + # TODO insert bool validation here + if enable and ( + self._z_offset is None or self._mm_to_edge is None or self._speed is None + ): + raise ValueError( + "z_offset, mm_to_edge and speed must be set before enabling touch tip." + ) + self._enabled = enable + + @property + def z_offset(self) -> Optional[float]: + return self._z_offset + + @z_offset.setter + def z_offset(self, new_offset: float) -> None: + # TODO validation for float + self._z_offset = new_offset + + @property + def mm_to_edge(self) -> Optional[float]: + return self._mm_to_edge + + @mm_to_edge.setter + def mm_to_edge(self, new_mm: float) -> None: + # TODO validation for float + self._z_offset = new_mm + + @property + def speed(self) -> Optional[float]: + return self._speed + + @speed.setter + def speed(self, new_speed: float) -> None: + # TODO insert positive float validation here + self._speed = new_speed + + +@dataclass +class MixProperties: + + _enabled: bool + _repetitions: Optional[int] + _volume: Optional[float] + + @property + def enabled(self) -> bool: + return self._enabled + + @enabled.setter + def enabled(self, enable: bool) -> None: + # TODO insert bool validation here + if enable and (self._repetitions is None or self._volume is None): + raise ValueError("repetitions and volume must be set before enabling mix.") + self._enabled = enable + + @property + def repetitions(self) -> Optional[int]: + return self._repetitions + + @repetitions.setter + def repetitions(self, new_repetitions: int) -> None: + # TODO validations for positive int + self._repetitions = new_repetitions + + @property + def volume(self) -> Optional[float]: + return self._volume + + @volume.setter + def volume(self, new_volume: float) -> None: + # TODO validations for volume float + self._volume = new_volume + + +@dataclass +class BlowoutProperties: + + _enabled: bool + _location: Optional[BlowoutLocation] + _flow_rate: Optional[float] + + @property + def enabled(self) -> bool: + return self._enabled + + @enabled.setter + def enabled(self, enable: bool) -> None: + # TODO insert bool validation here + if enable and (self._location is None or self._flow_rate is None): + raise ValueError( + "location and flow_rate must be set before enabling blowout." + ) + self._enabled = enable + + @property + def location(self) -> Optional[BlowoutLocation]: + return self._location + + @location.setter + def location(self, new_location: str) -> None: + # TODO blowout location validation + self._location = BlowoutLocation(new_location) + + @property + def flow_rate(self) -> Optional[float]: + return self._flow_rate + + @flow_rate.setter + def flow_rate(self, new_flow_rate: float) -> None: + # TODO validations for positive float + self._flow_rate = new_flow_rate + + +@dataclass +class SubmergeRetractCommon: + + _position_reference: PositionReference + _offset: Coordinate + _speed: float + _delay: DelayProperties + + @property + def position_reference(self) -> PositionReference: + return self._position_reference + + @position_reference.setter + def position_reference(self, new_position: str) -> None: + # TODO validation for position reference + self._position_reference = PositionReference(new_position) + + @property + def offset(self) -> Coordinate: + return self._offset + + @offset.setter + def offset(self, new_offset: Sequence[float]) -> None: + # TODO validate valid coordinates + self._offset = Coordinate(x=new_offset[0], y=new_offset[1], z=new_offset[2]) + + @property + def speed(self) -> float: + return self._speed + + @speed.setter + def speed(self, new_speed: float) -> None: + # TODO insert positive float validation here + self._speed = new_speed + + @property + def delay(self) -> DelayProperties: + return self._delay + + +@dataclass +class Submerge(SubmergeRetractCommon): + ... + + +@dataclass +class RetractAspirate(SubmergeRetractCommon): + + _air_gap_by_volume: LiquidHandlingPropertyByVolume + _touch_tip: TouchTipProperties + + @property + def air_gap_by_volume(self) -> LiquidHandlingPropertyByVolume: + return self._air_gap_by_volume + + @property + def touch_tip(self) -> TouchTipProperties: + return self._touch_tip + + +@dataclass +class RetractDispense(SubmergeRetractCommon): + + _air_gap_by_volume: LiquidHandlingPropertyByVolume + _touch_tip: TouchTipProperties + _blowout: BlowoutProperties + + @property + def air_gap_by_volume(self) -> LiquidHandlingPropertyByVolume: + return self._air_gap_by_volume + + @property + def touch_tip(self) -> TouchTipProperties: + return self._touch_tip + + @property + def blowout(self) -> BlowoutProperties: + return self._blowout + + +@dataclass +class BaseLiquidHandlingProperties: + + _submerge: Submerge + _position_reference: PositionReference + _offset: Coordinate + _flow_rate_by_volume: LiquidHandlingPropertyByVolume + _delay: DelayProperties + + @property + def submerge(self) -> Submerge: + return self._submerge + + @property + def position_reference(self) -> PositionReference: + return self._position_reference + + @position_reference.setter + def position_reference(self, new_position: str) -> None: + # TODO validation for position reference + self._position_reference = PositionReference(new_position) + + @property + def offset(self) -> Coordinate: + return self._offset + + @offset.setter + def offset(self, new_offset: Sequence[float]) -> None: + # TODO validate valid coordinates + self._offset = Coordinate(x=new_offset[0], y=new_offset[1], z=new_offset[2]) + + @property + def flow_rate_by_volume(self) -> LiquidHandlingPropertyByVolume: + return self._flow_rate_by_volume + + @property + def delay(self) -> DelayProperties: + return self._delay + + +@dataclass +class AspirateProperties(BaseLiquidHandlingProperties): + + _retract: RetractAspirate + _pre_wet: bool + _mix: MixProperties + + @property + def pre_wet(self) -> bool: + return self._pre_wet + + @pre_wet.setter + def pre_wet(self, new_setting: bool) -> None: + # TODO boolean validation + self._pre_wet = new_setting + + @property + def retract(self) -> RetractAspirate: + return self._retract + + @property + def mix(self) -> MixProperties: + return self._mix + + +@dataclass +class SingleDispenseProperties(BaseLiquidHandlingProperties): + + _retract: RetractDispense + _push_out_by_volume: LiquidHandlingPropertyByVolume + _mix: MixProperties + + @property + def push_out_by_volume(self) -> LiquidHandlingPropertyByVolume: + return self._push_out_by_volume + + @property + def retract(self) -> RetractDispense: + return self._retract + + @property + def mix(self) -> MixProperties: + return self._mix + + +@dataclass +class MultiDispenseProperties(BaseLiquidHandlingProperties): + + _retract: RetractDispense + _conditioning_by_volume: LiquidHandlingPropertyByVolume + _disposal_by_volume: LiquidHandlingPropertyByVolume + + @property + def retract(self) -> RetractDispense: + return self._retract + + @property + def conditioning_by_volume(self) -> LiquidHandlingPropertyByVolume: + return self._conditioning_by_volume + + @property + def disposal_by_volume(self) -> LiquidHandlingPropertyByVolume: + return self._disposal_by_volume + + +# TODO (spp, 2024-10-17): create PAPI-equivalent types for all the properties +# and have validation on value updates with user-facing error messages +@dataclass +class TransferProperties: + _aspirate: AspirateProperties + _dispense: SingleDispenseProperties + _multi_dispense: Optional[MultiDispenseProperties] + + @property + def aspirate(self) -> AspirateProperties: + """Aspirate properties.""" + return self._aspirate + + @property + def dispense(self) -> SingleDispenseProperties: + """Single dispense properties.""" + return self._dispense + + @property + def multi_dispense(self) -> Optional[MultiDispenseProperties]: + """Multi dispense properties.""" + return self._multi_dispense + + +def _build_delay_properties( + delay_properties: SharedDataDelayProperties, +) -> DelayProperties: + if delay_properties.params is not None: + duration = delay_properties.params.duration + else: + duration = None + return DelayProperties(_enabled=delay_properties.enable, _duration=duration) + + +def _build_touch_tip_properties( + touch_tip_properties: SharedDataTouchTipProperties, +) -> TouchTipProperties: + if touch_tip_properties.params is not None: + z_offset = touch_tip_properties.params.zOffset + mm_to_edge = touch_tip_properties.params.mmToEdge + speed = touch_tip_properties.params.speed + else: + z_offset = None + mm_to_edge = None + speed = None + return TouchTipProperties( + _enabled=touch_tip_properties.enable, + _z_offset=z_offset, + _mm_to_edge=mm_to_edge, + _speed=speed, + ) + + +def _build_mix_properties( + mix_properties: SharedDataMixProperties, +) -> MixProperties: + if mix_properties.params is not None: + repetitions = mix_properties.params.repetitions + volume = mix_properties.params.volume + else: + repetitions = None + volume = None + return MixProperties( + _enabled=mix_properties.enable, _repetitions=repetitions, _volume=volume + ) + + +def _build_blowout_properties( + blowout_properties: SharedDataBlowoutProperties, +) -> BlowoutProperties: + if blowout_properties.params is not None: + location = blowout_properties.params.location + flow_rate = blowout_properties.params.flowRate + else: + location = None + flow_rate = None + return BlowoutProperties( + _enabled=blowout_properties.enable, _location=location, _flow_rate=flow_rate + ) + + +def _build_submerge( + submerge_properties: SharedDataSubmerge, +) -> Submerge: + return Submerge( + _position_reference=submerge_properties.positionReference, + _offset=submerge_properties.offset, + _speed=submerge_properties.speed, + _delay=_build_delay_properties(submerge_properties.delay), + ) + + +def _build_retract_aspirate( + retract_aspirate: SharedDataRetractAspirate, +) -> RetractAspirate: + return RetractAspirate( + _position_reference=retract_aspirate.positionReference, + _offset=retract_aspirate.offset, + _speed=retract_aspirate.speed, + _air_gap_by_volume=retract_aspirate.airGapByVolume, + _touch_tip=_build_touch_tip_properties(retract_aspirate.touchTip), + _delay=_build_delay_properties(retract_aspirate.delay), + ) + + +def _build_retract_dispense( + retract_dispense: SharedDataRetractDispense, +) -> RetractDispense: + return RetractDispense( + _position_reference=retract_dispense.positionReference, + _offset=retract_dispense.offset, + _speed=retract_dispense.speed, + _air_gap_by_volume=retract_dispense.airGapByVolume, + _blowout=_build_blowout_properties(retract_dispense.blowout), + _touch_tip=_build_touch_tip_properties(retract_dispense.touchTip), + _delay=_build_delay_properties(retract_dispense.delay), + ) + + +def build_aspirate_properties( + aspirate_properties: SharedDataAspirateProperties, +) -> AspirateProperties: + return AspirateProperties( + _submerge=_build_submerge(aspirate_properties.submerge), + _retract=_build_retract_aspirate(aspirate_properties.retract), + _position_reference=aspirate_properties.positionReference, + _offset=aspirate_properties.offset, + _flow_rate_by_volume=aspirate_properties.flowRateByVolume, + _pre_wet=aspirate_properties.preWet, + _mix=_build_mix_properties(aspirate_properties.mix), + _delay=_build_delay_properties(aspirate_properties.delay), + ) + + +def build_single_dispense_properties( + single_dispense_properties: SharedDataSingleDispenseProperties, +) -> SingleDispenseProperties: + return SingleDispenseProperties( + _submerge=_build_submerge(single_dispense_properties.submerge), + _retract=_build_retract_dispense(single_dispense_properties.retract), + _position_reference=single_dispense_properties.positionReference, + _offset=single_dispense_properties.offset, + _flow_rate_by_volume=single_dispense_properties.flowRateByVolume, + _mix=_build_mix_properties(single_dispense_properties.mix), + _push_out_by_volume=single_dispense_properties.pushOutByVolume, + _delay=_build_delay_properties(single_dispense_properties.delay), + ) + + +def build_multi_dispense_properties( + multi_dispense_properties: Optional[SharedDataMultiDispenseProperties], +) -> Optional[MultiDispenseProperties]: + if multi_dispense_properties is None: + return None + return MultiDispenseProperties( + _submerge=_build_submerge(multi_dispense_properties.submerge), + _retract=_build_retract_dispense(multi_dispense_properties.retract), + _position_reference=multi_dispense_properties.positionReference, + _offset=multi_dispense_properties.offset, + _flow_rate_by_volume=multi_dispense_properties.flowRateByVolume, + _conditioning_by_volume=multi_dispense_properties.conditioningByVolume, + _disposal_by_volume=multi_dispense_properties.disposalByVolume, + _delay=_build_delay_properties(multi_dispense_properties.delay), + ) + + +def build_transfer_properties( + by_tip_type_setting: SharedByTipTypeSetting, +) -> TransferProperties: + return TransferProperties( + _aspirate=build_aspirate_properties(by_tip_type_setting.aspirate), + _dispense=build_single_dispense_properties(by_tip_type_setting.singleDispense), + _multi_dispense=build_multi_dispense_properties( + by_tip_type_setting.multiDispense + ), + ) diff --git a/api/src/opentrons/protocol_api/core/engine/instrument.py b/api/src/opentrons/protocol_api/core/engine/instrument.py index 4474a174a85..dc174988069 100644 --- a/api/src/opentrons/protocol_api/core/engine/instrument.py +++ b/api/src/opentrons/protocol_api/core/engine/instrument.py @@ -104,6 +104,19 @@ def set_default_speed(self, speed: float) -> None: pipette_id=self._pipette_id, speed=speed ) + def air_gap_in_place(self, volume: float, flow_rate: float) -> None: + """Aspirate a given volume of air from the current location of the pipette. + + Args: + volume: The volume of air to aspirate, in microliters. + folw_rate: The flow rate of air into the pipette, in microliters/s + """ + self._engine_client.execute_command( + cmd.AirGapInPlaceParams( + pipetteId=self._pipette_id, volume=volume, flowRate=flow_rate + ) + ) + def aspirate( self, location: Location, @@ -153,6 +166,8 @@ def aspirate( absolute_point=location.point, is_meniscus=is_meniscus, ) + if well_location.origin == WellOrigin.MENISCUS: + well_location.volumeOffset = "operationVolume" pipette_movement_conflict.check_safe_for_pipette_movement( engine_state=self._engine_client.state, pipette_id=self._pipette_id, diff --git a/api/src/opentrons/protocol_api/core/engine/module_core.py b/api/src/opentrons/protocol_api/core/engine/module_core.py index 1d800dee7ea..1e6d4e26b2f 100644 --- a/api/src/opentrons/protocol_api/core/engine/module_core.py +++ b/api/src/opentrons/protocol_api/core/engine/module_core.py @@ -567,6 +567,7 @@ class AbsorbanceReaderCore(ModuleCore, AbstractAbsorbanceReaderCore): _sync_module_hardware: SynchronousAdapter[hw_modules.AbsorbanceReader] _initialized_value: Optional[List[int]] = None + _ready_to_initialize: bool = False def initialize( self, @@ -575,6 +576,11 @@ def initialize( reference_wavelength: Optional[int] = None, ) -> None: """Initialize the Absorbance Reader by taking zero reading.""" + if not self._ready_to_initialize: + raise CannotPerformModuleAction( + "Cannot perform Initialize action on Absorbance Reader without calling `.close_lid()` first." + ) + # TODO: check that the wavelengths are within the supported wavelengths self._engine_client.execute_command( cmd.absorbance_reader.InitializeParams( @@ -586,7 +592,7 @@ def initialize( ) self._initialized_value = wavelengths - def read(self, filename: Optional[str]) -> Dict[int, Dict[str, float]]: + def read(self, filename: Optional[str] = None) -> Dict[int, Dict[str, float]]: """Initiate a read on the Absorbance Reader, and return the results. During Analysis, this will return a measurement of zero for all wells.""" wavelengths = self._engine_client.state.modules.get_absorbance_reader_substate( self.module_id @@ -633,6 +639,7 @@ def close_lid( moduleId=self.module_id, ) ) + self._ready_to_initialize = True def open_lid(self) -> None: """Close the Absorbance Reader's lid.""" diff --git a/api/src/opentrons/protocol_api/core/engine/protocol.py b/api/src/opentrons/protocol_api/core/engine/protocol.py index 0ed5270320a..dac8bc44a5b 100644 --- a/api/src/opentrons/protocol_api/core/engine/protocol.py +++ b/api/src/opentrons/protocol_api/core/engine/protocol.py @@ -449,9 +449,10 @@ def load_module( # When the protocol engine is created, we add Module Lids as part of the deck fixed labware # If a valid module exists in the deck config. For analysis, we add the labware here since - # deck fixed labware is not created under the same conditions. - if self._engine_client.state.config.use_virtual_modules: - self._load_virtual_module_lid(module_core) + # deck fixed labware is not created under the same conditions. We also need to inject the Module + # lids when the module isnt already on the deck config, like when adding a new + # module during a protocol setup. + self._load_virtual_module_lid(module_core) self._module_cores_by_id[module_core.module_id] = module_core @@ -461,20 +462,24 @@ def _load_virtual_module_lid( self, module_core: Union[ModuleCore, NonConnectedModuleCore] ) -> None: if isinstance(module_core, AbsorbanceReaderCore): - lid = self._engine_client.execute_command_without_recovery( - cmd.LoadLabwareParams( - loadName="opentrons_flex_lid_absorbance_plate_reader_module", - location=ModuleLocation(moduleId=module_core.module_id), - namespace="opentrons", - version=1, - displayName="Absorbance Reader Lid", - ) + substate = self._engine_client.state.modules.get_absorbance_reader_substate( + module_core.module_id ) + if substate.lid_id is None: + lid = self._engine_client.execute_command_without_recovery( + cmd.LoadLabwareParams( + loadName="opentrons_flex_lid_absorbance_plate_reader_module", + location=ModuleLocation(moduleId=module_core.module_id), + namespace="opentrons", + version=1, + displayName="Absorbance Reader Lid", + ) + ) - self._engine_client.add_absorbance_reader_lid( - module_id=module_core.module_id, - lid_id=lid.labwareId, - ) + self._engine_client.add_absorbance_reader_lid( + module_id=module_core.module_id, + lid_id=lid.labwareId, + ) def _create_non_connected_module_core( self, load_module_result: LoadModuleResult diff --git a/api/src/opentrons/protocol_api/core/engine/well.py b/api/src/opentrons/protocol_api/core/engine/well.py index 6743a8a39c5..dba1dc6c840 100644 --- a/api/src/opentrons/protocol_api/core/engine/well.py +++ b/api/src/opentrons/protocol_api/core/engine/well.py @@ -130,7 +130,10 @@ def load_liquid( liquid: Liquid, volume: float, ) -> None: - """Load liquid into a well.""" + """Load liquid into a well. + + If the well is known to be empty, use ``load_empty()`` instead of calling this with a 0.0 volume. + """ self._engine_client.execute_command( cmd.LoadLiquidParams( labwareId=self._labware_id, @@ -139,6 +142,22 @@ def load_liquid( ) ) + def load_empty( + self, + ) -> None: + """Inform the system that a well is known to be empty. + + This should be done early in the protocol, at the same time as a load_liquid command might + be used. + """ + self._engine_client.execute_command( + cmd.LoadLiquidParams( + labwareId=self._labware_id, + liquidId="EMPTY", + volumeByWell={self._name: 0.0}, + ) + ) + def from_center_cartesian(self, x: float, y: float, z: float) -> Point: """Gets point in deck coordinates based on percentage of the radius of each axis.""" well_size = self._engine_client.state.labware.get_well_size( diff --git a/api/src/opentrons/protocol_api/core/instrument.py b/api/src/opentrons/protocol_api/core/instrument.py index 7d1816e1044..f88633a7a6d 100644 --- a/api/src/opentrons/protocol_api/core/instrument.py +++ b/api/src/opentrons/protocol_api/core/instrument.py @@ -24,6 +24,14 @@ def get_default_speed(self) -> float: def set_default_speed(self, speed: float) -> None: ... + @abstractmethod + def air_gap_in_place(self, volume: float, flow_rate: float) -> None: + """Aspirate a given volume of air from the current location of the pipette. + Args: + volume: The volume of air to aspirate, in microliters. + flow_rate: The flow rate of air into the pipette, in microliters. + """ + @abstractmethod def aspirate( self, diff --git a/api/src/opentrons/protocol_api/core/legacy/legacy_instrument_core.py b/api/src/opentrons/protocol_api/core/legacy/legacy_instrument_core.py index ed1e0d607c9..c112fc32abc 100644 --- a/api/src/opentrons/protocol_api/core/legacy/legacy_instrument_core.py +++ b/api/src/opentrons/protocol_api/core/legacy/legacy_instrument_core.py @@ -72,6 +72,9 @@ def set_default_speed(self, speed: float) -> None: """Sets the speed at which the robot's gantry moves.""" self._default_speed = speed + def air_gap_in_place(self, volume: float, flow_rate: float) -> None: + assert False, "Air gap tracking only available in API version 2.22 and later" + def aspirate( self, location: types.Location, diff --git a/api/src/opentrons/protocol_api/core/legacy/legacy_well_core.py b/api/src/opentrons/protocol_api/core/legacy/legacy_well_core.py index a88dd2eee80..891f0f1b681 100644 --- a/api/src/opentrons/protocol_api/core/legacy/legacy_well_core.py +++ b/api/src/opentrons/protocol_api/core/legacy/legacy_well_core.py @@ -114,6 +114,10 @@ def load_liquid( """Load liquid into a well.""" raise APIVersionError(api_element="Loading a liquid") + def load_empty(self) -> None: + """Mark a well as empty.""" + assert False, "load_empty only supported on engine core" + def from_center_cartesian(self, x: float, y: float, z: float) -> Point: """Gets point in deck coordinates based on percentage of the radius of each axis.""" return self._geometry.from_center_cartesian(x, y, z) diff --git a/api/src/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py b/api/src/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py index 55bde6c0a75..f02d1e66fd1 100644 --- a/api/src/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py +++ b/api/src/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py @@ -83,6 +83,9 @@ def get_default_speed(self) -> float: def set_default_speed(self, speed: float) -> None: self._default_speed = speed + def air_gap_in_place(self, volume: float, flow_rate: float) -> None: + assert False, "Air gap tracking only available in API version 2.22 and later" + def aspirate( self, location: types.Location, diff --git a/api/src/opentrons/protocol_api/core/module.py b/api/src/opentrons/protocol_api/core/module.py index c93e8ce8de8..e24fbbc54b0 100644 --- a/api/src/opentrons/protocol_api/core/module.py +++ b/api/src/opentrons/protocol_api/core/module.py @@ -365,7 +365,7 @@ def initialize( """Initialize the Absorbance Reader by taking zero reading.""" @abstractmethod - def read(self, filename: Optional[str]) -> Dict[int, Dict[str, float]]: + def read(self, filename: Optional[str] = None) -> Dict[int, Dict[str, float]]: """Get an absorbance reading from the Absorbance Reader.""" @abstractmethod diff --git a/api/src/opentrons/protocol_api/core/well.py b/api/src/opentrons/protocol_api/core/well.py index bd58963a59c..24489bb04e7 100644 --- a/api/src/opentrons/protocol_api/core/well.py +++ b/api/src/opentrons/protocol_api/core/well.py @@ -79,6 +79,10 @@ def load_liquid( ) -> None: """Load liquid into a well.""" + @abstractmethod + def load_empty(self) -> None: + """Mark a well as containing no liquid.""" + @abstractmethod def from_center_cartesian(self, x: float, y: float, z: float) -> Point: """Gets point in deck coordinates based on percentage of the radius of each axis.""" diff --git a/api/src/opentrons/protocol_api/instrument_context.py b/api/src/opentrons/protocol_api/instrument_context.py index 880626b53c9..93c485f8087 100644 --- a/api/src/opentrons/protocol_api/instrument_context.py +++ b/api/src/opentrons/protocol_api/instrument_context.py @@ -60,6 +60,8 @@ """The version after which offsets for deck configured trash containers and changes to alternating tip drop behavior were introduced.""" _PARTIAL_NOZZLE_CONFIGURATION_SINGLE_ROW_PARTIAL_COLUMN_ADDED_IN = APIVersion(2, 20) """The version after which partial nozzle configurations of single, row, and partial column layouts became available.""" +_AIR_GAP_TRACKING_ADDED_IN = APIVersion(2, 22) +"""The version after which air gaps should be implemented with a separate call instead of an aspirate for better liquid volume tracking.""" class InstrumentContext(publisher.CommandPublisher): @@ -753,7 +755,12 @@ def air_gap( ``pipette.air_gap(height=2)``. If you call ``air_gap`` with a single, unnamed argument, it will always be interpreted as a volume. + .. note:: + Before API version 2.22, this function was implemented as an aspirate, and + dispensing into a well would add the air gap volume to the liquid tracked in + the well. At or above API version 2.22, air gap volume is not counted as liquid + when dispensing into a well. """ if not self._core.has_tip(): raise UnexpectedTipRemovalError("air_gap", self.name, self.mount) @@ -765,7 +772,12 @@ def air_gap( raise RuntimeError("No previous Well cached to perform air gap") target = loc.labware.as_well().top(height) self.move_to(target, publish=False) - self.aspirate(volume) + if self.api_version >= _AIR_GAP_TRACKING_ADDED_IN: + c_vol = self._core.get_available_volume() if volume is None else volume + flow_rate = self._core.get_aspirate_flow_rate() + self._core.air_gap_in_place(c_vol, flow_rate) + else: + self.aspirate(volume) return self @publisher.publish(command=cmds.return_tip) diff --git a/api/src/opentrons/protocol_api/labware.py b/api/src/opentrons/protocol_api/labware.py index 0e8a17d07d3..825cc19668a 100644 --- a/api/src/opentrons/protocol_api/labware.py +++ b/api/src/opentrons/protocol_api/labware.py @@ -280,12 +280,20 @@ def load_liquid(self, liquid: Liquid, volume: float) -> None: :param Liquid liquid: The liquid to load into the well. :param float volume: The volume of liquid to load, in µL. + + .. note:: + In API version 2.22 and later, use :py:meth:`~.Well.load_empty()` to mark a well as empty at the beginning of a protocol, rather than using this method with ``volume=0``. """ self._core.load_liquid( liquid=liquid, volume=volume, ) + @requires_version(2, 22) + def load_empty(self) -> None: + """Mark a well as empty.""" + self._core.load_empty() + def _from_center_cartesian(self, x: float, y: float, z: float) -> Point: """ Private version of from_center_cartesian. Present only for backward diff --git a/api/src/opentrons/protocol_api/module_contexts.py b/api/src/opentrons/protocol_api/module_contexts.py index f7541da1836..9ae550f8d3f 100644 --- a/api/src/opentrons/protocol_api/module_contexts.py +++ b/api/src/opentrons/protocol_api/module_contexts.py @@ -1035,7 +1035,9 @@ def initialize( ) @requires_version(2, 21) - def read(self, export_filename: Optional[str]) -> Dict[int, Dict[str, float]]: + def read( + self, export_filename: Optional[str] = None + ) -> Dict[int, Dict[str, float]]: """Initiate read on the Absorbance Reader. Returns a dictionary of wavelengths to dictionary of values ordered by well name. diff --git a/api/src/opentrons/protocol_engine/actions/__init__.py b/api/src/opentrons/protocol_engine/actions/__init__.py index dfd497817c0..26dfb0df8e0 100644 --- a/api/src/opentrons/protocol_engine/actions/__init__.py +++ b/api/src/opentrons/protocol_engine/actions/__init__.py @@ -30,7 +30,7 @@ SetPipetteMovementSpeedAction, AddAbsorbanceReaderLidAction, ) -from .get_state_update import get_state_update +from .get_state_update import get_state_updates __all__ = [ # action pipeline interface @@ -63,5 +63,5 @@ "PauseSource", "FinishErrorDetails", # helper functions - "get_state_update", + "get_state_updates", ] diff --git a/api/src/opentrons/protocol_engine/actions/actions.py b/api/src/opentrons/protocol_engine/actions/actions.py index 4569f7866ef..6260a6d4614 100644 --- a/api/src/opentrons/protocol_engine/actions/actions.py +++ b/api/src/opentrons/protocol_engine/actions/actions.py @@ -18,7 +18,6 @@ Command, CommandCreate, CommandDefinedErrorData, - CommandPrivateResult, ) from ..error_recovery_policy import ErrorRecoveryPolicy, ErrorRecoveryType from ..notes.notes import CommandNote @@ -69,7 +68,7 @@ class StopAction: class ResumeFromRecoveryAction: """See `ProtocolEngine.resume_from_recovery()`.""" - pass + state_update: StateUpdate @dataclasses.dataclass(frozen=True) @@ -146,10 +145,6 @@ class SucceedCommandAction: command: Command """The command in its new succeeded state.""" - # todo(mm, 2024-08-26): Remove when no state stores use this anymore. - # https://opentrons.atlassian.net/browse/EXEC-639 - private_result: CommandPrivateResult - state_update: StateUpdate = dataclasses.field( # todo(mm, 2024-08-26): This has a default only to make it easier to transition # old tests while https://opentrons.atlassian.net/browse/EXEC-639 is in diff --git a/api/src/opentrons/protocol_engine/actions/get_state_update.py b/api/src/opentrons/protocol_engine/actions/get_state_update.py index e0ddadc3222..ec29a6e38ef 100644 --- a/api/src/opentrons/protocol_engine/actions/get_state_update.py +++ b/api/src/opentrons/protocol_engine/actions/get_state_update.py @@ -1,18 +1,38 @@ # noqa: D100 +from __future__ import annotations +from typing import TYPE_CHECKING - -from .actions import Action, SucceedCommandAction, FailCommandAction +from .actions import ( + Action, + ResumeFromRecoveryAction, + SucceedCommandAction, + FailCommandAction, +) from ..commands.command import DefinedErrorData -from ..state.update_types import StateUpdate +from ..error_recovery_policy import ErrorRecoveryType + +if TYPE_CHECKING: + from ..state.update_types import StateUpdate -def get_state_update(action: Action) -> StateUpdate | None: - """Extract the StateUpdate from an action, if there is one.""" +def get_state_updates(action: Action) -> list[StateUpdate]: + """Extract all the StateUpdates that the StateStores should apply when they apply an action.""" if isinstance(action, SucceedCommandAction): - return action.state_update + return [action.state_update] + elif isinstance(action, FailCommandAction) and isinstance( action.error, DefinedErrorData ): - return action.error.state_update + if action.type == ErrorRecoveryType.ASSUME_FALSE_POSITIVE_AND_CONTINUE: + return [ + action.error.state_update, + action.error.state_update_if_false_positive, + ] + else: + return [action.error.state_update] + + elif isinstance(action, ResumeFromRecoveryAction): + return [action.state_update] + else: - return None + return [] diff --git a/api/src/opentrons/protocol_engine/commands/__init__.py b/api/src/opentrons/protocol_engine/commands/__init__.py index b8ad7ab0b57..69a3d0c12c1 100644 --- a/api/src/opentrons/protocol_engine/commands/__init__.py +++ b/api/src/opentrons/protocol_engine/commands/__init__.py @@ -38,10 +38,17 @@ CommandCreate, CommandResult, CommandType, - CommandPrivateResult, CommandDefinedErrorData, ) +from .air_gap_in_place import ( + AirGapInPlace, + AirGapInPlaceParams, + AirGapInPlaceCreate, + AirGapInPlaceResult, + AirGapInPlaceCommandType, +) + from .aspirate import ( Aspirate, AspirateParams, @@ -153,7 +160,6 @@ LoadPipetteCreate, LoadPipetteResult, LoadPipetteCommandType, - LoadPipettePrivateResult, ) from .move_labware import ( @@ -357,6 +363,12 @@ "hash_protocol_command_params", # command schema generation "generate_command_schema", + # air gap command models + "AirGapInPlace", + "AirGapInPlaceCreate", + "AirGapInPlaceParams", + "AirGapInPlaceResult", + "AirGapInPlaceCommandType", # aspirate command models "Aspirate", "AspirateCreate", diff --git a/api/src/opentrons/protocol_engine/commands/absorbance_reader/close_lid.py b/api/src/opentrons/protocol_engine/commands/absorbance_reader/close_lid.py index b5131d76bcf..2f7f96d9523 100644 --- a/api/src/opentrons/protocol_engine/commands/absorbance_reader/close_lid.py +++ b/api/src/opentrons/protocol_engine/commands/absorbance_reader/close_lid.py @@ -37,9 +37,7 @@ class CloseLidResult(BaseModel): """Result data from closing the lid on an aborbance reading.""" -class CloseLidImpl( - AbstractCommandImpl[CloseLidParams, SuccessData[CloseLidResult, None]] -): +class CloseLidImpl(AbstractCommandImpl[CloseLidParams, SuccessData[CloseLidResult]]): """Execution implementation of closing the lid on an Absorbance Reader.""" def __init__( @@ -53,9 +51,7 @@ def __init__( self._equipment = equipment self._labware_movement = labware_movement - async def execute( - self, params: CloseLidParams - ) -> SuccessData[CloseLidResult, None]: + async def execute(self, params: CloseLidParams) -> SuccessData[CloseLidResult]: """Execute the close lid command.""" mod_substate = self._state_view.modules.get_absorbance_reader_substate( module_id=params.moduleId @@ -142,7 +138,6 @@ async def execute( return SuccessData( public=CloseLidResult(), - private=None, state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/absorbance_reader/initialize.py b/api/src/opentrons/protocol_engine/commands/absorbance_reader/initialize.py index 314645b39b2..4b28154ed17 100644 --- a/api/src/opentrons/protocol_engine/commands/absorbance_reader/initialize.py +++ b/api/src/opentrons/protocol_engine/commands/absorbance_reader/initialize.py @@ -37,7 +37,7 @@ class InitializeResult(BaseModel): class InitializeImpl( - AbstractCommandImpl[InitializeParams, SuccessData[InitializeResult, None]] + AbstractCommandImpl[InitializeParams, SuccessData[InitializeResult]] ): """Execution implementation of initializing an Absorbance Reader.""" @@ -50,9 +50,7 @@ def __init__( self._state_view = state_view self._equipment = equipment - async def execute( - self, params: InitializeParams - ) -> SuccessData[InitializeResult, None]: + async def execute(self, params: InitializeParams) -> SuccessData[InitializeResult]: """Initiate a single absorbance measurement.""" abs_reader_substate = self._state_view.modules.get_absorbance_reader_substate( module_id=params.moduleId @@ -105,7 +103,6 @@ async def execute( return SuccessData( public=InitializeResult(), - private=None, ) diff --git a/api/src/opentrons/protocol_engine/commands/absorbance_reader/open_lid.py b/api/src/opentrons/protocol_engine/commands/absorbance_reader/open_lid.py index 7a048a69b52..5f3eed57199 100644 --- a/api/src/opentrons/protocol_engine/commands/absorbance_reader/open_lid.py +++ b/api/src/opentrons/protocol_engine/commands/absorbance_reader/open_lid.py @@ -38,7 +38,7 @@ class OpenLidResult(BaseModel): """Result data from opening the lid on an aborbance reading.""" -class OpenLidImpl(AbstractCommandImpl[OpenLidParams, SuccessData[OpenLidResult, None]]): +class OpenLidImpl(AbstractCommandImpl[OpenLidParams, SuccessData[OpenLidResult]]): """Execution implementation of opening the lid on an Absorbance Reader.""" def __init__( @@ -52,7 +52,7 @@ def __init__( self._equipment = equipment self._labware_movement = labware_movement - async def execute(self, params: OpenLidParams) -> SuccessData[OpenLidResult, None]: + async def execute(self, params: OpenLidParams) -> SuccessData[OpenLidResult]: """Move the absorbance reader lid from the module to the lid dock.""" mod_substate = self._state_view.modules.get_absorbance_reader_substate( module_id=params.moduleId @@ -137,7 +137,6 @@ async def execute(self, params: OpenLidParams) -> SuccessData[OpenLidResult, Non return SuccessData( public=OpenLidResult(), - private=None, state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/absorbance_reader/read.py b/api/src/opentrons/protocol_engine/commands/absorbance_reader/read.py index caf8a738f09..8743fd1383b 100644 --- a/api/src/opentrons/protocol_engine/commands/absorbance_reader/read.py +++ b/api/src/opentrons/protocol_engine/commands/absorbance_reader/read.py @@ -48,7 +48,7 @@ class ReadAbsorbanceResult(BaseModel): class ReadAbsorbanceImpl( - AbstractCommandImpl[ReadAbsorbanceParams, SuccessData[ReadAbsorbanceResult, None]] + AbstractCommandImpl[ReadAbsorbanceParams, SuccessData[ReadAbsorbanceResult]] ): """Execution implementation of an Absorbance Reader measurement.""" @@ -65,7 +65,7 @@ def __init__( async def execute( # noqa: C901 self, params: ReadAbsorbanceParams - ) -> SuccessData[ReadAbsorbanceResult, None]: + ) -> SuccessData[ReadAbsorbanceResult]: """Initiate an absorbance measurement.""" abs_reader_substate = self._state_view.modules.get_absorbance_reader_substate( module_id=params.moduleId @@ -168,12 +168,10 @@ async def execute( # noqa: C901 public=ReadAbsorbanceResult( data=asbsorbance_result, fileIds=file_ids ), - private=None, ) return SuccessData( public=ReadAbsorbanceResult(data=asbsorbance_result, fileIds=file_ids), - private=None, ) diff --git a/api/src/opentrons/protocol_engine/commands/air_gap_in_place.py b/api/src/opentrons/protocol_engine/commands/air_gap_in_place.py new file mode 100644 index 00000000000..461a446f3e4 --- /dev/null +++ b/api/src/opentrons/protocol_engine/commands/air_gap_in_place.py @@ -0,0 +1,160 @@ +"""AirGap in place command request, result, and implementation models.""" + +from __future__ import annotations +from typing import TYPE_CHECKING, Optional, Type, Union +from typing_extensions import Literal + +from opentrons_shared_data.errors.exceptions import PipetteOverpressureError + +from opentrons.hardware_control import HardwareControlAPI + +from .pipetting_common import ( + PipetteIdMixin, + AspirateVolumeMixin, + FlowRateMixin, + BaseLiquidHandlingResult, + OverpressureError, +) +from .command import ( + AbstractCommandImpl, + BaseCommand, + BaseCommandCreate, + SuccessData, + DefinedErrorData, +) +from ..errors.error_occurrence import ErrorOccurrence +from ..errors.exceptions import PipetteNotReadyToAspirateError +from ..state.update_types import StateUpdate +from ..types import AspiratedFluid, FluidKind + +if TYPE_CHECKING: + from ..execution import PipettingHandler, GantryMover + from ..resources import ModelUtils + from ..state.state import StateView + from ..notes import CommandNoteAdder + +AirGapInPlaceCommandType = Literal["airGapInPlace"] + + +class AirGapInPlaceParams(PipetteIdMixin, AspirateVolumeMixin, FlowRateMixin): + """Payload required to air gap in place.""" + + pass + + +class AirGapInPlaceResult(BaseLiquidHandlingResult): + """Result data from the execution of a AirGapInPlace command.""" + + pass + + +_ExecuteReturn = Union[ + SuccessData[AirGapInPlaceResult], + DefinedErrorData[OverpressureError], +] + + +class AirGapInPlaceImplementation( + AbstractCommandImpl[AirGapInPlaceParams, _ExecuteReturn] +): + """AirGapInPlace command implementation.""" + + def __init__( + self, + pipetting: PipettingHandler, + hardware_api: HardwareControlAPI, + state_view: StateView, + command_note_adder: CommandNoteAdder, + model_utils: ModelUtils, + gantry_mover: GantryMover, + **kwargs: object, + ) -> None: + self._pipetting = pipetting + self._state_view = state_view + self._hardware_api = hardware_api + self._command_note_adder = command_note_adder + self._model_utils = model_utils + self._gantry_mover = gantry_mover + + async def execute(self, params: AirGapInPlaceParams) -> _ExecuteReturn: + """Air gap without moving the pipette. + + Raises: + TipNotAttachedError: if no tip is attached to the pipette. + PipetteNotReadyToAirGapError: pipette plunger is not ready. + """ + ready_to_aspirate = self._pipetting.get_is_ready_to_aspirate( + pipette_id=params.pipetteId, + ) + + if not ready_to_aspirate: + raise PipetteNotReadyToAspirateError( + "Pipette cannot air gap in place because of a previous blow out." + " The first aspirate following a blow-out must be from a specific well" + " so the plunger can be reset in a known safe position." + ) + + state_update = StateUpdate() + + try: + current_position = await self._gantry_mover.get_position(params.pipetteId) + volume = await self._pipetting.aspirate_in_place( + pipette_id=params.pipetteId, + volume=params.volume, + flow_rate=params.flowRate, + command_note_adder=self._command_note_adder, + ) + except PipetteOverpressureError as e: + return DefinedErrorData( + public=OverpressureError( + id=self._model_utils.generate_id(), + createdAt=self._model_utils.get_timestamp(), + wrappedErrors=[ + ErrorOccurrence.from_failed( + id=self._model_utils.generate_id(), + createdAt=self._model_utils.get_timestamp(), + error=e, + ) + ], + errorInfo=( + { + "retryLocation": ( + current_position.x, + current_position.y, + current_position.z, + ) + } + ), + ), + state_update=state_update, + ) + else: + state_update.set_fluid_aspirated( + pipette_id=params.pipetteId, + fluid=AspiratedFluid(kind=FluidKind.AIR, volume=volume), + ) + return SuccessData( + public=AirGapInPlaceResult(volume=volume), + state_update=state_update, + ) + + +class AirGapInPlace( + BaseCommand[AirGapInPlaceParams, AirGapInPlaceResult, OverpressureError] +): + """AirGapInPlace command model.""" + + commandType: AirGapInPlaceCommandType = "airGapInPlace" + params: AirGapInPlaceParams + result: Optional[AirGapInPlaceResult] + + _ImplementationCls: Type[AirGapInPlaceImplementation] = AirGapInPlaceImplementation + + +class AirGapInPlaceCreate(BaseCommandCreate[AirGapInPlaceParams]): + """AirGapInPlace command request model.""" + + commandType: AirGapInPlaceCommandType = "airGapInPlace" + params: AirGapInPlaceParams + + _CommandCls: Type[AirGapInPlace] = AirGapInPlace diff --git a/api/src/opentrons/protocol_engine/commands/aspirate.py b/api/src/opentrons/protocol_engine/commands/aspirate.py index 14b59248216..b5541c79792 100644 --- a/api/src/opentrons/protocol_engine/commands/aspirate.py +++ b/api/src/opentrons/protocol_engine/commands/aspirate.py @@ -24,8 +24,15 @@ from opentrons.hardware_control import HardwareControlAPI -from ..state.update_types import StateUpdate -from ..types import WellLocation, WellOrigin, CurrentWell, DeckPoint +from ..state.update_types import StateUpdate, CLEAR +from ..types import ( + WellLocation, + WellOrigin, + CurrentWell, + DeckPoint, + AspiratedFluid, + FluidKind, +) if TYPE_CHECKING: from ..execution import MovementHandler, PipettingHandler @@ -52,7 +59,7 @@ class AspirateResult(BaseLiquidHandlingResult, DestinationPositionResult): _ExecuteReturn = Union[ - SuccessData[AspirateResult, None], + SuccessData[AspirateResult], DefinedErrorData[OverpressureError], ] @@ -112,15 +119,11 @@ async def execute(self, params: AspirateParams) -> _ExecuteReturn: well_name=well_name, ) - well_location = params.wellLocation - if well_location.origin == WellOrigin.MENISCUS: - well_location.volumeOffset = "operationVolume" - position = await self._movement.move_to_well( pipette_id=pipette_id, labware_id=labware_id, well_name=well_name, - well_location=well_location, + well_location=params.wellLocation, current_well=current_well, operation_volume=-params.volume, ) @@ -140,6 +143,12 @@ async def execute(self, params: AspirateParams) -> _ExecuteReturn: command_note_adder=self._command_note_adder, ) except PipetteOverpressureError as e: + state_update.set_liquid_operated( + labware_id=labware_id, + well_name=well_name, + volume_added=CLEAR, + ) + state_update.set_fluid_unknown(pipette_id=params.pipetteId) return DefinedErrorData( public=OverpressureError( id=self._model_utils.generate_id(), @@ -156,12 +165,20 @@ async def execute(self, params: AspirateParams) -> _ExecuteReturn: state_update=state_update, ) else: + state_update.set_liquid_operated( + labware_id=labware_id, + well_name=well_name, + volume_added=-volume_aspirated, + ) + state_update.set_fluid_aspirated( + pipette_id=params.pipetteId, + fluid=AspiratedFluid(kind=FluidKind.LIQUID, volume=volume_aspirated), + ) return SuccessData( public=AspirateResult( volume=volume_aspirated, position=deck_point, ), - private=None, state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/aspirate_in_place.py b/api/src/opentrons/protocol_engine/commands/aspirate_in_place.py index 59879e7ca63..f25b6c24bbb 100644 --- a/api/src/opentrons/protocol_engine/commands/aspirate_in_place.py +++ b/api/src/opentrons/protocol_engine/commands/aspirate_in_place.py @@ -24,6 +24,8 @@ ) from ..errors.error_occurrence import ErrorOccurrence from ..errors.exceptions import PipetteNotReadyToAspirateError +from ..state.update_types import StateUpdate, CLEAR +from ..types import CurrentWell, AspiratedFluid, FluidKind if TYPE_CHECKING: from ..execution import PipettingHandler, GantryMover @@ -47,7 +49,7 @@ class AspirateInPlaceResult(BaseLiquidHandlingResult): _ExecuteReturn = Union[ - SuccessData[AspirateInPlaceResult, None], + SuccessData[AspirateInPlaceResult], DefinedErrorData[OverpressureError], ] @@ -91,6 +93,10 @@ async def execute(self, params: AspirateInPlaceParams) -> _ExecuteReturn: " The first aspirate following a blow-out must be from a specific well" " so the plunger can be reset in a known safe position." ) + + state_update = StateUpdate() + current_location = self._state_view.pipettes.get_current_location() + try: current_position = await self._gantry_mover.get_position(params.pipetteId) volume = await self._pipetting.aspirate_in_place( @@ -100,6 +106,16 @@ async def execute(self, params: AspirateInPlaceParams) -> _ExecuteReturn: command_note_adder=self._command_note_adder, ) except PipetteOverpressureError as e: + if ( + isinstance(current_location, CurrentWell) + and current_location.pipette_id == params.pipetteId + ): + state_update.set_liquid_operated( + labware_id=current_location.labware_id, + well_name=current_location.well_name, + volume_added=CLEAR, + ) + state_update.set_fluid_unknown(pipette_id=params.pipetteId) return DefinedErrorData( public=OverpressureError( id=self._model_utils.generate_id(), @@ -121,10 +137,26 @@ async def execute(self, params: AspirateInPlaceParams) -> _ExecuteReturn: } ), ), + state_update=state_update, ) else: + state_update.set_fluid_aspirated( + pipette_id=params.pipetteId, + fluid=AspiratedFluid(kind=FluidKind.LIQUID, volume=volume), + ) + if ( + isinstance(current_location, CurrentWell) + and current_location.pipette_id == params.pipetteId + ): + state_update.set_liquid_operated( + labware_id=current_location.labware_id, + well_name=current_location.well_name, + volume_added=-volume, + ) + return SuccessData( - public=AspirateInPlaceResult(volume=volume), private=None + public=AspirateInPlaceResult(volume=volume), + state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/blow_out.py b/api/src/opentrons/protocol_engine/commands/blow_out.py index c8a6b65ce63..c450fa894ed 100644 --- a/api/src/opentrons/protocol_engine/commands/blow_out.py +++ b/api/src/opentrons/protocol_engine/commands/blow_out.py @@ -48,7 +48,7 @@ class BlowOutResult(DestinationPositionResult): _ExecuteReturn = Union[ - SuccessData[BlowOutResult, None], + SuccessData[BlowOutResult], DefinedErrorData[OverpressureError], ] @@ -93,6 +93,7 @@ async def execute(self, params: BlowOutParams) -> _ExecuteReturn: pipette_id=params.pipetteId, flow_rate=params.flowRate ) except PipetteOverpressureError as e: + state_update.set_fluid_unknown(pipette_id=params.pipetteId) return DefinedErrorData( public=OverpressureError( id=self._model_utils.generate_id(), @@ -112,11 +113,12 @@ async def execute(self, params: BlowOutParams) -> _ExecuteReturn: ) }, ), + state_update=state_update, ) else: + state_update.set_fluid_empty(pipette_id=params.pipetteId) return SuccessData( public=BlowOutResult(position=deck_point), - private=None, state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/blow_out_in_place.py b/api/src/opentrons/protocol_engine/commands/blow_out_in_place.py index 38165a4a626..04a38b8915c 100644 --- a/api/src/opentrons/protocol_engine/commands/blow_out_in_place.py +++ b/api/src/opentrons/protocol_engine/commands/blow_out_in_place.py @@ -19,6 +19,7 @@ SuccessData, ) from ..errors.error_occurrence import ErrorOccurrence +from ..state import update_types from opentrons.hardware_control import HardwareControlAPI @@ -45,7 +46,7 @@ class BlowOutInPlaceResult(BaseModel): _ExecuteReturn = Union[ - SuccessData[BlowOutInPlaceResult, None], + SuccessData[BlowOutInPlaceResult], DefinedErrorData[OverpressureError], ] @@ -72,12 +73,14 @@ def __init__( async def execute(self, params: BlowOutInPlaceParams) -> _ExecuteReturn: """Blow-out without moving the pipette.""" + state_update = update_types.StateUpdate() try: current_position = await self._gantry_mover.get_position(params.pipetteId) await self._pipetting.blow_out_in_place( pipette_id=params.pipetteId, flow_rate=params.flowRate ) except PipetteOverpressureError as e: + state_update.set_fluid_unknown(pipette_id=params.pipetteId) return DefinedErrorData( public=OverpressureError( id=self._model_utils.generate_id(), @@ -97,9 +100,11 @@ async def execute(self, params: BlowOutInPlaceParams) -> _ExecuteReturn: ) }, ), + state_update=state_update, ) else: - return SuccessData(public=BlowOutInPlaceResult(), private=None) + state_update.set_fluid_empty(pipette_id=params.pipetteId) + return SuccessData(public=BlowOutInPlaceResult(), state_update=state_update) class BlowOutInPlace( diff --git a/api/src/opentrons/protocol_engine/commands/calibration/calibrate_gripper.py b/api/src/opentrons/protocol_engine/commands/calibration/calibrate_gripper.py index b400e2dd33a..0333a171077 100644 --- a/api/src/opentrons/protocol_engine/commands/calibration/calibrate_gripper.py +++ b/api/src/opentrons/protocol_engine/commands/calibration/calibrate_gripper.py @@ -71,9 +71,7 @@ class CalibrateGripperResult(BaseModel): class CalibrateGripperImplementation( - AbstractCommandImpl[ - CalibrateGripperParams, SuccessData[CalibrateGripperResult, None] - ] + AbstractCommandImpl[CalibrateGripperParams, SuccessData[CalibrateGripperResult]] ): """The implementation of a `calibrateGripper` command.""" @@ -87,7 +85,7 @@ def __init__( async def execute( self, params: CalibrateGripperParams - ) -> SuccessData[CalibrateGripperResult, None]: + ) -> SuccessData[CalibrateGripperResult]: """Execute a `calibrateGripper` command. 1. Move from the current location to the calibration area on the deck. @@ -126,7 +124,6 @@ async def execute( ), savedCalibration=calibration_data, ), - private=None, ) @staticmethod diff --git a/api/src/opentrons/protocol_engine/commands/calibration/calibrate_module.py b/api/src/opentrons/protocol_engine/commands/calibration/calibrate_module.py index 8eee75c6207..f488e8eab97 100644 --- a/api/src/opentrons/protocol_engine/commands/calibration/calibrate_module.py +++ b/api/src/opentrons/protocol_engine/commands/calibration/calibrate_module.py @@ -49,7 +49,7 @@ class CalibrateModuleResult(BaseModel): class CalibrateModuleImplementation( - AbstractCommandImpl[CalibrateModuleParams, SuccessData[CalibrateModuleResult, None]] + AbstractCommandImpl[CalibrateModuleParams, SuccessData[CalibrateModuleResult]] ): """CalibrateModule command implementation.""" @@ -64,7 +64,7 @@ def __init__( async def execute( self, params: CalibrateModuleParams - ) -> SuccessData[CalibrateModuleResult, None]: + ) -> SuccessData[CalibrateModuleResult]: """Execute calibrate-module command.""" ot3_api = ensure_ot3_hardware( self._hardware_api, @@ -91,7 +91,6 @@ async def execute( ), location=slot, ), - private=None, ) diff --git a/api/src/opentrons/protocol_engine/commands/calibration/calibrate_pipette.py b/api/src/opentrons/protocol_engine/commands/calibration/calibrate_pipette.py index 4369f88a9c5..fbe754f6389 100644 --- a/api/src/opentrons/protocol_engine/commands/calibration/calibrate_pipette.py +++ b/api/src/opentrons/protocol_engine/commands/calibration/calibrate_pipette.py @@ -34,9 +34,7 @@ class CalibratePipetteResult(BaseModel): class CalibratePipetteImplementation( - AbstractCommandImpl[ - CalibratePipetteParams, SuccessData[CalibratePipetteResult, None] - ] + AbstractCommandImpl[CalibratePipetteParams, SuccessData[CalibratePipetteResult]] ): """CalibratePipette command implementation.""" @@ -49,7 +47,7 @@ def __init__( async def execute( self, params: CalibratePipetteParams - ) -> SuccessData[CalibratePipetteResult, None]: + ) -> SuccessData[CalibratePipetteResult]: """Execute calibrate-pipette command.""" # TODO (tz, 20-9-22): Add a better solution to determine if a command can be executed on an OT-3/OT-2 ot3_api = ensure_ot3_hardware( @@ -72,7 +70,6 @@ async def execute( x=pipette_offset.x, y=pipette_offset.y, z=pipette_offset.z ) ), - private=None, ) diff --git a/api/src/opentrons/protocol_engine/commands/calibration/move_to_maintenance_position.py b/api/src/opentrons/protocol_engine/commands/calibration/move_to_maintenance_position.py index 73a0a8c2511..afb178cae99 100644 --- a/api/src/opentrons/protocol_engine/commands/calibration/move_to_maintenance_position.py +++ b/api/src/opentrons/protocol_engine/commands/calibration/move_to_maintenance_position.py @@ -57,7 +57,7 @@ class MoveToMaintenancePositionResult(BaseModel): class MoveToMaintenancePositionImplementation( AbstractCommandImpl[ MoveToMaintenancePositionParams, - SuccessData[MoveToMaintenancePositionResult, None], + SuccessData[MoveToMaintenancePositionResult], ] ): """Calibration set up position command implementation.""" @@ -73,7 +73,7 @@ def __init__( async def execute( self, params: MoveToMaintenancePositionParams - ) -> SuccessData[MoveToMaintenancePositionResult, None]: + ) -> SuccessData[MoveToMaintenancePositionResult]: """Move the requested mount to a maintenance deck slot.""" ot3_api = ensure_ot3_hardware( self._hardware_api, @@ -118,7 +118,9 @@ async def execute( ) await ot3_api.disengage_axes([Axis.Z_R]) - return SuccessData(public=MoveToMaintenancePositionResult(), private=None) + return SuccessData( + public=MoveToMaintenancePositionResult(), + ) class MoveToMaintenancePosition( diff --git a/api/src/opentrons/protocol_engine/commands/command.py b/api/src/opentrons/protocol_engine/commands/command.py index 9ba9404af1f..1fefcbf7315 100644 --- a/api/src/opentrons/protocol_engine/commands/command.py +++ b/api/src/opentrons/protocol_engine/commands/command.py @@ -39,7 +39,6 @@ _ResultT_co = TypeVar("_ResultT_co", bound=BaseModel, covariant=True) _ErrorT = TypeVar("_ErrorT", bound=ErrorOccurrence) _ErrorT_co = TypeVar("_ErrorT_co", bound=ErrorOccurrence, covariant=True) -_PrivateResultT_co = TypeVar("_PrivateResultT_co", covariant=True) class CommandStatus(str, Enum): @@ -108,19 +107,12 @@ class BaseCommandCreate( @dataclasses.dataclass(frozen=True) -class SuccessData(Generic[_ResultT_co, _PrivateResultT_co]): +class SuccessData(Generic[_ResultT_co]): """Data from the successful completion of a command.""" public: _ResultT_co """Public result data. Exposed over HTTP and stored in databases.""" - private: _PrivateResultT_co - """Additional result data, only given to `opentrons.protocol_engine` internals. - - Deprecated: - Use `state_update` instead. - """ - state_update: StateUpdate = dataclasses.field( # todo(mm, 2024-08-22): Remove the default once all command implementations # use this, to make it harder to forget in new command implementations. @@ -147,6 +139,10 @@ class DefinedErrorData(Generic[_ErrorT_co]): ) """How the engine state should be updated to reflect this command failure.""" + state_update_if_false_positive: StateUpdate = dataclasses.field( + default_factory=StateUpdate + ) + class BaseCommand( GenericModel, @@ -235,8 +231,6 @@ class BaseCommand( # Our _ImplementationCls must return public result data that can fit # in our `result` field: _ResultT, - # But we don't care (here) what kind of private result data it returns: - object, ], DefinedErrorData[ # Our _ImplementationCls must return public error data that can fit @@ -251,7 +245,7 @@ class BaseCommand( _ExecuteReturnT_co = TypeVar( "_ExecuteReturnT_co", bound=Union[ - SuccessData[BaseModel, object], + SuccessData[BaseModel], DefinedErrorData[ErrorOccurrence], ], covariant=True, diff --git a/api/src/opentrons/protocol_engine/commands/command_unions.py b/api/src/opentrons/protocol_engine/commands/command_unions.py index 7623cc09f68..c59c538ddd3 100644 --- a/api/src/opentrons/protocol_engine/commands/command_unions.py +++ b/api/src/opentrons/protocol_engine/commands/command_unions.py @@ -31,6 +31,14 @@ SetRailLightsResult, ) +from .air_gap_in_place import ( + AirGapInPlace, + AirGapInPlaceParams, + AirGapInPlaceCreate, + AirGapInPlaceResult, + AirGapInPlaceCommandType, +) + from .aspirate import ( Aspirate, AspirateParams, @@ -141,7 +149,6 @@ LoadPipetteCreate, LoadPipetteResult, LoadPipetteCommandType, - LoadPipettePrivateResult, ) from .move_labware import ( @@ -272,7 +279,6 @@ ConfigureForVolumeCreate, ConfigureForVolumeResult, ConfigureForVolumeCommandType, - ConfigureForVolumePrivateResult, ) from .prepare_to_aspirate import ( @@ -322,6 +328,7 @@ Command = Annotated[ Union[ + AirGapInPlace, Aspirate, AspirateInPlace, Comment, @@ -394,11 +401,13 @@ unsafe.UpdatePositionEstimators, unsafe.UnsafeEngageAxes, unsafe.UnsafeUngripLabware, + unsafe.UnsafePlaceLabware, ], Field(discriminator="commandType"), ] CommandParams = Union[ + AirGapInPlaceParams, AspirateParams, AspirateInPlaceParams, CommentParams, @@ -471,9 +480,11 @@ unsafe.UpdatePositionEstimatorsParams, unsafe.UnsafeEngageAxesParams, unsafe.UnsafeUngripLabwareParams, + unsafe.UnsafePlaceLabwareParams, ] CommandType = Union[ + AirGapInPlaceCommandType, AspirateCommandType, AspirateInPlaceCommandType, CommentCommandType, @@ -546,10 +557,12 @@ unsafe.UpdatePositionEstimatorsCommandType, unsafe.UnsafeEngageAxesCommandType, unsafe.UnsafeUngripLabwareCommandType, + unsafe.UnsafePlaceLabwareCommandType, ] CommandCreate = Annotated[ Union[ + AirGapInPlaceCreate, AspirateCreate, AspirateInPlaceCreate, CommentCreate, @@ -622,11 +635,13 @@ unsafe.UpdatePositionEstimatorsCreate, unsafe.UnsafeEngageAxesCreate, unsafe.UnsafeUngripLabwareCreate, + unsafe.UnsafePlaceLabwareCreate, ], Field(discriminator="commandType"), ] CommandResult = Union[ + AirGapInPlaceResult, AspirateResult, AspirateInPlaceResult, CommentResult, @@ -699,17 +714,9 @@ unsafe.UpdatePositionEstimatorsResult, unsafe.UnsafeEngageAxesResult, unsafe.UnsafeUngripLabwareResult, + unsafe.UnsafePlaceLabwareResult, ] -# todo(mm, 2024-06-12): Ideally, command return types would have specific -# CommandPrivateResults paired with specific CommandResults. For example, -# a TouchTipResult can never be paired with a LoadPipettePrivateResult in practice, -# and ideally our types would reflect that. -CommandPrivateResult = Union[ - None, - LoadPipettePrivateResult, - ConfigureForVolumePrivateResult, -] # All `DefinedErrorData`s that implementations will actually return in practice. CommandDefinedErrorData = Union[ diff --git a/api/src/opentrons/protocol_engine/commands/comment.py b/api/src/opentrons/protocol_engine/commands/comment.py index d411b6b4047..5cd0b0c3113 100644 --- a/api/src/opentrons/protocol_engine/commands/comment.py +++ b/api/src/opentrons/protocol_engine/commands/comment.py @@ -24,16 +24,18 @@ class CommentResult(BaseModel): class CommentImplementation( - AbstractCommandImpl[CommentParams, SuccessData[CommentResult, None]] + AbstractCommandImpl[CommentParams, SuccessData[CommentResult]] ): """Comment command implementation.""" def __init__(self, **kwargs: object) -> None: pass - async def execute(self, params: CommentParams) -> SuccessData[CommentResult, None]: + async def execute(self, params: CommentParams) -> SuccessData[CommentResult]: """No operation taken other than capturing message in command.""" - return SuccessData(public=CommentResult(), private=None) + return SuccessData( + public=CommentResult(), + ) class Comment(BaseCommand[CommentParams, CommentResult, ErrorOccurrence]): diff --git a/api/src/opentrons/protocol_engine/commands/configure_for_volume.py b/api/src/opentrons/protocol_engine/commands/configure_for_volume.py index 93a56ca8805..1c8aa21f491 100644 --- a/api/src/opentrons/protocol_engine/commands/configure_for_volume.py +++ b/api/src/opentrons/protocol_engine/commands/configure_for_volume.py @@ -7,7 +7,6 @@ from .pipetting_common import PipetteIdMixin from .command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData from ..errors.error_occurrence import ErrorOccurrence -from .configuring_common import PipetteConfigUpdateResultMixin from ..state.update_types import StateUpdate if TYPE_CHECKING: @@ -35,12 +34,6 @@ class ConfigureForVolumeParams(PipetteIdMixin): ) -class ConfigureForVolumePrivateResult(PipetteConfigUpdateResultMixin): - """Result sent to the store but not serialized.""" - - pass - - class ConfigureForVolumeResult(BaseModel): """Result data from execution of an ConfigureForVolume command.""" @@ -50,7 +43,7 @@ class ConfigureForVolumeResult(BaseModel): class ConfigureForVolumeImplementation( AbstractCommandImpl[ ConfigureForVolumeParams, - SuccessData[ConfigureForVolumeResult, ConfigureForVolumePrivateResult], + SuccessData[ConfigureForVolumeResult], ] ): """Configure for volume command implementation.""" @@ -60,7 +53,7 @@ def __init__(self, equipment: EquipmentHandler, **kwargs: object) -> None: async def execute( self, params: ConfigureForVolumeParams - ) -> SuccessData[ConfigureForVolumeResult, ConfigureForVolumePrivateResult]: + ) -> SuccessData[ConfigureForVolumeResult]: """Check that requested pipette can be configured for the given volume.""" pipette_result = await self._equipment.configure_for_volume( pipette_id=params.pipetteId, @@ -77,11 +70,6 @@ async def execute( return SuccessData( public=ConfigureForVolumeResult(), - private=ConfigureForVolumePrivateResult( - pipette_id=pipette_result.pipette_id, - serial_number=pipette_result.serial_number, - config=pipette_result.static_config, - ), state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/configure_nozzle_layout.py b/api/src/opentrons/protocol_engine/commands/configure_nozzle_layout.py index d04eee55c94..f78839773ec 100644 --- a/api/src/opentrons/protocol_engine/commands/configure_nozzle_layout.py +++ b/api/src/opentrons/protocol_engine/commands/configure_nozzle_layout.py @@ -46,7 +46,7 @@ class ConfigureNozzleLayoutResult(BaseModel): class ConfigureNozzleLayoutImplementation( AbstractCommandImpl[ ConfigureNozzleLayoutParams, - SuccessData[ConfigureNozzleLayoutResult, None], + SuccessData[ConfigureNozzleLayoutResult], ] ): """Configure nozzle layout command implementation.""" @@ -59,7 +59,7 @@ def __init__( async def execute( self, params: ConfigureNozzleLayoutParams - ) -> SuccessData[ConfigureNozzleLayoutResult, None]: + ) -> SuccessData[ConfigureNozzleLayoutResult]: """Check that requested pipette can support the requested nozzle layout.""" primary_nozzle = params.configurationParams.dict().get("primaryNozzle") front_right_nozzle = params.configurationParams.dict().get("frontRightNozzle") @@ -84,7 +84,6 @@ async def execute( return SuccessData( public=ConfigureNozzleLayoutResult(), - private=None, state_update=update_state, ) diff --git a/api/src/opentrons/protocol_engine/commands/configuring_common.py b/api/src/opentrons/protocol_engine/commands/configuring_common.py deleted file mode 100644 index f69cf41fef6..00000000000 --- a/api/src/opentrons/protocol_engine/commands/configuring_common.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Common configuration command base models.""" - -from dataclasses import dataclass -from ..resources import pipette_data_provider - - -@dataclass -class PipetteConfigUpdateResultMixin: - """A mixin-suitable model for adding pipette config to private results.""" - - pipette_id: str - serial_number: str - config: pipette_data_provider.LoadedStaticPipetteData diff --git a/api/src/opentrons/protocol_engine/commands/custom.py b/api/src/opentrons/protocol_engine/commands/custom.py index 2ceebda764c..1bdf07084be 100644 --- a/api/src/opentrons/protocol_engine/commands/custom.py +++ b/api/src/opentrons/protocol_engine/commands/custom.py @@ -40,16 +40,18 @@ class Config: class CustomImplementation( - AbstractCommandImpl[CustomParams, SuccessData[CustomResult, None]] + AbstractCommandImpl[CustomParams, SuccessData[CustomResult]] ): """Custom command implementation.""" # TODO(mm, 2022-11-09): figure out how a plugin can specify a custom command # implementation. For now, always no-op, so we can use custom commands as containers # for legacy RPC (pre-ProtocolEngine) payloads. - async def execute(self, params: CustomParams) -> SuccessData[CustomResult, None]: + async def execute(self, params: CustomParams) -> SuccessData[CustomResult]: """A custom command does nothing when executed directly.""" - return SuccessData(public=CustomResult.construct(), private=None) + return SuccessData( + public=CustomResult.construct(), + ) class Custom(BaseCommand[CustomParams, CustomResult, ErrorOccurrence]): diff --git a/api/src/opentrons/protocol_engine/commands/dispense.py b/api/src/opentrons/protocol_engine/commands/dispense.py index 7e18cc6560b..603fa7396a7 100644 --- a/api/src/opentrons/protocol_engine/commands/dispense.py +++ b/api/src/opentrons/protocol_engine/commands/dispense.py @@ -8,7 +8,7 @@ from pydantic import Field from ..types import DeckPoint -from ..state.update_types import StateUpdate +from ..state.update_types import StateUpdate, CLEAR from .pipetting_common import ( PipetteIdMixin, DispenseVolumeMixin, @@ -54,7 +54,7 @@ class DispenseResult(BaseLiquidHandlingResult, DestinationPositionResult): _ExecuteReturn = Union[ - SuccessData[DispenseResult, None], + SuccessData[DispenseResult], DefinedErrorData[OverpressureError], ] @@ -107,6 +107,12 @@ async def execute(self, params: DispenseParams) -> _ExecuteReturn: push_out=params.pushOut, ) except PipetteOverpressureError as e: + state_update.set_liquid_operated( + labware_id=labware_id, + well_name=well_name, + volume_added=CLEAR, + ) + state_update.set_fluid_unknown(pipette_id=params.pipetteId) return DefinedErrorData( public=OverpressureError( id=self._model_utils.generate_id(), @@ -123,9 +129,19 @@ async def execute(self, params: DispenseParams) -> _ExecuteReturn: state_update=state_update, ) else: + volume_added = ( + self._state_view.pipettes.get_liquid_dispensed_by_ejecting_volume( + pipette_id=params.pipetteId, volume=volume + ) + ) + state_update.set_liquid_operated( + labware_id=labware_id, + well_name=well_name, + volume_added=volume_added if volume_added is not None else CLEAR, + ) + state_update.set_fluid_ejected(pipette_id=params.pipetteId, volume=volume) return SuccessData( public=DispenseResult(volume=volume, position=deck_point), - private=None, state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/dispense_in_place.py b/api/src/opentrons/protocol_engine/commands/dispense_in_place.py index 36f15e8e528..ee7cae42dc1 100644 --- a/api/src/opentrons/protocol_engine/commands/dispense_in_place.py +++ b/api/src/opentrons/protocol_engine/commands/dispense_in_place.py @@ -21,10 +21,13 @@ DefinedErrorData, ) from ..errors.error_occurrence import ErrorOccurrence +from ..state.update_types import StateUpdate, CLEAR +from ..types import CurrentWell if TYPE_CHECKING: from ..execution import PipettingHandler, GantryMover from ..resources import ModelUtils + from ..state.state import StateView DispenseInPlaceCommandType = Literal["dispenseInPlace"] @@ -46,7 +49,7 @@ class DispenseInPlaceResult(BaseLiquidHandlingResult): _ExecuteReturn = Union[ - SuccessData[DispenseInPlaceResult, None], + SuccessData[DispenseInPlaceResult], DefinedErrorData[OverpressureError], ] @@ -59,16 +62,20 @@ class DispenseInPlaceImplementation( def __init__( self, pipetting: PipettingHandler, + state_view: StateView, gantry_mover: GantryMover, model_utils: ModelUtils, **kwargs: object, ) -> None: self._pipetting = pipetting + self._state_view = state_view self._gantry_mover = gantry_mover self._model_utils = model_utils async def execute(self, params: DispenseInPlaceParams) -> _ExecuteReturn: """Dispense without moving the pipette.""" + state_update = StateUpdate() + current_location = self._state_view.pipettes.get_current_location() try: current_position = await self._gantry_mover.get_position(params.pipetteId) volume = await self._pipetting.dispense_in_place( @@ -78,6 +85,16 @@ async def execute(self, params: DispenseInPlaceParams) -> _ExecuteReturn: push_out=params.pushOut, ) except PipetteOverpressureError as e: + if ( + isinstance(current_location, CurrentWell) + and current_location.pipette_id == params.pipetteId + ): + state_update.set_liquid_operated( + labware_id=current_location.labware_id, + well_name=current_location.well_name, + volume_added=CLEAR, + ) + state_update.set_fluid_unknown(pipette_id=params.pipetteId) return DefinedErrorData( public=OverpressureError( id=self._model_utils.generate_id(), @@ -99,10 +116,27 @@ async def execute(self, params: DispenseInPlaceParams) -> _ExecuteReturn: } ), ), + state_update=state_update, ) else: + state_update.set_fluid_ejected(pipette_id=params.pipetteId, volume=volume) + if ( + isinstance(current_location, CurrentWell) + and current_location.pipette_id == params.pipetteId + ): + volume_added = ( + self._state_view.pipettes.get_liquid_dispensed_by_ejecting_volume( + pipette_id=params.pipetteId, volume=volume + ) + ) + state_update.set_liquid_operated( + labware_id=current_location.labware_id, + well_name=current_location.well_name, + volume_added=volume_added if volume_added is not None else CLEAR, + ) return SuccessData( - public=DispenseInPlaceResult(volume=volume), private=None + public=DispenseInPlaceResult(volume=volume), + state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/drop_tip.py b/api/src/opentrons/protocol_engine/commands/drop_tip.py index f4917a82195..ad0954c5a32 100644 --- a/api/src/opentrons/protocol_engine/commands/drop_tip.py +++ b/api/src/opentrons/protocol_engine/commands/drop_tip.py @@ -68,7 +68,7 @@ class DropTipResult(DestinationPositionResult): _ExecuteReturn = ( - SuccessData[DropTipResult, None] | DefinedErrorData[TipPhysicallyAttachedError] + SuccessData[DropTipResult] | DefinedErrorData[TipPhysicallyAttachedError] ) @@ -146,14 +146,23 @@ async def execute(self, params: DropTipParams) -> _ExecuteReturn: ) ], ) - return DefinedErrorData(public=error, state_update=state_update) + state_update_if_false_positive = update_types.StateUpdate() + state_update_if_false_positive.update_pipette_tip_state( + pipette_id=params.pipetteId, tip_geometry=None + ) + state_update.set_fluid_unknown(pipette_id=pipette_id) + return DefinedErrorData( + public=error, + state_update=state_update, + state_update_if_false_positive=state_update_if_false_positive, + ) else: + state_update.set_fluid_unknown(pipette_id=pipette_id) state_update.update_pipette_tip_state( pipette_id=params.pipetteId, tip_geometry=None ) return SuccessData( public=DropTipResult(position=deck_point), - private=None, state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/drop_tip_in_place.py b/api/src/opentrons/protocol_engine/commands/drop_tip_in_place.py index 81b47e05c08..0f98b32ff58 100644 --- a/api/src/opentrons/protocol_engine/commands/drop_tip_in_place.py +++ b/api/src/opentrons/protocol_engine/commands/drop_tip_in_place.py @@ -44,8 +44,7 @@ class DropTipInPlaceResult(BaseModel): _ExecuteReturn = ( - SuccessData[DropTipInPlaceResult, None] - | DefinedErrorData[TipPhysicallyAttachedError] + SuccessData[DropTipInPlaceResult] | DefinedErrorData[TipPhysicallyAttachedError] ) @@ -66,12 +65,16 @@ def __init__( async def execute(self, params: DropTipInPlaceParams) -> _ExecuteReturn: """Drop a tip using the requested pipette.""" state_update = update_types.StateUpdate() - try: await self._tip_handler.drop_tip( pipette_id=params.pipetteId, home_after=params.homeAfter ) except TipAttachedError as exception: + state_update_if_false_positive = update_types.StateUpdate() + state_update_if_false_positive.update_pipette_tip_state( + pipette_id=params.pipetteId, tip_geometry=None + ) + state_update.set_fluid_unknown(pipette_id=params.pipetteId) error = TipPhysicallyAttachedError( id=self._model_utils.generate_id(), createdAt=self._model_utils.get_timestamp(), @@ -83,14 +86,17 @@ async def execute(self, params: DropTipInPlaceParams) -> _ExecuteReturn: ) ], ) - return DefinedErrorData(public=error, state_update=state_update) + return DefinedErrorData( + public=error, + state_update=state_update, + state_update_if_false_positive=state_update_if_false_positive, + ) else: + state_update.set_fluid_unknown(pipette_id=params.pipetteId) state_update.update_pipette_tip_state( pipette_id=params.pipetteId, tip_geometry=None ) - return SuccessData( - public=DropTipInPlaceResult(), private=None, state_update=state_update - ) + return SuccessData(public=DropTipInPlaceResult(), state_update=state_update) class DropTipInPlace( diff --git a/api/src/opentrons/protocol_engine/commands/get_tip_presence.py b/api/src/opentrons/protocol_engine/commands/get_tip_presence.py index 6c4eea93a84..6bbe5fa2fe3 100644 --- a/api/src/opentrons/protocol_engine/commands/get_tip_presence.py +++ b/api/src/opentrons/protocol_engine/commands/get_tip_presence.py @@ -38,7 +38,7 @@ class GetTipPresenceResult(BaseModel): class GetTipPresenceImplementation( - AbstractCommandImpl[GetTipPresenceParams, SuccessData[GetTipPresenceResult, None]] + AbstractCommandImpl[GetTipPresenceParams, SuccessData[GetTipPresenceResult]] ): """GetTipPresence command implementation.""" @@ -51,7 +51,7 @@ def __init__( async def execute( self, params: GetTipPresenceParams - ) -> SuccessData[GetTipPresenceResult, None]: + ) -> SuccessData[GetTipPresenceResult]: """Verify if tip presence is as expected for the requested pipette.""" pipette_id = params.pipetteId @@ -59,7 +59,9 @@ async def execute( pipette_id=pipette_id, ) - return SuccessData(public=GetTipPresenceResult(status=result), private=None) + return SuccessData( + public=GetTipPresenceResult(status=result), + ) class GetTipPresence( diff --git a/api/src/opentrons/protocol_engine/commands/heater_shaker/close_labware_latch.py b/api/src/opentrons/protocol_engine/commands/heater_shaker/close_labware_latch.py index f9af6438958..2151fb05877 100644 --- a/api/src/opentrons/protocol_engine/commands/heater_shaker/close_labware_latch.py +++ b/api/src/opentrons/protocol_engine/commands/heater_shaker/close_labware_latch.py @@ -27,9 +27,7 @@ class CloseLabwareLatchResult(BaseModel): class CloseLabwareLatchImpl( - AbstractCommandImpl[ - CloseLabwareLatchParams, SuccessData[CloseLabwareLatchResult, None] - ] + AbstractCommandImpl[CloseLabwareLatchParams, SuccessData[CloseLabwareLatchResult]] ): """Execution implementation of a Heater-Shaker's close labware latch command.""" @@ -44,7 +42,7 @@ def __init__( async def execute( self, params: CloseLabwareLatchParams - ) -> SuccessData[CloseLabwareLatchResult, None]: + ) -> SuccessData[CloseLabwareLatchResult]: """Close a Heater-Shaker's labware latch.""" # Allow propagation of ModuleNotLoadedError and WrongModuleTypeError. hs_module_substate = self._state_view.modules.get_heater_shaker_module_substate( @@ -59,7 +57,9 @@ async def execute( if hs_hardware_module is not None: await hs_hardware_module.close_labware_latch() - return SuccessData(public=CloseLabwareLatchResult(), private=None) + return SuccessData( + public=CloseLabwareLatchResult(), + ) class CloseLabwareLatch( diff --git a/api/src/opentrons/protocol_engine/commands/heater_shaker/deactivate_heater.py b/api/src/opentrons/protocol_engine/commands/heater_shaker/deactivate_heater.py index fb512b72319..3932f1d6490 100644 --- a/api/src/opentrons/protocol_engine/commands/heater_shaker/deactivate_heater.py +++ b/api/src/opentrons/protocol_engine/commands/heater_shaker/deactivate_heater.py @@ -27,9 +27,7 @@ class DeactivateHeaterResult(BaseModel): class DeactivateHeaterImpl( - AbstractCommandImpl[ - DeactivateHeaterParams, SuccessData[DeactivateHeaterResult, None] - ] + AbstractCommandImpl[DeactivateHeaterParams, SuccessData[DeactivateHeaterResult]] ): """Execution implementation of a Heater-Shaker's deactivate heater command.""" @@ -44,7 +42,7 @@ def __init__( async def execute( self, params: DeactivateHeaterParams - ) -> SuccessData[DeactivateHeaterResult, None]: + ) -> SuccessData[DeactivateHeaterResult]: """Unset a Heater-Shaker's target temperature.""" hs_module_substate = self._state_view.modules.get_heater_shaker_module_substate( module_id=params.moduleId @@ -58,7 +56,9 @@ async def execute( if hs_hardware_module is not None: await hs_hardware_module.deactivate_heater() - return SuccessData(public=DeactivateHeaterResult(), private=None) + return SuccessData( + public=DeactivateHeaterResult(), + ) class DeactivateHeater( diff --git a/api/src/opentrons/protocol_engine/commands/heater_shaker/deactivate_shaker.py b/api/src/opentrons/protocol_engine/commands/heater_shaker/deactivate_shaker.py index bc06b9767c4..b259745ea3d 100644 --- a/api/src/opentrons/protocol_engine/commands/heater_shaker/deactivate_shaker.py +++ b/api/src/opentrons/protocol_engine/commands/heater_shaker/deactivate_shaker.py @@ -26,9 +26,7 @@ class DeactivateShakerResult(BaseModel): class DeactivateShakerImpl( - AbstractCommandImpl[ - DeactivateShakerParams, SuccessData[DeactivateShakerResult, None] - ] + AbstractCommandImpl[DeactivateShakerParams, SuccessData[DeactivateShakerResult]] ): """Execution implementation of a Heater-Shaker's deactivate shaker command.""" @@ -43,7 +41,7 @@ def __init__( async def execute( self, params: DeactivateShakerParams - ) -> SuccessData[DeactivateShakerResult, None]: + ) -> SuccessData[DeactivateShakerResult]: """Deactivate shaker for a Heater-Shaker.""" # Allow propagation of ModuleNotLoadedError and WrongModuleTypeError. hs_module_substate = self._state_view.modules.get_heater_shaker_module_substate( @@ -60,7 +58,9 @@ async def execute( if hs_hardware_module is not None: await hs_hardware_module.deactivate_shaker() - return SuccessData(public=DeactivateShakerResult(), private=None) + return SuccessData( + public=DeactivateShakerResult(), + ) class DeactivateShaker( diff --git a/api/src/opentrons/protocol_engine/commands/heater_shaker/open_labware_latch.py b/api/src/opentrons/protocol_engine/commands/heater_shaker/open_labware_latch.py index e39a2e200bf..9c3a9d8ae7d 100644 --- a/api/src/opentrons/protocol_engine/commands/heater_shaker/open_labware_latch.py +++ b/api/src/opentrons/protocol_engine/commands/heater_shaker/open_labware_latch.py @@ -34,9 +34,7 @@ class OpenLabwareLatchResult(BaseModel): class OpenLabwareLatchImpl( - AbstractCommandImpl[ - OpenLabwareLatchParams, SuccessData[OpenLabwareLatchResult, None] - ] + AbstractCommandImpl[OpenLabwareLatchParams, SuccessData[OpenLabwareLatchResult]] ): """Execution implementation of a Heater-Shaker's open latch labware command.""" @@ -53,7 +51,7 @@ def __init__( async def execute( self, params: OpenLabwareLatchParams - ) -> SuccessData[OpenLabwareLatchResult, None]: + ) -> SuccessData[OpenLabwareLatchResult]: """Open a Heater-Shaker's labware latch.""" state_update = update_types.StateUpdate() @@ -87,7 +85,6 @@ async def execute( return SuccessData( public=OpenLabwareLatchResult(pipetteRetracted=pipette_should_retract), - private=None, state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/heater_shaker/set_and_wait_for_shake_speed.py b/api/src/opentrons/protocol_engine/commands/heater_shaker/set_and_wait_for_shake_speed.py index e3cf35142d6..8828195c658 100644 --- a/api/src/opentrons/protocol_engine/commands/heater_shaker/set_and_wait_for_shake_speed.py +++ b/api/src/opentrons/protocol_engine/commands/heater_shaker/set_and_wait_for_shake_speed.py @@ -36,7 +36,7 @@ class SetAndWaitForShakeSpeedResult(BaseModel): class SetAndWaitForShakeSpeedImpl( AbstractCommandImpl[ - SetAndWaitForShakeSpeedParams, SuccessData[SetAndWaitForShakeSpeedResult, None] + SetAndWaitForShakeSpeedParams, SuccessData[SetAndWaitForShakeSpeedResult] ] ): """Execution implementation of Heater-Shaker's set and wait shake speed command.""" @@ -55,7 +55,7 @@ def __init__( async def execute( self, params: SetAndWaitForShakeSpeedParams, - ) -> SuccessData[SetAndWaitForShakeSpeedResult, None]: + ) -> SuccessData[SetAndWaitForShakeSpeedResult]: """Set and wait for a Heater-Shaker's target shake speed.""" state_update = update_types.StateUpdate() @@ -94,7 +94,6 @@ async def execute( public=SetAndWaitForShakeSpeedResult( pipetteRetracted=pipette_should_retract ), - private=None, state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/heater_shaker/set_target_temperature.py b/api/src/opentrons/protocol_engine/commands/heater_shaker/set_target_temperature.py index 854004dabae..fa29390b910 100644 --- a/api/src/opentrons/protocol_engine/commands/heater_shaker/set_target_temperature.py +++ b/api/src/opentrons/protocol_engine/commands/heater_shaker/set_target_temperature.py @@ -29,7 +29,7 @@ class SetTargetTemperatureResult(BaseModel): class SetTargetTemperatureImpl( AbstractCommandImpl[ - SetTargetTemperatureParams, SuccessData[SetTargetTemperatureResult, None] + SetTargetTemperatureParams, SuccessData[SetTargetTemperatureResult] ] ): """Execution implementation of a Heater-Shaker's set temperature command.""" @@ -46,7 +46,7 @@ def __init__( async def execute( self, params: SetTargetTemperatureParams, - ) -> SuccessData[SetTargetTemperatureResult, None]: + ) -> SuccessData[SetTargetTemperatureResult]: """Set a Heater-Shaker's target temperature.""" # Allow propagation of ModuleNotLoadedError and WrongModuleTypeError. hs_module_substate = self._state_view.modules.get_heater_shaker_module_substate( @@ -64,7 +64,9 @@ async def execute( if hs_hardware_module is not None: await hs_hardware_module.start_set_temperature(validated_temp) - return SuccessData(public=SetTargetTemperatureResult(), private=None) + return SuccessData( + public=SetTargetTemperatureResult(), + ) class SetTargetTemperature( diff --git a/api/src/opentrons/protocol_engine/commands/heater_shaker/wait_for_temperature.py b/api/src/opentrons/protocol_engine/commands/heater_shaker/wait_for_temperature.py index fbd7ee24743..bb440a2674c 100644 --- a/api/src/opentrons/protocol_engine/commands/heater_shaker/wait_for_temperature.py +++ b/api/src/opentrons/protocol_engine/commands/heater_shaker/wait_for_temperature.py @@ -36,9 +36,7 @@ class WaitForTemperatureResult(BaseModel): class WaitForTemperatureImpl( - AbstractCommandImpl[ - WaitForTemperatureParams, SuccessData[WaitForTemperatureResult, None] - ] + AbstractCommandImpl[WaitForTemperatureParams, SuccessData[WaitForTemperatureResult]] ): """Execution implementation of a Heater-Shaker's wait for temperature command.""" @@ -53,7 +51,7 @@ def __init__( async def execute( self, params: WaitForTemperatureParams - ) -> SuccessData[WaitForTemperatureResult, None]: + ) -> SuccessData[WaitForTemperatureResult]: """Wait for a Heater-Shaker's target temperature to be reached.""" hs_module_substate = self._state_view.modules.get_heater_shaker_module_substate( module_id=params.moduleId @@ -72,7 +70,9 @@ async def execute( if hs_hardware_module is not None: await hs_hardware_module.await_temperature(awaiting_temperature=target_temp) - return SuccessData(public=WaitForTemperatureResult(), private=None) + return SuccessData( + public=WaitForTemperatureResult(), + ) class WaitForTemperature( diff --git a/api/src/opentrons/protocol_engine/commands/home.py b/api/src/opentrons/protocol_engine/commands/home.py index 93d988772dc..7b82f90e1fd 100644 --- a/api/src/opentrons/protocol_engine/commands/home.py +++ b/api/src/opentrons/protocol_engine/commands/home.py @@ -42,15 +42,13 @@ class HomeResult(BaseModel): """Result data from the execution of a Home command.""" -class HomeImplementation( - AbstractCommandImpl[HomeParams, SuccessData[HomeResult, None]] -): +class HomeImplementation(AbstractCommandImpl[HomeParams, SuccessData[HomeResult]]): """Home command implementation.""" def __init__(self, movement: MovementHandler, **kwargs: object) -> None: self._movement = movement - async def execute(self, params: HomeParams) -> SuccessData[HomeResult, None]: + async def execute(self, params: HomeParams) -> SuccessData[HomeResult]: """Home some or all motors to establish positional accuracy.""" state_update = update_types.StateUpdate() @@ -66,7 +64,7 @@ async def execute(self, params: HomeParams) -> SuccessData[HomeResult, None]: # preserve prior behavior, but we might only want to do this if we actually home. state_update.clear_all_pipette_locations() - return SuccessData(public=HomeResult(), private=None, state_update=state_update) + return SuccessData(public=HomeResult(), state_update=state_update) class Home(BaseCommand[HomeParams, HomeResult, ErrorOccurrence]): diff --git a/api/src/opentrons/protocol_engine/commands/liquid_probe.py b/api/src/opentrons/protocol_engine/commands/liquid_probe.py index 1a8597f9c03..f78cd5bb55c 100644 --- a/api/src/opentrons/protocol_engine/commands/liquid_probe.py +++ b/api/src/opentrons/protocol_engine/commands/liquid_probe.py @@ -11,6 +11,7 @@ MustHomeError, PipetteNotReadyToAspirateError, TipNotEmptyError, + IncompleteLabwareDefinitionError, ) from opentrons.types import MountType from opentrons_shared_data.errors.exceptions import ( @@ -85,10 +86,10 @@ class TryLiquidProbeResult(DestinationPositionResult): _LiquidProbeExecuteReturn = Union[ - SuccessData[LiquidProbeResult, None], + SuccessData[LiquidProbeResult], DefinedErrorData[LiquidNotFoundError], ] -_TryLiquidProbeExecuteReturn = SuccessData[TryLiquidProbeResult, None] +_TryLiquidProbeExecuteReturn = SuccessData[TryLiquidProbeResult] class _ExecuteCommonResult(NamedTuple): @@ -205,6 +206,13 @@ async def execute(self, params: _CommonParams) -> _LiquidProbeExecuteReturn: self._state_view, self._movement, self._pipetting, params ) if isinstance(z_pos_or_error, PipetteLiquidNotFoundError): + state_update.set_liquid_probed( + labware_id=params.labwareId, + well_name=params.wellName, + height=update_types.CLEAR, + volume=update_types.CLEAR, + last_probed=self._model_utils.get_timestamp(), + ) return DefinedErrorData( public=LiquidNotFoundError( id=self._model_utils.generate_id(), @@ -220,11 +228,27 @@ async def execute(self, params: _CommonParams) -> _LiquidProbeExecuteReturn: state_update=state_update, ) else: + try: + well_volume: float | update_types.ClearType = ( + self._state_view.geometry.get_well_volume_at_height( + labware_id=params.labwareId, + well_name=params.wellName, + height=z_pos_or_error, + ) + ) + except IncompleteLabwareDefinitionError: + well_volume = update_types.CLEAR + state_update.set_liquid_probed( + labware_id=params.labwareId, + well_name=params.wellName, + height=z_pos_or_error, + volume=well_volume, + last_probed=self._model_utils.get_timestamp(), + ) return SuccessData( public=LiquidProbeResult( z_position=z_pos_or_error, position=deck_point ), - private=None, state_update=state_update, ) @@ -239,11 +263,13 @@ def __init__( state_view: StateView, movement: MovementHandler, pipetting: PipettingHandler, + model_utils: ModelUtils, **kwargs: object, ) -> None: self._state_view = state_view self._movement = movement self._pipetting = pipetting + self._model_utils = model_utils async def execute(self, params: _CommonParams) -> _TryLiquidProbeExecuteReturn: """Execute a `tryLiquidProbe` command. @@ -256,17 +282,31 @@ async def execute(self, params: _CommonParams) -> _TryLiquidProbeExecuteReturn: self._state_view, self._movement, self._pipetting, params ) - z_pos = ( - None - if isinstance(z_pos_or_error, PipetteLiquidNotFoundError) - else z_pos_or_error + if isinstance(z_pos_or_error, PipetteLiquidNotFoundError): + z_pos = None + well_volume: float | update_types.ClearType = update_types.CLEAR + else: + z_pos = z_pos_or_error + try: + well_volume = self._state_view.geometry.get_well_volume_at_height( + labware_id=params.labwareId, well_name=params.wellName, height=z_pos + ) + except IncompleteLabwareDefinitionError: + well_volume = update_types.CLEAR + + state_update.set_liquid_probed( + labware_id=params.labwareId, + well_name=params.wellName, + height=z_pos if z_pos is not None else update_types.CLEAR, + volume=well_volume, + last_probed=self._model_utils.get_timestamp(), ) + return SuccessData( public=TryLiquidProbeResult( z_position=z_pos, position=deck_point, ), - private=None, state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/load_labware.py b/api/src/opentrons/protocol_engine/commands/load_labware.py index 2de394c482c..05eccb95a7a 100644 --- a/api/src/opentrons/protocol_engine/commands/load_labware.py +++ b/api/src/opentrons/protocol_engine/commands/load_labware.py @@ -88,7 +88,7 @@ class LoadLabwareResult(BaseModel): class LoadLabwareImplementation( - AbstractCommandImpl[LoadLabwareParams, SuccessData[LoadLabwareResult, None]] + AbstractCommandImpl[LoadLabwareParams, SuccessData[LoadLabwareResult]] ): """Load labware command implementation.""" @@ -100,7 +100,7 @@ def __init__( async def execute( self, params: LoadLabwareParams - ) -> SuccessData[LoadLabwareResult, None]: + ) -> SuccessData[LoadLabwareResult]: """Load definition and calibration data necessary for a labware.""" # TODO (tz, 8-15-2023): extend column validation to column 1 when working # on https://opentrons.atlassian.net/browse/RSS-258 and completing @@ -167,7 +167,6 @@ async def execute( definition=loaded_labware.definition, offsetId=loaded_labware.offsetId, ), - private=None, state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/load_liquid.py b/api/src/opentrons/protocol_engine/commands/load_liquid.py index 856cf3ee127..f6aa037fa01 100644 --- a/api/src/opentrons/protocol_engine/commands/load_liquid.py +++ b/api/src/opentrons/protocol_engine/commands/load_liquid.py @@ -4,11 +4,16 @@ from typing import Optional, Type, Dict, TYPE_CHECKING from typing_extensions import Literal +from opentrons.protocol_engine.state.update_types import StateUpdate +from opentrons.protocol_engine.types import LiquidId +from opentrons.protocol_engine.errors import InvalidLiquidError + from .command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData from ..errors.error_occurrence import ErrorOccurrence if TYPE_CHECKING: from ..state.state import StateView + from ..resources import ModelUtils LoadLiquidCommandType = Literal["loadLiquid"] @@ -16,9 +21,9 @@ class LoadLiquidParams(BaseModel): """Payload required to load a liquid into a well.""" - liquidId: str = Field( + liquidId: LiquidId = Field( ..., - description="Unique identifier of the liquid to load.", + description="Unique identifier of the liquid to load. If this is the sentinel value EMPTY, all values of volumeByWell must be 0.", ) labwareId: str = Field( ..., @@ -26,7 +31,7 @@ class LoadLiquidParams(BaseModel): ) volumeByWell: Dict[str, float] = Field( ..., - description="Volume of liquid, in µL, loaded into each well by name, in this labware.", + description="Volume of liquid, in µL, loaded into each well by name, in this labware. If the liquid id is the sentinel value EMPTY, all volumes must be 0.", ) @@ -37,24 +42,38 @@ class LoadLiquidResult(BaseModel): class LoadLiquidImplementation( - AbstractCommandImpl[LoadLiquidParams, SuccessData[LoadLiquidResult, None]] + AbstractCommandImpl[LoadLiquidParams, SuccessData[LoadLiquidResult]] ): """Load liquid command implementation.""" - def __init__(self, state_view: StateView, **kwargs: object) -> None: + def __init__( + self, state_view: StateView, model_utils: ModelUtils, **kwargs: object + ) -> None: self._state_view = state_view + self._model_utils = model_utils - async def execute( - self, params: LoadLiquidParams - ) -> SuccessData[LoadLiquidResult, None]: + async def execute(self, params: LoadLiquidParams) -> SuccessData[LoadLiquidResult]: """Load data necessary for a liquid.""" self._state_view.liquid.validate_liquid_id(params.liquidId) self._state_view.labware.validate_liquid_allowed_in_labware( labware_id=params.labwareId, wells=params.volumeByWell ) + if params.liquidId == "EMPTY": + for well_name, volume in params.volumeByWell.items(): + if volume != 0.0: + raise InvalidLiquidError( + 'loadLiquid commands that specify the special liquid "EMPTY" must set volume to be 0.0, but the volume for {well_name} is {volume}' + ) + + state_update = StateUpdate() + state_update.set_liquid_loaded( + labware_id=params.labwareId, + volumes=params.volumeByWell, + last_loaded=self._model_utils.get_timestamp(), + ) - return SuccessData(public=LoadLiquidResult(), private=None) + return SuccessData(public=LoadLiquidResult(), state_update=state_update) class LoadLiquid(BaseCommand[LoadLiquidParams, LoadLiquidResult, ErrorOccurrence]): diff --git a/api/src/opentrons/protocol_engine/commands/load_module.py b/api/src/opentrons/protocol_engine/commands/load_module.py index f8127658ea0..9560f4931c3 100644 --- a/api/src/opentrons/protocol_engine/commands/load_module.py +++ b/api/src/opentrons/protocol_engine/commands/load_module.py @@ -106,7 +106,7 @@ class LoadModuleResult(BaseModel): class LoadModuleImplementation( - AbstractCommandImpl[LoadModuleParams, SuccessData[LoadModuleResult, None]] + AbstractCommandImpl[LoadModuleParams, SuccessData[LoadModuleResult]] ): """The implementation of the load module command.""" @@ -116,9 +116,7 @@ def __init__( self._equipment = equipment self._state_view = state_view - async def execute( - self, params: LoadModuleParams - ) -> SuccessData[LoadModuleResult, None]: + async def execute(self, params: LoadModuleParams) -> SuccessData[LoadModuleResult]: """Check that the requested module is attached and assign its identifier.""" module_type = params.model.as_type() self._ensure_module_location(params.location.slotName, module_type) @@ -198,7 +196,6 @@ async def execute( model=loaded_module.definition.model, definition=loaded_module.definition, ), - private=None, ) def _ensure_module_location( diff --git a/api/src/opentrons/protocol_engine/commands/load_pipette.py b/api/src/opentrons/protocol_engine/commands/load_pipette.py index 5961272ae7c..6d8d74b4fa2 100644 --- a/api/src/opentrons/protocol_engine/commands/load_pipette.py +++ b/api/src/opentrons/protocol_engine/commands/load_pipette.py @@ -17,7 +17,6 @@ from .command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData from ..errors.error_occurrence import ErrorOccurrence -from .configuring_common import PipetteConfigUpdateResultMixin from ..errors import InvalidSpecificationForRobotTypeError, InvalidLoadPipetteSpecsError if TYPE_CHECKING: @@ -28,12 +27,6 @@ LoadPipetteCommandType = Literal["loadPipette"] -class LoadPipettePrivateResult(PipetteConfigUpdateResultMixin): - """The not-to-be-exposed results of a load pipette call.""" - - ... - - class LoadPipetteParams(BaseModel): """Payload needed to load a pipette on to a mount.""" @@ -73,9 +66,7 @@ class LoadPipetteResult(BaseModel): class LoadPipetteImplementation( - AbstractCommandImpl[ - LoadPipetteParams, SuccessData[LoadPipetteResult, LoadPipettePrivateResult] - ] + AbstractCommandImpl[LoadPipetteParams, SuccessData[LoadPipetteResult]] ): """Load pipette command implementation.""" @@ -87,7 +78,7 @@ def __init__( async def execute( self, params: LoadPipetteParams - ) -> SuccessData[LoadPipetteResult, LoadPipettePrivateResult]: + ) -> SuccessData[LoadPipetteResult]: """Check that requested pipette is attached and assign its identifier.""" pipette_generation = convert_to_pipette_name_type( params.pipetteName.value @@ -136,14 +127,10 @@ async def execute( serial_number=loaded_pipette.serial_number, config=loaded_pipette.static_config, ) + state_update.set_fluid_unknown(pipette_id=loaded_pipette.pipette_id) return SuccessData( public=LoadPipetteResult(pipetteId=loaded_pipette.pipette_id), - private=LoadPipettePrivateResult( - pipette_id=loaded_pipette.pipette_id, - serial_number=loaded_pipette.serial_number, - config=loaded_pipette.static_config, - ), state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/magnetic_module/disengage.py b/api/src/opentrons/protocol_engine/commands/magnetic_module/disengage.py index a1be2c8480f..c20b18e481d 100644 --- a/api/src/opentrons/protocol_engine/commands/magnetic_module/disengage.py +++ b/api/src/opentrons/protocol_engine/commands/magnetic_module/disengage.py @@ -38,7 +38,7 @@ class DisengageResult(BaseModel): class DisengageImplementation( - AbstractCommandImpl[DisengageParams, SuccessData[DisengageResult, None]] + AbstractCommandImpl[DisengageParams, SuccessData[DisengageResult]] ): """The implementation of a Magnetic Module disengage command.""" @@ -51,9 +51,7 @@ def __init__( self._state_view = state_view self._equipment = equipment - async def execute( - self, params: DisengageParams - ) -> SuccessData[DisengageResult, None]: + async def execute(self, params: DisengageParams) -> SuccessData[DisengageResult]: """Execute a Magnetic Module disengage command. Raises: @@ -75,7 +73,9 @@ async def execute( if hardware_module is not None: # Not virtualizing modules. await hardware_module.deactivate() - return SuccessData(public=DisengageResult(), private=None) + return SuccessData( + public=DisengageResult(), + ) class Disengage(BaseCommand[DisengageParams, DisengageResult, ErrorOccurrence]): diff --git a/api/src/opentrons/protocol_engine/commands/magnetic_module/engage.py b/api/src/opentrons/protocol_engine/commands/magnetic_module/engage.py index 3796f43a022..62f4e24eef4 100644 --- a/api/src/opentrons/protocol_engine/commands/magnetic_module/engage.py +++ b/api/src/opentrons/protocol_engine/commands/magnetic_module/engage.py @@ -54,7 +54,7 @@ class EngageResult(BaseModel): class EngageImplementation( - AbstractCommandImpl[EngageParams, SuccessData[EngageResult, None]] + AbstractCommandImpl[EngageParams, SuccessData[EngageResult]] ): """The implementation of a Magnetic Module engage command.""" @@ -67,7 +67,7 @@ def __init__( self._state_view = state_view self._equipment = equipment - async def execute(self, params: EngageParams) -> SuccessData[EngageResult, None]: + async def execute(self, params: EngageParams) -> SuccessData[EngageResult]: """Execute a Magnetic Module engage command. Raises: @@ -95,7 +95,9 @@ async def execute(self, params: EngageParams) -> SuccessData[EngageResult, None] if hardware_module is not None: # Not virtualizing modules. await hardware_module.engage(height=hardware_height) - return SuccessData(public=EngageResult(), private=None) + return SuccessData( + public=EngageResult(), + ) class Engage(BaseCommand[EngageParams, EngageResult, ErrorOccurrence]): diff --git a/api/src/opentrons/protocol_engine/commands/move_labware.py b/api/src/opentrons/protocol_engine/commands/move_labware.py index d5c188d219f..0d2967e87d5 100644 --- a/api/src/opentrons/protocol_engine/commands/move_labware.py +++ b/api/src/opentrons/protocol_engine/commands/move_labware.py @@ -98,9 +98,7 @@ class GripperMovementError(ErrorOccurrence): errorType: Literal["gripperMovement"] = "gripperMovement" -_ExecuteReturn = ( - SuccessData[MoveLabwareResult, None] | DefinedErrorData[GripperMovementError] -) +_ExecuteReturn = SuccessData[MoveLabwareResult] | DefinedErrorData[GripperMovementError] class MoveLabwareImplementation(AbstractCommandImpl[MoveLabwareParams, _ExecuteReturn]): @@ -186,6 +184,10 @@ async def execute(self, params: MoveLabwareParams) -> _ExecuteReturn: # noqa: C top_labware_definition=current_labware_definition, bottom_labware_id=available_new_location.labwareId, ) + if params.labwareId == available_new_location.labwareId: + raise LabwareMovementNotAllowedError( + "Cannot move a labware onto itself." + ) # Allow propagation of ModuleNotLoadedError. new_offset_id = self._equipment.find_applicable_labware_offset_id( @@ -297,7 +299,6 @@ async def execute(self, params: MoveLabwareParams) -> _ExecuteReturn: # noqa: C return SuccessData( public=MoveLabwareResult(offsetId=new_offset_id), - private=None, state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/move_relative.py b/api/src/opentrons/protocol_engine/commands/move_relative.py index dc559648ea3..9133725727d 100644 --- a/api/src/opentrons/protocol_engine/commands/move_relative.py +++ b/api/src/opentrons/protocol_engine/commands/move_relative.py @@ -39,7 +39,7 @@ class MoveRelativeResult(DestinationPositionResult): class MoveRelativeImplementation( - AbstractCommandImpl[MoveRelativeParams, SuccessData[MoveRelativeResult, None]] + AbstractCommandImpl[MoveRelativeParams, SuccessData[MoveRelativeResult]] ): """Move relative command implementation.""" @@ -48,7 +48,7 @@ def __init__(self, movement: MovementHandler, **kwargs: object) -> None: async def execute( self, params: MoveRelativeParams - ) -> SuccessData[MoveRelativeResult, None]: + ) -> SuccessData[MoveRelativeResult]: """Move (jog) a given pipette a relative distance.""" state_update = update_types.StateUpdate() @@ -67,7 +67,6 @@ async def execute( return SuccessData( public=MoveRelativeResult(position=deck_point), - private=None, state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/move_to_addressable_area.py b/api/src/opentrons/protocol_engine/commands/move_to_addressable_area.py index cfdfbe77133..8247f54a266 100644 --- a/api/src/opentrons/protocol_engine/commands/move_to_addressable_area.py +++ b/api/src/opentrons/protocol_engine/commands/move_to_addressable_area.py @@ -76,7 +76,7 @@ class MoveToAddressableAreaResult(DestinationPositionResult): class MoveToAddressableAreaImplementation( AbstractCommandImpl[ - MoveToAddressableAreaParams, SuccessData[MoveToAddressableAreaResult, None] + MoveToAddressableAreaParams, SuccessData[MoveToAddressableAreaResult] ] ): """Move to addressable area command implementation.""" @@ -89,7 +89,7 @@ def __init__( async def execute( self, params: MoveToAddressableAreaParams - ) -> SuccessData[MoveToAddressableAreaResult, None]: + ) -> SuccessData[MoveToAddressableAreaResult]: """Move the requested pipette to the requested addressable area.""" state_update = update_types.StateUpdate() @@ -134,7 +134,6 @@ async def execute( return SuccessData( public=MoveToAddressableAreaResult(position=DeckPoint(x=x, y=y, z=z)), - private=None, state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/move_to_addressable_area_for_drop_tip.py b/api/src/opentrons/protocol_engine/commands/move_to_addressable_area_for_drop_tip.py index 44244dcb25c..1c151f1e605 100644 --- a/api/src/opentrons/protocol_engine/commands/move_to_addressable_area_for_drop_tip.py +++ b/api/src/opentrons/protocol_engine/commands/move_to_addressable_area_for_drop_tip.py @@ -86,7 +86,7 @@ class MoveToAddressableAreaForDropTipResult(DestinationPositionResult): class MoveToAddressableAreaForDropTipImplementation( AbstractCommandImpl[ MoveToAddressableAreaForDropTipParams, - SuccessData[MoveToAddressableAreaForDropTipResult, None], + SuccessData[MoveToAddressableAreaForDropTipResult], ] ): """Move to addressable area for drop tip command implementation.""" @@ -99,7 +99,7 @@ def __init__( async def execute( self, params: MoveToAddressableAreaForDropTipParams - ) -> SuccessData[MoveToAddressableAreaForDropTipResult, None]: + ) -> SuccessData[MoveToAddressableAreaForDropTipResult]: """Move the requested pipette to the requested addressable area in preperation of a drop tip.""" state_update = update_types.StateUpdate() @@ -140,7 +140,6 @@ async def execute( public=MoveToAddressableAreaForDropTipResult( position=DeckPoint(x=x, y=y, z=z) ), - private=None, state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/move_to_coordinates.py b/api/src/opentrons/protocol_engine/commands/move_to_coordinates.py index fbc9f20e790..d7a0919d238 100644 --- a/api/src/opentrons/protocol_engine/commands/move_to_coordinates.py +++ b/api/src/opentrons/protocol_engine/commands/move_to_coordinates.py @@ -35,9 +35,7 @@ class MoveToCoordinatesResult(DestinationPositionResult): class MoveToCoordinatesImplementation( - AbstractCommandImpl[ - MoveToCoordinatesParams, SuccessData[MoveToCoordinatesResult, None] - ] + AbstractCommandImpl[MoveToCoordinatesParams, SuccessData[MoveToCoordinatesResult]] ): """Move to coordinates command implementation.""" @@ -50,7 +48,7 @@ def __init__( async def execute( self, params: MoveToCoordinatesParams - ) -> SuccessData[MoveToCoordinatesResult, None]: + ) -> SuccessData[MoveToCoordinatesResult]: """Move the requested pipette to the requested coordinates.""" state_update = update_types.StateUpdate() @@ -68,7 +66,6 @@ async def execute( return SuccessData( public=MoveToCoordinatesResult(position=DeckPoint(x=x, y=y, z=z)), - private=None, state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/move_to_well.py b/api/src/opentrons/protocol_engine/commands/move_to_well.py index 309f2e89513..49ab10111a4 100644 --- a/api/src/opentrons/protocol_engine/commands/move_to_well.py +++ b/api/src/opentrons/protocol_engine/commands/move_to_well.py @@ -35,7 +35,7 @@ class MoveToWellResult(DestinationPositionResult): class MoveToWellImplementation( - AbstractCommandImpl[MoveToWellParams, SuccessData[MoveToWellResult, None]] + AbstractCommandImpl[MoveToWellParams, SuccessData[MoveToWellResult]] ): """Move to well command implementation.""" @@ -45,9 +45,7 @@ def __init__( self._state_view = state_view self._movement = movement - async def execute( - self, params: MoveToWellParams - ) -> SuccessData[MoveToWellResult, None]: + async def execute(self, params: MoveToWellParams) -> SuccessData[MoveToWellResult]: """Move the requested pipette to the requested well.""" pipette_id = params.pipetteId labware_id = params.labwareId @@ -83,7 +81,6 @@ async def execute( return SuccessData( public=MoveToWellResult(position=deck_point), - private=None, state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/pick_up_tip.py b/api/src/opentrons/protocol_engine/commands/pick_up_tip.py index 5ccdcfc6f3a..86967c6502f 100644 --- a/api/src/opentrons/protocol_engine/commands/pick_up_tip.py +++ b/api/src/opentrons/protocol_engine/commands/pick_up_tip.py @@ -6,7 +6,7 @@ from typing_extensions import Literal -from ..errors import ErrorOccurrence, TipNotAttachedError +from ..errors import ErrorOccurrence, PickUpTipTipNotAttachedError from ..resources import ModelUtils from ..state import update_types from ..types import PickUpTipWellLocation, DeckPoint @@ -86,7 +86,7 @@ class TipPhysicallyMissingError(ErrorOccurrence): _ExecuteReturn = Union[ - SuccessData[PickUpTipResult, None], + SuccessData[PickUpTipResult], DefinedErrorData[TipPhysicallyMissingError], ] @@ -109,7 +109,7 @@ def __init__( async def execute( self, params: PickUpTipParams - ) -> Union[SuccessData[PickUpTipResult, None], _ExecuteReturn]: + ) -> Union[SuccessData[PickUpTipResult], _ExecuteReturn]: """Move to and pick up a tip using the requested pipette.""" pipette_id = params.pipetteId labware_id = params.labwareId @@ -140,10 +140,17 @@ async def execute( labware_id=labware_id, well_name=well_name, ) - except TipNotAttachedError as e: + except PickUpTipTipNotAttachedError as e: + state_update_if_false_positive = update_types.StateUpdate() + state_update_if_false_positive.update_pipette_tip_state( + pipette_id=pipette_id, + tip_geometry=e.tip_geometry, + ) + state_update_if_false_positive.set_fluid_empty(pipette_id=pipette_id) state_update.mark_tips_as_used( pipette_id=pipette_id, labware_id=labware_id, well_name=well_name ) + state_update.set_fluid_unknown(pipette_id=pipette_id) return DefinedErrorData( public=TipPhysicallyMissingError( id=self._model_utils.generate_id(), @@ -157,6 +164,7 @@ async def execute( ], ), state_update=state_update, + state_update_if_false_positive=state_update_if_false_positive, ) else: state_update.update_pipette_tip_state( @@ -166,6 +174,7 @@ async def execute( state_update.mark_tips_as_used( pipette_id=pipette_id, labware_id=labware_id, well_name=well_name ) + state_update.set_fluid_empty(pipette_id=pipette_id) return SuccessData( public=PickUpTipResult( tipVolume=tip_geometry.volume, @@ -173,7 +182,6 @@ async def execute( tipDiameter=tip_geometry.diameter, position=deck_point, ), - private=None, state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/prepare_to_aspirate.py b/api/src/opentrons/protocol_engine/commands/prepare_to_aspirate.py index d63e42a7f90..f5525b3c90e 100644 --- a/api/src/opentrons/protocol_engine/commands/prepare_to_aspirate.py +++ b/api/src/opentrons/protocol_engine/commands/prepare_to_aspirate.py @@ -18,6 +18,7 @@ SuccessData, ) from ..errors.error_occurrence import ErrorOccurrence +from ..state import update_types if TYPE_CHECKING: from ..execution import PipettingHandler, GantryMover @@ -40,7 +41,7 @@ class PrepareToAspirateResult(BaseModel): _ExecuteReturn = Union[ - SuccessData[PrepareToAspirateResult, None], + SuccessData[PrepareToAspirateResult], DefinedErrorData[OverpressureError], ] @@ -64,11 +65,13 @@ def __init__( async def execute(self, params: PrepareToAspirateParams) -> _ExecuteReturn: """Prepare the pipette to aspirate.""" current_position = await self._gantry_mover.get_position(params.pipetteId) + state_update = update_types.StateUpdate() try: await self._pipetting_handler.prepare_for_aspirate( pipette_id=params.pipetteId, ) except PipetteOverpressureError as e: + state_update.set_fluid_unknown(pipette_id=params.pipetteId) return DefinedErrorData( public=OverpressureError( id=self._model_utils.generate_id(), @@ -90,9 +93,13 @@ async def execute(self, params: PrepareToAspirateParams) -> _ExecuteReturn: } ), ), + state_update=state_update, ) else: - return SuccessData(public=PrepareToAspirateResult(), private=None) + state_update.set_fluid_empty(pipette_id=params.pipetteId) + return SuccessData( + public=PrepareToAspirateResult(), state_update=state_update + ) class PrepareToAspirate( diff --git a/api/src/opentrons/protocol_engine/commands/reload_labware.py b/api/src/opentrons/protocol_engine/commands/reload_labware.py index 25f545736be..60230a1c6dd 100644 --- a/api/src/opentrons/protocol_engine/commands/reload_labware.py +++ b/api/src/opentrons/protocol_engine/commands/reload_labware.py @@ -47,7 +47,7 @@ class ReloadLabwareResult(BaseModel): class ReloadLabwareImplementation( - AbstractCommandImpl[ReloadLabwareParams, SuccessData[ReloadLabwareResult, None]] + AbstractCommandImpl[ReloadLabwareParams, SuccessData[ReloadLabwareResult]] ): """Reload labware command implementation.""" @@ -59,7 +59,7 @@ def __init__( async def execute( self, params: ReloadLabwareParams - ) -> SuccessData[ReloadLabwareResult, None]: + ) -> SuccessData[ReloadLabwareResult]: """Reload the definition and calibration data for a specific labware.""" reloaded_labware = await self._equipment.reload_labware( labware_id=params.labwareId, @@ -78,7 +78,6 @@ async def execute( labwareId=params.labwareId, offsetId=reloaded_labware.offsetId, ), - private=None, state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/retract_axis.py b/api/src/opentrons/protocol_engine/commands/retract_axis.py index 29e09acc064..49020eb517e 100644 --- a/api/src/opentrons/protocol_engine/commands/retract_axis.py +++ b/api/src/opentrons/protocol_engine/commands/retract_axis.py @@ -39,7 +39,7 @@ class RetractAxisResult(BaseModel): class RetractAxisImplementation( - AbstractCommandImpl[RetractAxisParams, SuccessData[RetractAxisResult, None]] + AbstractCommandImpl[RetractAxisParams, SuccessData[RetractAxisResult]] ): """Retract Axis command implementation.""" @@ -48,14 +48,12 @@ def __init__(self, movement: MovementHandler, **kwargs: object) -> None: async def execute( self, params: RetractAxisParams - ) -> SuccessData[RetractAxisResult, None]: + ) -> SuccessData[RetractAxisResult]: """Retract the specified axis.""" state_update = update_types.StateUpdate() await self._movement.retract_axis(axis=params.axis) state_update.clear_all_pipette_locations() - return SuccessData( - public=RetractAxisResult(), private=None, state_update=state_update - ) + return SuccessData(public=RetractAxisResult(), state_update=state_update) class RetractAxis(BaseCommand[RetractAxisParams, RetractAxisResult, ErrorOccurrence]): diff --git a/api/src/opentrons/protocol_engine/commands/save_position.py b/api/src/opentrons/protocol_engine/commands/save_position.py index 988e4b762a7..4bc208d1421 100644 --- a/api/src/opentrons/protocol_engine/commands/save_position.py +++ b/api/src/opentrons/protocol_engine/commands/save_position.py @@ -46,7 +46,7 @@ class SavePositionResult(BaseModel): class SavePositionImplementation( - AbstractCommandImpl[SavePositionParams, SuccessData[SavePositionResult, None]] + AbstractCommandImpl[SavePositionParams, SuccessData[SavePositionResult]] ): """Save position command implementation.""" @@ -61,7 +61,7 @@ def __init__( async def execute( self, params: SavePositionParams - ) -> SuccessData[SavePositionResult, None]: + ) -> SuccessData[SavePositionResult]: """Check the requested pipette's current position.""" position_id = self._model_utils.ensure_id(params.positionId) fail_on_not_homed = ( @@ -76,7 +76,6 @@ async def execute( positionId=position_id, position=DeckPoint(x=x, y=y, z=z), ), - private=None, ) diff --git a/api/src/opentrons/protocol_engine/commands/set_rail_lights.py b/api/src/opentrons/protocol_engine/commands/set_rail_lights.py index 6235e0d9bb6..09254dbe966 100644 --- a/api/src/opentrons/protocol_engine/commands/set_rail_lights.py +++ b/api/src/opentrons/protocol_engine/commands/set_rail_lights.py @@ -29,7 +29,7 @@ class SetRailLightsResult(BaseModel): class SetRailLightsImplementation( - AbstractCommandImpl[SetRailLightsParams, SuccessData[SetRailLightsResult, None]] + AbstractCommandImpl[SetRailLightsParams, SuccessData[SetRailLightsResult]] ): """setRailLights command implementation.""" @@ -38,10 +38,12 @@ def __init__(self, rail_lights: RailLightsHandler, **kwargs: object) -> None: async def execute( self, params: SetRailLightsParams - ) -> SuccessData[SetRailLightsResult, None]: + ) -> SuccessData[SetRailLightsResult]: """Dispatch a set lights command setting the state of the rail lights.""" await self._rail_lights.set_rail_lights(params.on) - return SuccessData(public=SetRailLightsResult(), private=None) + return SuccessData( + public=SetRailLightsResult(), + ) class SetRailLights( diff --git a/api/src/opentrons/protocol_engine/commands/set_status_bar.py b/api/src/opentrons/protocol_engine/commands/set_status_bar.py index cb83aa56ce2..2e1483f6d93 100644 --- a/api/src/opentrons/protocol_engine/commands/set_status_bar.py +++ b/api/src/opentrons/protocol_engine/commands/set_status_bar.py @@ -49,7 +49,7 @@ class SetStatusBarResult(BaseModel): class SetStatusBarImplementation( - AbstractCommandImpl[SetStatusBarParams, SuccessData[SetStatusBarResult, None]] + AbstractCommandImpl[SetStatusBarParams, SuccessData[SetStatusBarResult]] ): """setStatusBar command implementation.""" @@ -58,12 +58,14 @@ def __init__(self, status_bar: StatusBarHandler, **kwargs: object) -> None: async def execute( self, params: SetStatusBarParams - ) -> SuccessData[SetStatusBarResult, None]: + ) -> SuccessData[SetStatusBarResult]: """Execute the setStatusBar command.""" if not self._status_bar.status_bar_should_not_be_changed(): state = _animation_to_status_bar_state(params.animation) await self._status_bar.set_status_bar(state) - return SuccessData(public=SetStatusBarResult(), private=None) + return SuccessData( + public=SetStatusBarResult(), + ) class SetStatusBar( diff --git a/api/src/opentrons/protocol_engine/commands/temperature_module/deactivate.py b/api/src/opentrons/protocol_engine/commands/temperature_module/deactivate.py index 52e988b179d..e56c98e6e30 100644 --- a/api/src/opentrons/protocol_engine/commands/temperature_module/deactivate.py +++ b/api/src/opentrons/protocol_engine/commands/temperature_module/deactivate.py @@ -27,7 +27,7 @@ class DeactivateTemperatureResult(BaseModel): class DeactivateTemperatureImpl( AbstractCommandImpl[ - DeactivateTemperatureParams, SuccessData[DeactivateTemperatureResult, None] + DeactivateTemperatureParams, SuccessData[DeactivateTemperatureResult] ] ): """Execution implementation of a Temperature Module's deactivate command.""" @@ -43,7 +43,7 @@ def __init__( async def execute( self, params: DeactivateTemperatureParams - ) -> SuccessData[DeactivateTemperatureResult, None]: + ) -> SuccessData[DeactivateTemperatureResult]: """Deactivate a Temperature Module.""" # Allow propagation of ModuleNotLoadedError and WrongModuleTypeError. module_substate = self._state_view.modules.get_temperature_module_substate( @@ -57,7 +57,9 @@ async def execute( if temp_hardware_module is not None: await temp_hardware_module.deactivate() - return SuccessData(public=DeactivateTemperatureResult(), private=None) + return SuccessData( + public=DeactivateTemperatureResult(), + ) class DeactivateTemperature( diff --git a/api/src/opentrons/protocol_engine/commands/temperature_module/set_target_temperature.py b/api/src/opentrons/protocol_engine/commands/temperature_module/set_target_temperature.py index 7e76de7d561..6d65bf47bb0 100644 --- a/api/src/opentrons/protocol_engine/commands/temperature_module/set_target_temperature.py +++ b/api/src/opentrons/protocol_engine/commands/temperature_module/set_target_temperature.py @@ -34,7 +34,7 @@ class SetTargetTemperatureResult(BaseModel): class SetTargetTemperatureImpl( AbstractCommandImpl[ - SetTargetTemperatureParams, SuccessData[SetTargetTemperatureResult, None] + SetTargetTemperatureParams, SuccessData[SetTargetTemperatureResult] ] ): """Execution implementation of a Temperature Module's set temperature command.""" @@ -50,7 +50,7 @@ def __init__( async def execute( self, params: SetTargetTemperatureParams - ) -> SuccessData[SetTargetTemperatureResult, None]: + ) -> SuccessData[SetTargetTemperatureResult]: """Set a Temperature Module's target temperature.""" # Allow propagation of ModuleNotLoadedError and WrongModuleTypeError. module_substate = self._state_view.modules.get_temperature_module_substate( @@ -69,7 +69,6 @@ async def execute( await temp_hardware_module.start_set_temperature(celsius=validated_temp) return SuccessData( public=SetTargetTemperatureResult(targetTemperature=validated_temp), - private=None, ) diff --git a/api/src/opentrons/protocol_engine/commands/temperature_module/wait_for_temperature.py b/api/src/opentrons/protocol_engine/commands/temperature_module/wait_for_temperature.py index 7a96be35242..fa7784de141 100644 --- a/api/src/opentrons/protocol_engine/commands/temperature_module/wait_for_temperature.py +++ b/api/src/opentrons/protocol_engine/commands/temperature_module/wait_for_temperature.py @@ -35,9 +35,7 @@ class WaitForTemperatureResult(BaseModel): class WaitForTemperatureImpl( - AbstractCommandImpl[ - WaitForTemperatureParams, SuccessData[WaitForTemperatureResult, None] - ] + AbstractCommandImpl[WaitForTemperatureParams, SuccessData[WaitForTemperatureResult]] ): """Execution implementation of Temperature Module's wait for temperature command.""" @@ -52,7 +50,7 @@ def __init__( async def execute( self, params: WaitForTemperatureParams - ) -> SuccessData[WaitForTemperatureResult, None]: + ) -> SuccessData[WaitForTemperatureResult]: """Wait for a Temperature Module's target temperature.""" # Allow propagation of ModuleNotLoadedError and WrongModuleTypeError. module_substate = self._state_view.modules.get_temperature_module_substate( @@ -74,7 +72,9 @@ async def execute( await temp_hardware_module.await_temperature( awaiting_temperature=target_temp ) - return SuccessData(public=WaitForTemperatureResult(), private=None) + return SuccessData( + public=WaitForTemperatureResult(), + ) class WaitForTemperature( diff --git a/api/src/opentrons/protocol_engine/commands/thermocycler/close_lid.py b/api/src/opentrons/protocol_engine/commands/thermocycler/close_lid.py index 12e1ab4b13f..578a5d6b7a7 100644 --- a/api/src/opentrons/protocol_engine/commands/thermocycler/close_lid.py +++ b/api/src/opentrons/protocol_engine/commands/thermocycler/close_lid.py @@ -28,9 +28,7 @@ class CloseLidResult(BaseModel): """Result data from closing a Thermocycler's lid.""" -class CloseLidImpl( - AbstractCommandImpl[CloseLidParams, SuccessData[CloseLidResult, None]] -): +class CloseLidImpl(AbstractCommandImpl[CloseLidParams, SuccessData[CloseLidResult]]): """Execution implementation of a Thermocycler's close lid command.""" def __init__( @@ -44,9 +42,7 @@ def __init__( self._equipment = equipment self._movement = movement - async def execute( - self, params: CloseLidParams - ) -> SuccessData[CloseLidResult, None]: + async def execute(self, params: CloseLidParams) -> SuccessData[CloseLidResult]: """Close a Thermocycler's lid.""" state_update = update_types.StateUpdate() @@ -69,9 +65,7 @@ async def execute( if thermocycler_hardware is not None: await thermocycler_hardware.close() - return SuccessData( - public=CloseLidResult(), private=None, state_update=state_update - ) + return SuccessData(public=CloseLidResult(), state_update=state_update) class CloseLid(BaseCommand[CloseLidParams, CloseLidResult, ErrorOccurrence]): diff --git a/api/src/opentrons/protocol_engine/commands/thermocycler/deactivate_block.py b/api/src/opentrons/protocol_engine/commands/thermocycler/deactivate_block.py index fd108dc9568..67199577d53 100644 --- a/api/src/opentrons/protocol_engine/commands/thermocycler/deactivate_block.py +++ b/api/src/opentrons/protocol_engine/commands/thermocycler/deactivate_block.py @@ -27,7 +27,7 @@ class DeactivateBlockResult(BaseModel): class DeactivateBlockImpl( - AbstractCommandImpl[DeactivateBlockParams, SuccessData[DeactivateBlockResult, None]] + AbstractCommandImpl[DeactivateBlockParams, SuccessData[DeactivateBlockResult]] ): """Execution implementation of a Thermocycler's deactivate block command.""" @@ -42,7 +42,7 @@ def __init__( async def execute( self, params: DeactivateBlockParams - ) -> SuccessData[DeactivateBlockResult, None]: + ) -> SuccessData[DeactivateBlockResult]: """Unset a Thermocycler's target block temperature.""" thermocycler_state = self._state_view.modules.get_thermocycler_module_substate( params.moduleId @@ -54,7 +54,9 @@ async def execute( if thermocycler_hardware is not None: await thermocycler_hardware.deactivate_block() - return SuccessData(public=DeactivateBlockResult(), private=None) + return SuccessData( + public=DeactivateBlockResult(), + ) class DeactivateBlock( diff --git a/api/src/opentrons/protocol_engine/commands/thermocycler/deactivate_lid.py b/api/src/opentrons/protocol_engine/commands/thermocycler/deactivate_lid.py index ff0fabc1e88..9c3541efb12 100644 --- a/api/src/opentrons/protocol_engine/commands/thermocycler/deactivate_lid.py +++ b/api/src/opentrons/protocol_engine/commands/thermocycler/deactivate_lid.py @@ -27,7 +27,7 @@ class DeactivateLidResult(BaseModel): class DeactivateLidImpl( - AbstractCommandImpl[DeactivateLidParams, SuccessData[DeactivateLidResult, None]] + AbstractCommandImpl[DeactivateLidParams, SuccessData[DeactivateLidResult]] ): """Execution implementation of a Thermocycler's deactivate lid command.""" @@ -42,7 +42,7 @@ def __init__( async def execute( self, params: DeactivateLidParams - ) -> SuccessData[DeactivateLidResult, None]: + ) -> SuccessData[DeactivateLidResult]: """Unset a Thermocycler's target lid temperature.""" thermocycler_state = self._state_view.modules.get_thermocycler_module_substate( params.moduleId @@ -54,7 +54,9 @@ async def execute( if thermocycler_hardware is not None: await thermocycler_hardware.deactivate_lid() - return SuccessData(public=DeactivateLidResult(), private=None) + return SuccessData( + public=DeactivateLidResult(), + ) class DeactivateLid( diff --git a/api/src/opentrons/protocol_engine/commands/thermocycler/open_lid.py b/api/src/opentrons/protocol_engine/commands/thermocycler/open_lid.py index e874a0b678c..3df32d1ec99 100644 --- a/api/src/opentrons/protocol_engine/commands/thermocycler/open_lid.py +++ b/api/src/opentrons/protocol_engine/commands/thermocycler/open_lid.py @@ -28,7 +28,7 @@ class OpenLidResult(BaseModel): """Result data from opening a Thermocycler's lid.""" -class OpenLidImpl(AbstractCommandImpl[OpenLidParams, SuccessData[OpenLidResult, None]]): +class OpenLidImpl(AbstractCommandImpl[OpenLidParams, SuccessData[OpenLidResult]]): """Execution implementation of a Thermocycler's open lid command.""" def __init__( @@ -42,7 +42,7 @@ def __init__( self._equipment = equipment self._movement = movement - async def execute(self, params: OpenLidParams) -> SuccessData[OpenLidResult, None]: + async def execute(self, params: OpenLidParams) -> SuccessData[OpenLidResult]: """Open a Thermocycler's lid.""" state_update = update_types.StateUpdate() @@ -65,9 +65,7 @@ async def execute(self, params: OpenLidParams) -> SuccessData[OpenLidResult, Non if thermocycler_hardware is not None: await thermocycler_hardware.open() - return SuccessData( - public=OpenLidResult(), private=None, state_update=state_update - ) + return SuccessData(public=OpenLidResult(), state_update=state_update) class OpenLid(BaseCommand[OpenLidParams, OpenLidResult, ErrorOccurrence]): diff --git a/api/src/opentrons/protocol_engine/commands/thermocycler/run_extended_profile.py b/api/src/opentrons/protocol_engine/commands/thermocycler/run_extended_profile.py index 3cf8a67bf41..6f63aed8fe3 100644 --- a/api/src/opentrons/protocol_engine/commands/thermocycler/run_extended_profile.py +++ b/api/src/opentrons/protocol_engine/commands/thermocycler/run_extended_profile.py @@ -59,7 +59,6 @@ class RunExtendedProfileResult(BaseModel): def _transform_profile_step( step: ProfileStep, thermocycler_state: ThermocyclerModuleSubState ) -> ThermocyclerStep: - return ThermocyclerStep( temperature=thermocycler_state.validate_target_block_temperature(step.celsius), hold_time_seconds=step.holdSeconds, @@ -97,9 +96,7 @@ def _transform_profile_element( class RunExtendedProfileImpl( - AbstractCommandImpl[ - RunExtendedProfileParams, SuccessData[RunExtendedProfileResult, None] - ] + AbstractCommandImpl[RunExtendedProfileParams, SuccessData[RunExtendedProfileResult]] ): """Execution implementation of a Thermocycler's run profile command.""" @@ -114,7 +111,7 @@ def __init__( async def execute( self, params: RunExtendedProfileParams - ) -> SuccessData[RunExtendedProfileResult, None]: + ) -> SuccessData[RunExtendedProfileResult]: """Run a Thermocycler profile.""" thermocycler_state = self._state_view.modules.get_thermocycler_module_substate( params.moduleId @@ -142,7 +139,9 @@ async def execute( profile=profile, volume=target_volume ) - return SuccessData(public=RunExtendedProfileResult(), private=None) + return SuccessData( + public=RunExtendedProfileResult(), + ) class RunExtendedProfile( diff --git a/api/src/opentrons/protocol_engine/commands/thermocycler/run_profile.py b/api/src/opentrons/protocol_engine/commands/thermocycler/run_profile.py index c0b5189afcb..02aa7ad93e2 100644 --- a/api/src/opentrons/protocol_engine/commands/thermocycler/run_profile.py +++ b/api/src/opentrons/protocol_engine/commands/thermocycler/run_profile.py @@ -47,7 +47,7 @@ class RunProfileResult(BaseModel): class RunProfileImpl( - AbstractCommandImpl[RunProfileParams, SuccessData[RunProfileResult, None]] + AbstractCommandImpl[RunProfileParams, SuccessData[RunProfileResult]] ): """Execution implementation of a Thermocycler's run profile command.""" @@ -60,9 +60,7 @@ def __init__( self._state_view = state_view self._equipment = equipment - async def execute( - self, params: RunProfileParams - ) -> SuccessData[RunProfileResult, None]: + async def execute(self, params: RunProfileParams) -> SuccessData[RunProfileResult]: """Run a Thermocycler profile.""" thermocycler_state = self._state_view.modules.get_thermocycler_module_substate( params.moduleId @@ -96,7 +94,9 @@ async def execute( steps=steps, repetitions=1, volume=target_volume ) - return SuccessData(public=RunProfileResult(), private=None) + return SuccessData( + public=RunProfileResult(), + ) class RunProfile(BaseCommand[RunProfileParams, RunProfileResult, ErrorOccurrence]): diff --git a/api/src/opentrons/protocol_engine/commands/thermocycler/set_target_block_temperature.py b/api/src/opentrons/protocol_engine/commands/thermocycler/set_target_block_temperature.py index 587369b733b..b69bb15ea90 100644 --- a/api/src/opentrons/protocol_engine/commands/thermocycler/set_target_block_temperature.py +++ b/api/src/opentrons/protocol_engine/commands/thermocycler/set_target_block_temperature.py @@ -46,7 +46,7 @@ class SetTargetBlockTemperatureResult(BaseModel): class SetTargetBlockTemperatureImpl( AbstractCommandImpl[ SetTargetBlockTemperatureParams, - SuccessData[SetTargetBlockTemperatureResult, None], + SuccessData[SetTargetBlockTemperatureResult], ] ): """Execution implementation of a Thermocycler's set block temperature command.""" @@ -63,7 +63,7 @@ def __init__( async def execute( self, params: SetTargetBlockTemperatureParams, - ) -> SuccessData[SetTargetBlockTemperatureResult, None]: + ) -> SuccessData[SetTargetBlockTemperatureResult]: """Set a Thermocycler's target block temperature.""" thermocycler_state = self._state_view.modules.get_thermocycler_module_substate( params.moduleId @@ -97,7 +97,6 @@ async def execute( public=SetTargetBlockTemperatureResult( targetBlockTemperature=target_temperature ), - private=None, ) diff --git a/api/src/opentrons/protocol_engine/commands/thermocycler/set_target_lid_temperature.py b/api/src/opentrons/protocol_engine/commands/thermocycler/set_target_lid_temperature.py index 5e7efa6bfd2..37217e047ae 100644 --- a/api/src/opentrons/protocol_engine/commands/thermocycler/set_target_lid_temperature.py +++ b/api/src/opentrons/protocol_engine/commands/thermocycler/set_target_lid_temperature.py @@ -34,7 +34,7 @@ class SetTargetLidTemperatureResult(BaseModel): class SetTargetLidTemperatureImpl( AbstractCommandImpl[ - SetTargetLidTemperatureParams, SuccessData[SetTargetLidTemperatureResult, None] + SetTargetLidTemperatureParams, SuccessData[SetTargetLidTemperatureResult] ] ): """Execution implementation of a Thermocycler's set lid temperature command.""" @@ -51,7 +51,7 @@ def __init__( async def execute( self, params: SetTargetLidTemperatureParams, - ) -> SuccessData[SetTargetLidTemperatureResult, None]: + ) -> SuccessData[SetTargetLidTemperatureResult]: """Set a Thermocycler's target lid temperature.""" thermocycler_state = self._state_view.modules.get_thermocycler_module_substate( params.moduleId @@ -70,7 +70,6 @@ async def execute( public=SetTargetLidTemperatureResult( targetLidTemperature=target_temperature ), - private=None, ) diff --git a/api/src/opentrons/protocol_engine/commands/thermocycler/wait_for_block_temperature.py b/api/src/opentrons/protocol_engine/commands/thermocycler/wait_for_block_temperature.py index dabe351f352..8e8c9b1a4ec 100644 --- a/api/src/opentrons/protocol_engine/commands/thermocycler/wait_for_block_temperature.py +++ b/api/src/opentrons/protocol_engine/commands/thermocycler/wait_for_block_temperature.py @@ -28,7 +28,7 @@ class WaitForBlockTemperatureResult(BaseModel): class WaitForBlockTemperatureImpl( AbstractCommandImpl[ - WaitForBlockTemperatureParams, SuccessData[WaitForBlockTemperatureResult, None] + WaitForBlockTemperatureParams, SuccessData[WaitForBlockTemperatureResult] ] ): """Execution implementation of Thermocycler's wait for block temperature command.""" @@ -45,7 +45,7 @@ def __init__( async def execute( self, params: WaitForBlockTemperatureParams, - ) -> SuccessData[WaitForBlockTemperatureResult, None]: + ) -> SuccessData[WaitForBlockTemperatureResult]: """Wait for a Thermocycler's target block temperature.""" thermocycler_state = self._state_view.modules.get_thermocycler_module_substate( params.moduleId @@ -61,7 +61,9 @@ async def execute( if thermocycler_hardware is not None: await thermocycler_hardware.wait_for_block_target() - return SuccessData(public=WaitForBlockTemperatureResult(), private=None) + return SuccessData( + public=WaitForBlockTemperatureResult(), + ) class WaitForBlockTemperature( diff --git a/api/src/opentrons/protocol_engine/commands/thermocycler/wait_for_lid_temperature.py b/api/src/opentrons/protocol_engine/commands/thermocycler/wait_for_lid_temperature.py index d15eb4f3238..95e5fbc4f0a 100644 --- a/api/src/opentrons/protocol_engine/commands/thermocycler/wait_for_lid_temperature.py +++ b/api/src/opentrons/protocol_engine/commands/thermocycler/wait_for_lid_temperature.py @@ -28,7 +28,7 @@ class WaitForLidTemperatureResult(BaseModel): class WaitForLidTemperatureImpl( AbstractCommandImpl[ - WaitForLidTemperatureParams, SuccessData[WaitForLidTemperatureResult, None] + WaitForLidTemperatureParams, SuccessData[WaitForLidTemperatureResult] ] ): """Execution implementation of Thermocycler's wait for lid temperature command.""" @@ -45,7 +45,7 @@ def __init__( async def execute( self, params: WaitForLidTemperatureParams, - ) -> SuccessData[WaitForLidTemperatureResult, None]: + ) -> SuccessData[WaitForLidTemperatureResult]: """Wait for a Thermocycler's lid temperature.""" thermocycler_state = self._state_view.modules.get_thermocycler_module_substate( params.moduleId @@ -61,7 +61,9 @@ async def execute( if thermocycler_hardware is not None: await thermocycler_hardware.wait_for_lid_target() - return SuccessData(public=WaitForLidTemperatureResult(), private=None) + return SuccessData( + public=WaitForLidTemperatureResult(), + ) class WaitForLidTemperature( diff --git a/api/src/opentrons/protocol_engine/commands/touch_tip.py b/api/src/opentrons/protocol_engine/commands/touch_tip.py index 744b1c14107..48c947abcbd 100644 --- a/api/src/opentrons/protocol_engine/commands/touch_tip.py +++ b/api/src/opentrons/protocol_engine/commands/touch_tip.py @@ -50,7 +50,7 @@ class TouchTipResult(DestinationPositionResult): class TouchTipImplementation( - AbstractCommandImpl[TouchTipParams, SuccessData[TouchTipResult, None]] + AbstractCommandImpl[TouchTipParams, SuccessData[TouchTipResult]] ): """Touch tip command implementation.""" @@ -65,9 +65,7 @@ def __init__( self._movement = movement self._gantry_mover = gantry_mover - async def execute( - self, params: TouchTipParams - ) -> SuccessData[TouchTipResult, None]: + async def execute(self, params: TouchTipParams) -> SuccessData[TouchTipResult]: """Touch tip to sides of a well using the requested pipette.""" pipette_id = params.pipetteId labware_id = params.labwareId @@ -119,7 +117,6 @@ async def execute( return SuccessData( public=TouchTipResult(position=final_deck_point), - private=None, state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/unsafe/__init__.py b/api/src/opentrons/protocol_engine/commands/unsafe/__init__.py index 72698a3b0f2..eb138d89914 100644 --- a/api/src/opentrons/protocol_engine/commands/unsafe/__init__.py +++ b/api/src/opentrons/protocol_engine/commands/unsafe/__init__.py @@ -40,6 +40,15 @@ ) +from .unsafe_place_labware import ( + UnsafePlaceLabwareCommandType, + UnsafePlaceLabwareParams, + UnsafePlaceLabwareResult, + UnsafePlaceLabware, + UnsafePlaceLabwareCreate, +) + + __all__ = [ # Unsafe blow-out-in-place command models "UnsafeBlowOutInPlaceCommandType", @@ -71,4 +80,10 @@ "UnsafeUngripLabwareResult", "UnsafeUngripLabware", "UnsafeUngripLabwareCreate", + # Unsafe place labware + "UnsafePlaceLabwareCommandType", + "UnsafePlaceLabwareParams", + "UnsafePlaceLabwareResult", + "UnsafePlaceLabware", + "UnsafePlaceLabwareCreate", ] diff --git a/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_blow_out_in_place.py b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_blow_out_in_place.py index d9ef8e1d15d..4c767625782 100644 --- a/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_blow_out_in_place.py +++ b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_blow_out_in_place.py @@ -10,6 +10,7 @@ from ..pipetting_common import PipetteIdMixin, FlowRateMixin from ...resources import ensure_ot3_hardware from ...errors.error_occurrence import ErrorOccurrence +from ...state import update_types from opentrons.hardware_control import HardwareControlAPI from opentrons.hardware_control.types import Axis @@ -36,7 +37,7 @@ class UnsafeBlowOutInPlaceResult(BaseModel): class UnsafeBlowOutInPlaceImplementation( AbstractCommandImpl[ - UnsafeBlowOutInPlaceParams, SuccessData[UnsafeBlowOutInPlaceResult, None] + UnsafeBlowOutInPlaceParams, SuccessData[UnsafeBlowOutInPlaceResult] ] ): """UnsafeBlowOutInPlace command implementation.""" @@ -54,7 +55,7 @@ def __init__( async def execute( self, params: UnsafeBlowOutInPlaceParams - ) -> SuccessData[UnsafeBlowOutInPlaceResult, None]: + ) -> SuccessData[UnsafeBlowOutInPlaceResult]: """Blow-out without moving the pipette even when position is unknown.""" ot3_hardware_api = ensure_ot3_hardware(self._hardware_api) pipette_location = self._state_view.motion.get_pipette_location( @@ -66,8 +67,12 @@ async def execute( await self._pipetting.blow_out_in_place( pipette_id=params.pipetteId, flow_rate=params.flowRate ) + state_update = update_types.StateUpdate() + state_update.set_fluid_empty(pipette_id=params.pipetteId) - return SuccessData(public=UnsafeBlowOutInPlaceResult(), private=None) + return SuccessData( + public=UnsafeBlowOutInPlaceResult(), state_update=state_update + ) class UnsafeBlowOutInPlace( diff --git a/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_drop_tip_in_place.py b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_drop_tip_in_place.py index 33d4baebeea..5aa4e292f63 100644 --- a/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_drop_tip_in_place.py +++ b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_drop_tip_in_place.py @@ -42,7 +42,7 @@ class UnsafeDropTipInPlaceResult(BaseModel): class UnsafeDropTipInPlaceImplementation( AbstractCommandImpl[ - UnsafeDropTipInPlaceParams, SuccessData[UnsafeDropTipInPlaceResult, None] + UnsafeDropTipInPlaceParams, SuccessData[UnsafeDropTipInPlaceResult] ] ): """Unsafe drop tip in place command implementation.""" @@ -60,7 +60,7 @@ def __init__( async def execute( self, params: UnsafeDropTipInPlaceParams - ) -> SuccessData[UnsafeDropTipInPlaceResult, None]: + ) -> SuccessData[UnsafeDropTipInPlaceResult]: """Drop a tip using the requested pipette, even if the plunger position is not known.""" ot3_hardware_api = ensure_ot3_hardware(self._hardware_api) pipette_location = self._state_view.motion.get_pipette_location( @@ -77,9 +77,10 @@ async def execute( state_update.update_pipette_tip_state( pipette_id=params.pipetteId, tip_geometry=None ) + state_update.set_fluid_unknown(pipette_id=params.pipetteId) return SuccessData( - public=UnsafeDropTipInPlaceResult(), private=None, state_update=state_update + public=UnsafeDropTipInPlaceResult(), state_update=state_update ) diff --git a/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_engage_axes.py b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_engage_axes.py index 500347d84b0..02bc22b0396 100644 --- a/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_engage_axes.py +++ b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_engage_axes.py @@ -32,7 +32,7 @@ class UnsafeEngageAxesResult(BaseModel): class UnsafeEngageAxesImplementation( AbstractCommandImpl[ UnsafeEngageAxesParams, - SuccessData[UnsafeEngageAxesResult, None], + SuccessData[UnsafeEngageAxesResult], ] ): """Enable axes command implementation.""" @@ -48,7 +48,7 @@ def __init__( async def execute( self, params: UnsafeEngageAxesParams - ) -> SuccessData[UnsafeEngageAxesResult, None]: + ) -> SuccessData[UnsafeEngageAxesResult]: """Enable exes.""" ot3_hardware_api = ensure_ot3_hardware(self._hardware_api) await ot3_hardware_api.engage_axes( @@ -57,7 +57,9 @@ async def execute( for axis in params.axes ] ) - return SuccessData(public=UnsafeEngageAxesResult(), private=None) + return SuccessData( + public=UnsafeEngageAxesResult(), + ) class UnsafeEngageAxes( diff --git a/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_place_labware.py b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_place_labware.py new file mode 100644 index 00000000000..547b8416637 --- /dev/null +++ b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_place_labware.py @@ -0,0 +1,194 @@ +"""Place labware payload, result, and implementaiton.""" + +from __future__ import annotations +from pydantic import BaseModel, Field +from typing import TYPE_CHECKING, Optional, Type, cast +from typing_extensions import Literal + +from opentrons.hardware_control.types import Axis, OT3Mount +from opentrons.motion_planning.waypoints import get_gripper_labware_placement_waypoints +from opentrons.protocol_engine.errors.exceptions import ( + CannotPerformGripperAction, + GripperNotAttachedError, +) +from opentrons.types import Point + +from ...types import DeckSlotLocation, ModuleModel, OnDeckLabwareLocation +from ..command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData +from ...errors.error_occurrence import ErrorOccurrence +from ...resources import ensure_ot3_hardware +from ...state.update_types import StateUpdate + +from opentrons.hardware_control import HardwareControlAPI, OT3HardwareControlAPI + +if TYPE_CHECKING: + from ...state.state import StateView + from ...execution.equipment import EquipmentHandler + + +UnsafePlaceLabwareCommandType = Literal["unsafe/placeLabware"] + + +class UnsafePlaceLabwareParams(BaseModel): + """Payload required for an UnsafePlaceLabware command.""" + + labwareId: str = Field(..., description="The id of the labware to place.") + location: OnDeckLabwareLocation = Field( + ..., description="Where to place the labware." + ) + + +class UnsafePlaceLabwareResult(BaseModel): + """Result data from the execution of an UnsafePlaceLabware command.""" + + +class UnsafePlaceLabwareImplementation( + AbstractCommandImpl[ + UnsafePlaceLabwareParams, + SuccessData[UnsafePlaceLabwareResult], + ] +): + """The UnsafePlaceLabware command implementation.""" + + def __init__( + self, + hardware_api: HardwareControlAPI, + state_view: StateView, + equipment: EquipmentHandler, + **kwargs: object, + ) -> None: + self._hardware_api = hardware_api + self._state_view = state_view + self._equipment = equipment + + async def execute( + self, params: UnsafePlaceLabwareParams + ) -> SuccessData[UnsafePlaceLabwareResult]: + """Place Labware. + + This command is used only when the gripper is in the middle of moving + labware but is interrupted before completing the move. (i.e., the e-stop + is pressed, get into error recovery, etc). + + Unlike the `moveLabware` command, where you pick a source and destination + location, this command takes the labwareId to be moved and location to + move it to. + + """ + ot3api = ensure_ot3_hardware(self._hardware_api) + if not ot3api.has_gripper(): + raise GripperNotAttachedError("No gripper found to perform labware place.") + + if ot3api.gripper_jaw_can_home(): + raise CannotPerformGripperAction( + "Cannot place labware when gripper is not gripping." + ) + + # Allow propagation of LabwareNotLoadedError. + labware_id = params.labwareId + definition_uri = self._state_view.labware.get(labware_id).definitionUri + final_offsets = self._state_view.labware.get_labware_gripper_offsets( + labware_id, None + ) + drop_offset = cast(Point, final_offsets.dropOffset) if final_offsets else None + + if isinstance(params.location, DeckSlotLocation): + self._state_view.addressable_areas.raise_if_area_not_in_deck_configuration( + params.location.slotName.id + ) + + location = self._state_view.geometry.ensure_valid_gripper_location( + params.location, + ) + + # This is an absorbance reader, move the lid to its dock (staging area). + if isinstance(location, DeckSlotLocation): + module = self._state_view.modules.get_by_slot(location.slotName) + if module and module.model == ModuleModel.ABSORBANCE_READER_V1: + location = self._state_view.modules.absorbance_reader_dock_location( + module.id + ) + + new_offset_id = self._equipment.find_applicable_labware_offset_id( + labware_definition_uri=definition_uri, + labware_location=location, + ) + + # NOTE: When the estop is pressed, the gantry loses position, + # so the robot needs to home x, y to sync. + await ot3api.home(axes=[Axis.Z_L, Axis.Z_R, Axis.Z_G, Axis.X, Axis.Y]) + state_update = StateUpdate() + + # Place the labware down + await self._start_movement(ot3api, labware_id, location, drop_offset) + + state_update.set_labware_location( + labware_id=labware_id, + new_location=location, + new_offset_id=new_offset_id, + ) + return SuccessData(public=UnsafePlaceLabwareResult(), state_update=state_update) + + async def _start_movement( + self, + ot3api: OT3HardwareControlAPI, + labware_id: str, + location: OnDeckLabwareLocation, + drop_offset: Optional[Point], + ) -> None: + gripper_homed_position = await ot3api.gantry_position( + mount=OT3Mount.GRIPPER, + refresh=True, + ) + + to_labware_center = self._state_view.geometry.get_labware_grip_point( + labware_id=labware_id, location=location + ) + + movement_waypoints = get_gripper_labware_placement_waypoints( + to_labware_center=to_labware_center, + gripper_home_z=gripper_homed_position.z, + drop_offset=drop_offset, + ) + + # start movement + for waypoint_data in movement_waypoints: + if waypoint_data.jaw_open: + if waypoint_data.dropping: + # This `disengage_axes` step is important in order to engage + # the electronic brake on the Z axis of the gripper. The brake + # has a stronger holding force on the axis than the hold current, + # and prevents the axis from spuriously dropping when e.g. the notch + # on the side of a falling tiprack catches the jaw. + await ot3api.disengage_axes([Axis.Z_G]) + await ot3api.ungrip() + if waypoint_data.dropping: + # We lost the position estimation after disengaging the axis, so + # it is necessary to home it next + await ot3api.home_z(OT3Mount.GRIPPER) + await ot3api.move_to( + mount=OT3Mount.GRIPPER, abs_position=waypoint_data.position + ) + + +class UnsafePlaceLabware( + BaseCommand[UnsafePlaceLabwareParams, UnsafePlaceLabwareResult, ErrorOccurrence] +): + """UnsafePlaceLabware command model.""" + + commandType: UnsafePlaceLabwareCommandType = "unsafe/placeLabware" + params: UnsafePlaceLabwareParams + result: Optional[UnsafePlaceLabwareResult] + + _ImplementationCls: Type[ + UnsafePlaceLabwareImplementation + ] = UnsafePlaceLabwareImplementation + + +class UnsafePlaceLabwareCreate(BaseCommandCreate[UnsafePlaceLabwareParams]): + """UnsafePlaceLabware command request model.""" + + commandType: UnsafePlaceLabwareCommandType = "unsafe/placeLabware" + params: UnsafePlaceLabwareParams + + _CommandCls: Type[UnsafePlaceLabware] = UnsafePlaceLabware diff --git a/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_ungrip_labware.py b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_ungrip_labware.py index e64beaa7ea7..9674513d749 100644 --- a/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_ungrip_labware.py +++ b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_ungrip_labware.py @@ -27,7 +27,7 @@ class UnsafeUngripLabwareResult(BaseModel): class UnsafeUngripLabwareImplementation( AbstractCommandImpl[ UnsafeUngripLabwareParams, - SuccessData[UnsafeUngripLabwareResult, None], + SuccessData[UnsafeUngripLabwareResult], ] ): """Ungrip labware command implementation.""" @@ -41,13 +41,15 @@ def __init__( async def execute( self, params: UnsafeUngripLabwareParams - ) -> SuccessData[UnsafeUngripLabwareResult, None]: + ) -> SuccessData[UnsafeUngripLabwareResult]: """Ungrip Labware.""" ot3_hardware_api = ensure_ot3_hardware(self._hardware_api) if not ot3_hardware_api.has_gripper(): raise GripperNotAttachedError("No gripper found to perform ungrip.") await ot3_hardware_api.ungrip() - return SuccessData(public=UnsafeUngripLabwareResult(), private=None) + return SuccessData( + public=UnsafeUngripLabwareResult(), + ) class UnsafeUngripLabware( diff --git a/api/src/opentrons/protocol_engine/commands/unsafe/update_position_estimators.py b/api/src/opentrons/protocol_engine/commands/unsafe/update_position_estimators.py index 96be2eb8551..cf5454db332 100644 --- a/api/src/opentrons/protocol_engine/commands/unsafe/update_position_estimators.py +++ b/api/src/opentrons/protocol_engine/commands/unsafe/update_position_estimators.py @@ -34,7 +34,7 @@ class UpdatePositionEstimatorsResult(BaseModel): class UpdatePositionEstimatorsImplementation( AbstractCommandImpl[ UpdatePositionEstimatorsParams, - SuccessData[UpdatePositionEstimatorsResult, None], + SuccessData[UpdatePositionEstimatorsResult], ] ): """Update position estimators command implementation.""" @@ -50,7 +50,7 @@ def __init__( async def execute( self, params: UpdatePositionEstimatorsParams - ) -> SuccessData[UpdatePositionEstimatorsResult, None]: + ) -> SuccessData[UpdatePositionEstimatorsResult]: """Update axis position estimators from their encoders.""" ot3_hardware_api = ensure_ot3_hardware(self._hardware_api) await ot3_hardware_api.update_axis_position_estimations( @@ -59,7 +59,9 @@ async def execute( for axis in params.axes ] ) - return SuccessData(public=UpdatePositionEstimatorsResult(), private=None) + return SuccessData( + public=UpdatePositionEstimatorsResult(), + ) class UpdatePositionEstimators( diff --git a/api/src/opentrons/protocol_engine/commands/verify_tip_presence.py b/api/src/opentrons/protocol_engine/commands/verify_tip_presence.py index 9816e03cf33..e0412022e85 100644 --- a/api/src/opentrons/protocol_engine/commands/verify_tip_presence.py +++ b/api/src/opentrons/protocol_engine/commands/verify_tip_presence.py @@ -36,9 +36,7 @@ class VerifyTipPresenceResult(BaseModel): class VerifyTipPresenceImplementation( - AbstractCommandImpl[ - VerifyTipPresenceParams, SuccessData[VerifyTipPresenceResult, None] - ] + AbstractCommandImpl[VerifyTipPresenceParams, SuccessData[VerifyTipPresenceResult]] ): """VerifyTipPresence command implementation.""" @@ -51,7 +49,7 @@ def __init__( async def execute( self, params: VerifyTipPresenceParams - ) -> SuccessData[VerifyTipPresenceResult, None]: + ) -> SuccessData[VerifyTipPresenceResult]: """Verify if tip presence is as expected for the requested pipette.""" pipette_id = params.pipetteId expected_state = params.expectedState @@ -67,7 +65,9 @@ async def execute( follow_singular_sensor=follow_singular_sensor, ) - return SuccessData(public=VerifyTipPresenceResult(), private=None) + return SuccessData( + public=VerifyTipPresenceResult(), + ) class VerifyTipPresence( diff --git a/api/src/opentrons/protocol_engine/commands/wait_for_duration.py b/api/src/opentrons/protocol_engine/commands/wait_for_duration.py index df1eae28aa4..04f8693386e 100644 --- a/api/src/opentrons/protocol_engine/commands/wait_for_duration.py +++ b/api/src/opentrons/protocol_engine/commands/wait_for_duration.py @@ -29,7 +29,7 @@ class WaitForDurationResult(BaseModel): class WaitForDurationImplementation( - AbstractCommandImpl[WaitForDurationParams, SuccessData[WaitForDurationResult, None]] + AbstractCommandImpl[WaitForDurationParams, SuccessData[WaitForDurationResult]] ): """Wait for duration command implementation.""" @@ -38,10 +38,12 @@ def __init__(self, run_control: RunControlHandler, **kwargs: object) -> None: async def execute( self, params: WaitForDurationParams - ) -> SuccessData[WaitForDurationResult, None]: + ) -> SuccessData[WaitForDurationResult]: """Wait for a duration of time.""" await self._run_control.wait_for_duration(params.seconds) - return SuccessData(public=WaitForDurationResult(), private=None) + return SuccessData( + public=WaitForDurationResult(), + ) class WaitForDuration( diff --git a/api/src/opentrons/protocol_engine/commands/wait_for_resume.py b/api/src/opentrons/protocol_engine/commands/wait_for_resume.py index c6036f852e2..f5066d52521 100644 --- a/api/src/opentrons/protocol_engine/commands/wait_for_resume.py +++ b/api/src/opentrons/protocol_engine/commands/wait_for_resume.py @@ -30,7 +30,7 @@ class WaitForResumeResult(BaseModel): class WaitForResumeImplementation( - AbstractCommandImpl[WaitForResumeParams, SuccessData[WaitForResumeResult, None]] + AbstractCommandImpl[WaitForResumeParams, SuccessData[WaitForResumeResult]] ): """Wait for resume command implementation.""" @@ -39,10 +39,12 @@ def __init__(self, run_control: RunControlHandler, **kwargs: object) -> None: async def execute( self, params: WaitForResumeParams - ) -> SuccessData[WaitForResumeResult, None]: + ) -> SuccessData[WaitForResumeResult]: """Dispatch a PauseAction to the store to pause the protocol.""" await self._run_control.wait_for_resume() - return SuccessData(public=WaitForResumeResult(), private=None) + return SuccessData( + public=WaitForResumeResult(), + ) class WaitForResume( diff --git a/api/src/opentrons/protocol_engine/create_protocol_engine.py b/api/src/opentrons/protocol_engine/create_protocol_engine.py index dc66591eff2..372972c1f50 100644 --- a/api/src/opentrons/protocol_engine/create_protocol_engine.py +++ b/api/src/opentrons/protocol_engine/create_protocol_engine.py @@ -5,12 +5,20 @@ from opentrons.hardware_control import HardwareControlAPI from opentrons.hardware_control.types import DoorState -from opentrons.protocol_engine.error_recovery_policy import ErrorRecoveryPolicy +from opentrons.protocol_engine.execution.error_recovery_hardware_state_synchronizer import ( + ErrorRecoveryHardwareStateSynchronizer, +) from opentrons.util.async_helpers import async_context_manager_in_thread + from opentrons_shared_data.robot import load as load_robot +from .actions.action_dispatcher import ActionDispatcher +from .error_recovery_policy import ErrorRecoveryPolicy +from .execution.door_watcher import DoorWatcher +from .execution.hardware_stopper import HardwareStopper +from .plugins import PluginStarter from .protocol_engine import ProtocolEngine -from .resources import DeckDataProvider, ModuleDataProvider, FileProvider +from .resources import DeckDataProvider, ModuleDataProvider, FileProvider, ModelUtils from .state.config import Config from .state.state import StateStore from .types import PostRunHardwareState, DeckConfigurationType @@ -61,10 +69,27 @@ async def create_protocol_engine( deck_configuration=deck_configuration, notify_publishers=notify_publishers, ) + hardware_state_synchronizer = ErrorRecoveryHardwareStateSynchronizer( + hardware_api, state_store + ) + action_dispatcher = ActionDispatcher(state_store) + action_dispatcher.add_handler(hardware_state_synchronizer) + plugin_starter = PluginStarter(state_store, action_dispatcher) + model_utils = ModelUtils() + hardware_stopper = HardwareStopper(hardware_api, state_store) + door_watcher = DoorWatcher(state_store, hardware_api, action_dispatcher) + module_data_provider = ModuleDataProvider() + file_provider = file_provider or FileProvider() return ProtocolEngine( - state_store=state_store, hardware_api=hardware_api, + state_store=state_store, + action_dispatcher=action_dispatcher, + plugin_starter=plugin_starter, + model_utils=model_utils, + hardware_stopper=hardware_stopper, + door_watcher=door_watcher, + module_data_provider=module_data_provider, file_provider=file_provider, ) diff --git a/api/src/opentrons/protocol_engine/error_recovery_policy.py b/api/src/opentrons/protocol_engine/error_recovery_policy.py index d959651393e..fcc8a2ffef5 100644 --- a/api/src/opentrons/protocol_engine/error_recovery_policy.py +++ b/api/src/opentrons/protocol_engine/error_recovery_policy.py @@ -26,10 +26,20 @@ class ErrorRecoveryType(enum.Enum): """ WAIT_FOR_RECOVERY = enum.auto() - """Stop and wait for the error to be recovered from manually.""" + """Enter interactive error recovery mode.""" - IGNORE_AND_CONTINUE = enum.auto() - """Continue with the run, as if the command never failed.""" + CONTINUE_WITH_ERROR = enum.auto() + """Continue without interruption, carrying on from whatever error state the failed + command left the engine in. + + This is like `ProtocolEngine.resume_from_recovery(reconcile_false_positive=False)`. + """ + + ASSUME_FALSE_POSITIVE_AND_CONTINUE = enum.auto() + """Continue without interruption, acting as if the underlying error was a false positive. + + This is like `ProtocolEngine.resume_from_recovery(reconcile_false_positive=True)`. + """ class ErrorRecoveryPolicy(Protocol): diff --git a/api/src/opentrons/protocol_engine/errors/__init__.py b/api/src/opentrons/protocol_engine/errors/__init__.py index 9bbe3aae9b8..e9f1acddeed 100644 --- a/api/src/opentrons/protocol_engine/errors/__init__.py +++ b/api/src/opentrons/protocol_engine/errors/__init__.py @@ -8,6 +8,7 @@ InvalidSpecificationForRobotTypeError, InvalidLoadPipetteSpecsError, TipNotAttachedError, + PickUpTipTipNotAttachedError, TipAttachedError, CommandDoesNotExistError, LabwareNotLoadedError, @@ -76,6 +77,7 @@ OperationLocationNotInWellError, InvalidDispenseVolumeError, StorageLimitReachedError, + InvalidLiquidError, ) from .error_occurrence import ErrorOccurrence, ProtocolCommandFailedError @@ -89,6 +91,7 @@ "InvalidSpecificationForRobotTypeError", "InvalidLoadPipetteSpecsError", "TipNotAttachedError", + "PickUpTipTipNotAttachedError", "TipAttachedError", "CommandDoesNotExistError", "LabwareNotLoadedError", @@ -135,6 +138,7 @@ "InvalidTargetSpeedError", "InvalidBlockVolumeError", "InvalidHoldTimeError", + "InvalidLiquidError", "CannotPerformModuleAction", "ResumeFromRecoveryNotAllowedError", "PauseNotAllowedError", diff --git a/api/src/opentrons/protocol_engine/errors/exceptions.py b/api/src/opentrons/protocol_engine/errors/exceptions.py index 5656942b338..36b0d2ccbef 100644 --- a/api/src/opentrons/protocol_engine/errors/exceptions.py +++ b/api/src/opentrons/protocol_engine/errors/exceptions.py @@ -1,11 +1,17 @@ """Protocol engine exceptions.""" +from __future__ import annotations + from logging import getLogger -from typing import Any, Dict, Optional, Union, Iterator, Sequence +from typing import Any, Dict, Final, Optional, Union, Iterator, Sequence, TYPE_CHECKING from opentrons_shared_data.errors import ErrorCodes from opentrons_shared_data.errors.exceptions import EnumeratedError, PythonException +if TYPE_CHECKING: + from opentrons.protocol_engine.types import TipGeometry + + log = getLogger(__name__) @@ -132,6 +138,21 @@ def __init__( super().__init__(ErrorCodes.UNEXPECTED_TIP_REMOVAL, message, details, wrapping) +class PickUpTipTipNotAttachedError(TipNotAttachedError): + """Raised from TipHandler.pick_up_tip(). + + This is like TipNotAttachedError except that it carries some extra information + about the attempted operation. + """ + + tip_geometry: Final[TipGeometry] + """The tip geometry that would have been on the pipette, had the operation succeeded.""" + + def __init__(self, tip_geometry: TipGeometry) -> None: + super().__init__() + self.tip_geometry = tip_geometry + + class TipAttachedError(ProtocolEngineError): """Raised when a tip shouldn't be attached, but is.""" @@ -223,6 +244,19 @@ def __init__( super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) +class InvalidLiquidError(ProtocolEngineError): + """Raised when attempting to add a liquid with an invalid property.""" + + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build an InvalidLiquidError.""" + super().__init__(ErrorCodes.INVALID_PROTOCOL_DATA, message, details, wrapping) + + class LabwareDefinitionDoesNotExistError(ProtocolEngineError): """Raised when referencing a labware definition that does not exist.""" diff --git a/api/src/opentrons/protocol_engine/execution/command_executor.py b/api/src/opentrons/protocol_engine/execution/command_executor.py index 1d30b8756d2..b6c686e0b11 100644 --- a/api/src/opentrons/protocol_engine/execution/command_executor.py +++ b/api/src/opentrons/protocol_engine/execution/command_executor.py @@ -12,6 +12,7 @@ ) from opentrons.protocol_engine.commands.command import SuccessData +from opentrons.protocol_engine.notes import make_error_recovery_debug_note from ..state.state import StateStore from ..resources import ModelUtils, FileProvider @@ -161,6 +162,12 @@ async def execute(self, command_id: str) -> None: elif not isinstance(error, EnumeratedError): error = PythonException(error) + error_recovery_type = error_recovery_policy( + self._state_store.config, + running_command, + None, + ) + note_tracker(make_error_recovery_debug_note(error_recovery_type)) self._action_dispatcher.dispatch( FailCommandAction( error=error, @@ -169,11 +176,7 @@ async def execute(self, command_id: str) -> None: error_id=self._model_utils.generate_id(), failed_at=self._model_utils.get_timestamp(), notes=note_tracker.get_notes(), - type=error_recovery_policy( - self._state_store.config, - running_command, - None, - ), + type=error_recovery_type, ) ) @@ -189,12 +192,17 @@ async def execute(self, command_id: str) -> None: self._action_dispatcher.dispatch( SucceedCommandAction( command=succeeded_command, - private_result=result.private, state_update=result.state_update, ), ) else: # The command encountered a defined error. + error_recovery_type = error_recovery_policy( + self._state_store.config, + running_command, + result, + ) + note_tracker(make_error_recovery_debug_note(error_recovery_type)) self._action_dispatcher.dispatch( FailCommandAction( error=result, @@ -203,10 +211,6 @@ async def execute(self, command_id: str) -> None: error_id=result.public.id, failed_at=result.public.createdAt, notes=note_tracker.get_notes(), - type=error_recovery_policy( - self._state_store.config, - running_command, - result, - ), + type=error_recovery_type, ) ) diff --git a/api/src/opentrons/protocol_engine/execution/error_recovery_hardware_state_synchronizer.py b/api/src/opentrons/protocol_engine/execution/error_recovery_hardware_state_synchronizer.py new file mode 100644 index 00000000000..67d75cfb181 --- /dev/null +++ b/api/src/opentrons/protocol_engine/execution/error_recovery_hardware_state_synchronizer.py @@ -0,0 +1,101 @@ +# noqa: D100 + + +from opentrons.hardware_control import HardwareControlAPI +from opentrons.protocol_engine.actions.action_handler import ActionHandler +from opentrons.protocol_engine.actions.actions import ( + Action, + FailCommandAction, + ResumeFromRecoveryAction, +) +from opentrons.protocol_engine.commands.command import DefinedErrorData +from opentrons.protocol_engine.error_recovery_policy import ErrorRecoveryType +from opentrons.protocol_engine.execution.tip_handler import HardwareTipHandler +from opentrons.protocol_engine.state import update_types +from opentrons.protocol_engine.state.state import StateView + + +class ErrorRecoveryHardwareStateSynchronizer(ActionHandler): + """A hack to keep the hardware API's state correct through certain error recovery flows. + + BACKGROUND: + + Certain parts of robot state are duplicated between `opentrons.protocol_engine` and + `opentrons.hardware_control`. Stuff like "is there a tip attached." + + Normally, Protocol Engine command implementations (`opentrons.protocol_engine.commands`) + mutate hardware API state when they execute; and then when they finish executing, + the Protocol Engine state stores (`opentrons.protocol_engine.state`) update Protocol + Engine state accordingly. So both halves are accounted for. This generally works fine. + + However, we need to go out of our way to support + `ProtocolEngine.resume_from_recovery(reconcile_false_positive=True)`. + It wants to apply a second set of state updates to "fix things up" with the + new knowledge that some error was a false positive. The Protocol Engine half of that + is easy for us to apply the normal way, through the state stores; but the + hardware API half of that cannot be applied the normal way, from the command + implementation, because the command in question is no longer running. + + THE HACK: + + This listens for the same error recovery state updates that the state stores do, + figures out what hardware API state mutations ought to go along with them, + and then does those mutations. + + The problem is that hardware API state is now mutated from two different places + (sometimes the command implementations, and sometimes here), which are bound + to grow accidental differences. + + TO FIX: + + Make Protocol Engine's use of the hardware API less stateful. e.g. supply + tip geometry every time we call a hardware API movement method, instead of + just once when we pick up a tip. Use Protocol Engine state as the single source + of truth. + """ + + def __init__(self, hardware_api: HardwareControlAPI, state_view: StateView) -> None: + self._hardware_api = hardware_api + self._state_view = state_view + + def handle_action(self, action: Action) -> None: + """Modify hardware API state in reaction to a Protocol Engine action.""" + state_update = _get_state_update(action) + if state_update: + self._synchronize(state_update) + + def _synchronize(self, state_update: update_types.StateUpdate) -> None: + tip_handler = HardwareTipHandler(self._state_view, self._hardware_api) + + if state_update.pipette_tip_state != update_types.NO_CHANGE: + pipette_id = state_update.pipette_tip_state.pipette_id + tip_geometry = state_update.pipette_tip_state.tip_geometry + if tip_geometry is None: + tip_handler.remove_tip(pipette_id) + else: + tip_handler.cache_tip(pipette_id=pipette_id, tip=tip_geometry) + + +def _get_state_update(action: Action) -> update_types.StateUpdate | None: + """Get the mutations that we need to do on the hardware API to stay in sync with an engine action. + + The mutations are returned in Protocol Engine terms, as a StateUpdate. + They then need to be converted to hardware API terms. + """ + match action: + case ResumeFromRecoveryAction(state_update=state_update): + return state_update + + case FailCommandAction( + error=DefinedErrorData( + state_update_if_false_positive=state_update_if_false_positive + ) + ): + return ( + state_update_if_false_positive + if action.type == ErrorRecoveryType.ASSUME_FALSE_POSITIVE_AND_CONTINUE + else None + ) + + case _: + return None diff --git a/api/src/opentrons/protocol_engine/execution/hardware_stopper.py b/api/src/opentrons/protocol_engine/execution/hardware_stopper.py index 28c310acd70..81d4f10d94d 100644 --- a/api/src/opentrons/protocol_engine/execution/hardware_stopper.py +++ b/api/src/opentrons/protocol_engine/execution/hardware_stopper.py @@ -78,7 +78,7 @@ async def _drop_tip(self) -> None: try: if self._state_store.labware.get_fixed_trash_id() == FIXED_TRASH_ID: # OT-2 and Flex 2.15 protocols will default to the Fixed Trash Labware - await self._tip_handler.add_tip(pipette_id=pipette_id, tip=tip) + self._tip_handler.cache_tip(pipette_id=pipette_id, tip=tip) await self._movement_handler.move_to_well( pipette_id=pipette_id, labware_id=FIXED_TRASH_ID, @@ -90,7 +90,7 @@ async def _drop_tip(self) -> None: ) elif self._state_store.config.robot_type == "OT-2 Standard": # API 2.16 and above OT2 protocols use addressable areas - await self._tip_handler.add_tip(pipette_id=pipette_id, tip=tip) + self._tip_handler.cache_tip(pipette_id=pipette_id, tip=tip) await self._movement_handler.move_to_addressable_area( pipette_id=pipette_id, addressable_area_name="fixedTrash", diff --git a/api/src/opentrons/protocol_engine/execution/queue_worker.py b/api/src/opentrons/protocol_engine/execution/queue_worker.py index 67f8f17b42c..015adf085c9 100644 --- a/api/src/opentrons/protocol_engine/execution/queue_worker.py +++ b/api/src/opentrons/protocol_engine/execution/queue_worker.py @@ -69,7 +69,11 @@ async def join(self) -> None: async def _run_commands(self) -> None: async for command_id in self._command_generator(): - await self._command_executor.execute(command_id=command_id) + try: + await self._command_executor.execute(command_id=command_id) + except BaseException: + log.exception("Unhandled failure in command executor") + raise # Yield to the event loop in case we're executing a long sequence of commands # that never yields internally. For example, a long sequence of comment commands. await asyncio.sleep(0) diff --git a/api/src/opentrons/protocol_engine/execution/tip_handler.py b/api/src/opentrons/protocol_engine/execution/tip_handler.py index 0fe2462ee5e..dde67ece007 100644 --- a/api/src/opentrons/protocol_engine/execution/tip_handler.py +++ b/api/src/opentrons/protocol_engine/execution/tip_handler.py @@ -4,6 +4,9 @@ from opentrons.hardware_control import HardwareControlAPI from opentrons.hardware_control.types import FailedTipStateCheck, InstrumentProbeType +from opentrons.protocol_engine.errors.exceptions import PickUpTipTipNotAttachedError +from opentrons.types import Mount + from opentrons_shared_data.errors.exceptions import ( CommandPreconditionViolated, CommandParameterLimitViolated, @@ -70,7 +73,7 @@ async def pick_up_tip( Tip geometry of the picked up tip. Raises: - TipNotAttachedError + PickUpTipTipNotAttachedError """ ... @@ -83,9 +86,12 @@ async def drop_tip(self, pipette_id: str, home_after: Optional[bool]) -> None: TipAttachedError """ - async def add_tip(self, pipette_id: str, tip: TipGeometry) -> None: + def cache_tip(self, pipette_id: str, tip: TipGeometry) -> None: """Tell the Hardware API that a tip is attached.""" + def remove_tip(self, pipette_id: str) -> None: + """Tell the hardware API that no tip is attached.""" + async def get_tip_presence(self, pipette_id: str) -> TipPresenceStatus: """Get tip presence status on the pipette.""" @@ -198,6 +204,11 @@ def __init__( self._labware_data_provider = labware_data_provider or LabwareDataProvider() self._state_view = state_view + # WARNING: ErrorRecoveryHardwareStateSynchronizer can currently construct several + # instances of this class per run, in addition to the main instance used + # for command execution. We're therefore depending on this class being + # stateless, so consider that before adding additional attributes here. + async def available_for_nozzle_layout( self, pipette_id: str, @@ -223,7 +234,7 @@ async def pick_up_tip( well_name: str, ) -> TipGeometry: """See documentation on abstract base class.""" - hw_mount = self._state_view.pipettes.get_mount(pipette_id).to_hw_mount() + hw_mount = self._get_hw_mount(pipette_id) nominal_tip_geometry = self._state_view.geometry.get_nominal_tip_geometry( pipette_id=pipette_id, labware_id=labware_id, well_name=well_name @@ -235,34 +246,29 @@ async def pick_up_tip( nominal_fallback=nominal_tip_geometry.length, ) + tip_geometry = TipGeometry( + length=actual_tip_length, + diameter=nominal_tip_geometry.diameter, + volume=nominal_tip_geometry.volume, + ) + await self._hardware_api.tip_pickup_moves( mount=hw_mount, presses=None, increment=None ) - # Allow TipNotAttachedError to propagate. - await self.verify_tip_presence(pipette_id, TipPresenceStatus.PRESENT) - - self._hardware_api.cache_tip(hw_mount, actual_tip_length) - await self._hardware_api.prepare_for_aspirate(hw_mount) + try: + await self.verify_tip_presence(pipette_id, TipPresenceStatus.PRESENT) + except TipNotAttachedError as e: + raise PickUpTipTipNotAttachedError(tip_geometry=tip_geometry) from e - self._hardware_api.set_current_tiprack_diameter( - mount=hw_mount, - tiprack_diameter=nominal_tip_geometry.diameter, - ) + self.cache_tip(pipette_id, tip_geometry) - self._hardware_api.set_working_volume( - mount=hw_mount, - tip_volume=nominal_tip_geometry.volume, - ) + await self._hardware_api.prepare_for_aspirate(hw_mount) - return TipGeometry( - length=actual_tip_length, - diameter=nominal_tip_geometry.diameter, - volume=nominal_tip_geometry.volume, - ) + return tip_geometry async def drop_tip(self, pipette_id: str, home_after: Optional[bool]) -> None: """See documentation on abstract base class.""" - hw_mount = self._state_view.pipettes.get_mount(pipette_id).to_hw_mount() + hw_mount = self._get_hw_mount(pipette_id) # Let the hardware controller handle defaulting home_after since its behavior # differs between machines @@ -276,14 +282,13 @@ async def drop_tip(self, pipette_id: str, home_after: Optional[bool]) -> None: # Allow TipNotAttachedError to propagate. await self.verify_tip_presence(pipette_id, TipPresenceStatus.ABSENT) - self._hardware_api.remove_tip(hw_mount) - self._hardware_api.set_current_tiprack_diameter(hw_mount, 0) + self.remove_tip(pipette_id) - async def add_tip(self, pipette_id: str, tip: TipGeometry) -> None: + def cache_tip(self, pipette_id: str, tip: TipGeometry) -> None: """See documentation on abstract base class.""" - hw_mount = self._state_view.pipettes.get_mount(pipette_id).to_hw_mount() + hw_mount = self._get_hw_mount(pipette_id) - self._hardware_api.add_tip(mount=hw_mount, tip_length=tip.length) + self._hardware_api.cache_tip(mount=hw_mount, tip_length=tip.length) self._hardware_api.set_current_tiprack_diameter( mount=hw_mount, @@ -295,12 +300,18 @@ async def add_tip(self, pipette_id: str, tip: TipGeometry) -> None: tip_volume=tip.volume, ) + def remove_tip(self, pipette_id: str) -> None: + """See documentation on abstract base class.""" + hw_mount = self._get_hw_mount(pipette_id) + self._hardware_api.remove_tip(hw_mount) + self._hardware_api.set_current_tiprack_diameter(hw_mount, 0) + async def get_tip_presence(self, pipette_id: str) -> TipPresenceStatus: """See documentation on abstract base class.""" try: ot3api = ensure_ot3_hardware(hardware_api=self._hardware_api) - hw_mount = self._state_view.pipettes.get_mount(pipette_id).to_hw_mount() + hw_mount = self._get_hw_mount(pipette_id) status = await ot3api.get_tip_presence_status(hw_mount) return TipPresenceStatus.from_hw_state(status) @@ -341,7 +352,7 @@ async def verify_tip_presence( return try: ot3api = ensure_ot3_hardware(hardware_api=self._hardware_api) - hw_mount = self._state_view.pipettes.get_mount(pipette_id).to_hw_mount() + hw_mount = self._get_hw_mount(pipette_id) await ot3api.verify_tip_presence( hw_mount, expected.to_hw_state(), follow_singular_sensor ) @@ -359,6 +370,9 @@ async def verify_tip_presence( wrapping=[PythonException(e)], ) + def _get_hw_mount(self, pipette_id: str) -> Mount: + return self._state_view.pipettes.get_mount(pipette_id).to_hw_mount() + class VirtualTipHandler(TipHandler): """Pick up and drop tips, using a virtual pipette.""" @@ -422,12 +436,19 @@ async def drop_tip( expected_has_tip=True, ) - async def add_tip(self, pipette_id: str, tip: TipGeometry) -> None: - """Add a tip using a virtual pipette. + def cache_tip(self, pipette_id: str, tip: TipGeometry) -> None: + """See documentation on abstract base class. + + This should not be called when using virtual pipettes. + """ + assert False, "TipHandler.cache_tip should not be used with virtual pipettes" + + def remove_tip(self, pipette_id: str) -> None: + """See documentation on abstract base class. This should not be called when using virtual pipettes. """ - assert False, "TipHandler.add_tip should not be used with virtual pipettes" + assert False, "TipHandler.remove_tip should not be used with virtual pipettes" async def verify_tip_presence( self, diff --git a/api/src/opentrons/protocol_engine/notes/__init__.py b/api/src/opentrons/protocol_engine/notes/__init__.py index f5b1d8c1a2a..606d75665a4 100644 --- a/api/src/opentrons/protocol_engine/notes/__init__.py +++ b/api/src/opentrons/protocol_engine/notes/__init__.py @@ -1,5 +1,17 @@ """Protocol engine notes module.""" -from .notes import NoteKind, CommandNote, CommandNoteAdder, CommandNoteTracker +from .notes import ( + NoteKind, + CommandNote, + CommandNoteAdder, + CommandNoteTracker, + make_error_recovery_debug_note, +) -__all__ = ["NoteKind", "CommandNote", "CommandNoteAdder", "CommandNoteTracker"] +__all__ = [ + "NoteKind", + "CommandNote", + "CommandNoteAdder", + "CommandNoteTracker", + "make_error_recovery_debug_note", +] diff --git a/api/src/opentrons/protocol_engine/notes/notes.py b/api/src/opentrons/protocol_engine/notes/notes.py index cf381aa4a68..8c349d167cd 100644 --- a/api/src/opentrons/protocol_engine/notes/notes.py +++ b/api/src/opentrons/protocol_engine/notes/notes.py @@ -1,7 +1,10 @@ """Definitions of data and interface shapes for notes.""" -from typing import Union, Literal, Protocol, List +from typing import Union, Literal, Protocol, List, TYPE_CHECKING from pydantic import BaseModel, Field +if TYPE_CHECKING: + from opentrons.protocol_engine.error_recovery_policy import ErrorRecoveryType + NoteKind = Union[Literal["warning", "information"], str] @@ -26,6 +29,20 @@ class CommandNote(BaseModel): ) +def make_error_recovery_debug_note(type: "ErrorRecoveryType") -> CommandNote: + """Return a note for debugging error recovery. + + This is intended to be read by developers and support people, not computers. + """ + message = f"Handling this command failure with {type.name}." + return CommandNote.construct( + noteKind="debugErrorRecovery", + shortMessage=message, + longMessage=message, + source="execution", + ) + + class CommandNoteAdder(Protocol): """The shape of a function that something can use to add a command note.""" diff --git a/api/src/opentrons/protocol_engine/protocol_engine.py b/api/src/opentrons/protocol_engine/protocol_engine.py index d93ab5dd42d..574c3d076f9 100644 --- a/api/src/opentrons/protocol_engine/protocol_engine.py +++ b/api/src/opentrons/protocol_engine/protocol_engine.py @@ -6,7 +6,6 @@ ResumeFromRecoveryAction, SetErrorRecoveryPolicyAction, ) -from opentrons.protocol_engine.error_recovery_policy import ErrorRecoveryPolicy from opentrons.protocols.models import LabwareDefinition from opentrons.hardware_control import HardwareControlAPI @@ -19,6 +18,7 @@ from .errors import ProtocolCommandFailedError, ErrorOccurrence, CommandNotAllowedError from .errors.exceptions import EStopActivatedError +from .error_recovery_policy import ErrorRecoveryPolicy from . import commands, slot_standardization from .resources import ModelUtils, ModuleDataProvider, FileProvider from .types import ( @@ -39,6 +39,7 @@ HardwareStopper, ) from .state.state import StateStore, StateView +from .state.update_types import StateUpdate from .plugins import AbstractPlugin, PluginStarter from .actions import ( ActionDispatcher, @@ -88,43 +89,31 @@ def __init__( self, hardware_api: HardwareControlAPI, state_store: StateStore, - action_dispatcher: Optional[ActionDispatcher] = None, - plugin_starter: Optional[PluginStarter] = None, + action_dispatcher: ActionDispatcher, + plugin_starter: PluginStarter, + model_utils: ModelUtils, + hardware_stopper: HardwareStopper, + door_watcher: DoorWatcher, + module_data_provider: ModuleDataProvider, + file_provider: FileProvider, queue_worker: Optional[QueueWorker] = None, - model_utils: Optional[ModelUtils] = None, - hardware_stopper: Optional[HardwareStopper] = None, - door_watcher: Optional[DoorWatcher] = None, - module_data_provider: Optional[ModuleDataProvider] = None, - file_provider: Optional[FileProvider] = None, ) -> None: """Initialize a ProtocolEngine instance. Must be called while an event loop is active. - This constructor does not inject provider implementations. + This constructor is only for `ProtocolEngine` unit tests. Prefer the `create_protocol_engine()` factory function. """ self._hardware_api = hardware_api - self._file_provider = file_provider or FileProvider() + self._file_provider = file_provider self._state_store = state_store - self._model_utils = model_utils or ModelUtils() - self._action_dispatcher = action_dispatcher or ActionDispatcher( - sink=self._state_store - ) - self._plugin_starter = plugin_starter or PluginStarter( - state=self._state_store, - action_dispatcher=self._action_dispatcher, - ) - self._hardware_stopper = hardware_stopper or HardwareStopper( - hardware_api=hardware_api, - state_store=state_store, - ) - self._door_watcher = door_watcher or DoorWatcher( - state_store=state_store, - hardware_api=hardware_api, - action_dispatcher=self._action_dispatcher, - ) - self._module_data_provider = module_data_provider or ModuleDataProvider() + self._model_utils = model_utils + self._action_dispatcher = action_dispatcher + self._plugin_starter = plugin_starter + self._hardware_stopper = hardware_stopper + self._door_watcher = door_watcher + self._module_data_provider = module_data_provider self._queue_worker = queue_worker if self._queue_worker: self._queue_worker.start() @@ -186,11 +175,35 @@ def request_pause(self) -> None: self._action_dispatcher.dispatch(action) self._hardware_api.pause(HardwarePauseType.PAUSE) - def resume_from_recovery(self) -> None: - """Resume normal protocol execution after the engine was `AWAITING_RECOVERY`.""" + def resume_from_recovery(self, reconcile_false_positive: bool) -> None: + """Resume normal protocol execution after the engine was `AWAITING_RECOVERY`. + + If `reconcile_false_positive` is `False`, the engine will continue naively from + whatever state the error left it in. (Each defined error individually documents + exactly how it affects state.) This is appropriate for client-driven error + recovery, where the client wants predictable behavior from the engine. + + If `reconcile_false_positive` is `True`, the engine may apply additional fixups + to its state to try to get the rest of the run to just work, assuming the error + was a false-positive. + + For example, a `tipPhysicallyMissing` error from a `pickUpTip` would normally + leave the engine state without a tip on the pipette. If `reconcile_false_positive=True`, + the engine will set the pipette to have that missing tip before continuing, so + subsequent path planning, aspirates, dispenses, etc. will work as if nothing + went wrong. + """ + if reconcile_false_positive: + state_update = ( + self._state_store.commands.get_state_update_for_false_positive() + ) + else: + state_update = StateUpdate() # Empty/no-op. + action = self._state_store.commands.validate_action_allowed( - ResumeFromRecoveryAction() + ResumeFromRecoveryAction(state_update) ) + self._action_dispatcher.dispatch(action) def add_command( @@ -553,9 +566,12 @@ def add_liquid( description=(description or ""), displayColor=color, ) + validated_liquid = self._state_store.liquid.validate_liquid_allowed( + liquid=liquid + ) - self._action_dispatcher.dispatch(AddLiquidAction(liquid=liquid)) - return liquid + self._action_dispatcher.dispatch(AddLiquidAction(liquid=validated_liquid)) + return validated_liquid def add_addressable_area(self, addressable_area_name: str) -> None: """Add an addressable area to state.""" diff --git a/api/src/opentrons/protocol_engine/resources/file_provider.py b/api/src/opentrons/protocol_engine/resources/file_provider.py index d4ed7b71522..e1299605e76 100644 --- a/api/src/opentrons/protocol_engine/resources/file_provider.py +++ b/api/src/opentrons/protocol_engine/resources/file_provider.py @@ -5,7 +5,7 @@ from ..errors import StorageLimitReachedError -MAXIMUM_CSV_FILE_LIMIT = 40 +MAXIMUM_CSV_FILE_LIMIT = 400 class GenericCsvTransform: diff --git a/api/src/opentrons/protocol_engine/state/commands.py b/api/src/opentrons/protocol_engine/state/commands.py index 6723c521892..4d2009aae80 100644 --- a/api/src/opentrons/protocol_engine/state/commands.py +++ b/api/src/opentrons/protocol_engine/state/commands.py @@ -25,6 +25,7 @@ ErrorRecoveryType, ) from opentrons.protocol_engine.notes.notes import CommandNote +from opentrons.protocol_engine.state import update_types from ..actions import ( Action, @@ -141,6 +142,16 @@ class CommandPointer: index: int +@dataclass(frozen=True) +class _RecoveryTargetInfo: + """Info about the failed command that we're currently recovering from.""" + + command_id: str + + state_update_if_false_positive: update_types.StateUpdate + """See `CommandView.get_state_update_if_continued()`.""" + + @dataclass class CommandState: """State of all protocol engine command resources.""" @@ -205,8 +216,8 @@ class CommandState: stable. Eventually, we might want this info to be stored directly on each command. """ - recovery_target_command_id: Optional[str] - """If we're currently recovering from a command failure, which command it was.""" + recovery_target: Optional[_RecoveryTargetInfo] + """If we're currently recovering from a command failure, info about that command.""" finish_error: Optional[ErrorOccurrence] """The error that happened during the post-run finish steps (homing & dropping tips), if any.""" @@ -253,7 +264,7 @@ def __init__( finish_error=None, failed_command=None, command_error_recovery_types={}, - recovery_target_command_id=None, + recovery_target=None, run_completed_at=None, run_started_at=None, latest_protocol_command_hash=None, @@ -335,14 +346,17 @@ def _handle_succeed_command_action(self, action: SucceedCommandAction) -> None: def _handle_fail_command_action(self, action: FailCommandAction) -> None: prev_entry = self.state.command_history.get(action.command_id) - if isinstance(action.error, EnumeratedError): + if isinstance(action.error, EnumeratedError): # The error was undefined. public_error_occurrence = ErrorOccurrence.from_failed( id=action.error_id, createdAt=action.failed_at, error=action.error, ) - else: + # An empty state update, to no-op. + state_update_if_false_positive = update_types.StateUpdate() + else: # The error was defined. public_error_occurrence = action.error.public + state_update_if_false_positive = action.error.state_update_if_false_positive self._update_to_failed( command_id=action.command_id, @@ -354,6 +368,19 @@ def _handle_fail_command_action(self, action: FailCommandAction) -> None: self._state.failed_command = self._state.command_history.get(action.command_id) self._state.failed_command_errors.append(public_error_occurrence) + if ( + prev_entry.command.intent in (CommandIntent.PROTOCOL, None) + and action.type == ErrorRecoveryType.WAIT_FOR_RECOVERY + ): + self._state.queue_status = QueueStatus.AWAITING_RECOVERY + self._state.recovery_target = _RecoveryTargetInfo( + command_id=action.command_id, + state_update_if_false_positive=state_update_if_false_positive, + ) + self._state.has_entered_error_recovery = True + + # When one command fails, we generally also cancel the commands that + # would have been queued after it. other_command_ids_to_fail: List[str] if prev_entry.command.intent == CommandIntent.SETUP: other_command_ids_to_fail = list( @@ -373,7 +400,8 @@ def _handle_fail_command_action(self, action: FailCommandAction) -> None: ) elif ( action.type == ErrorRecoveryType.WAIT_FOR_RECOVERY - or action.type == ErrorRecoveryType.IGNORE_AND_CONTINUE + or action.type == ErrorRecoveryType.CONTINUE_WITH_ERROR + or action.type == ErrorRecoveryType.ASSUME_FALSE_POSITIVE_AND_CONTINUE ): other_command_ids_to_fail = [] else: @@ -390,14 +418,6 @@ def _handle_fail_command_action(self, action: FailCommandAction) -> None: notes=None, ) - if ( - prev_entry.command.intent in (CommandIntent.PROTOCOL, None) - and action.type == ErrorRecoveryType.WAIT_FOR_RECOVERY - ): - self._state.queue_status = QueueStatus.AWAITING_RECOVERY - self._state.recovery_target_command_id = action.command_id - self._state.has_entered_error_recovery = True - def _handle_play_action(self, action: PlayAction) -> None: if not self._state.run_result: self._state.run_started_at = ( @@ -425,13 +445,13 @@ def _handle_resume_from_recovery_action( self, action: ResumeFromRecoveryAction ) -> None: self._state.queue_status = QueueStatus.RUNNING - self._state.recovery_target_command_id = None + self._state.recovery_target = None def _handle_stop_action(self, action: StopAction) -> None: if not self._state.run_result: - self._state.recovery_target_command_id = None - + self._state.recovery_target = None self._state.queue_status = QueueStatus.PAUSED + if action.from_estop: self._state.stopped_by_estop = True self._state.run_result = RunResult.FAILED @@ -440,7 +460,9 @@ def _handle_stop_action(self, action: StopAction) -> None: def _handle_finish_action(self, action: FinishAction) -> None: if not self._state.run_result: + self._state.recovery_target = None self._state.queue_status = QueueStatus.PAUSED + if action.set_run_status: self._state.run_result = ( RunResult.SUCCEEDED @@ -866,11 +888,11 @@ def get_all_commands_final(self) -> bool: def get_recovery_target(self) -> Optional[CommandPointer]: """Return the command currently undergoing error recovery, if any.""" - recovery_target_command_id = self._state.recovery_target_command_id - if recovery_target_command_id is None: + recovery_target = self._state.recovery_target + if recovery_target is None: return None else: - entry = self._state.command_history.get(recovery_target_command_id) + entry = self._state.command_history.get(recovery_target.command_id) return CommandPointer( command_id=entry.command.id, command_key=entry.command.key, @@ -1083,6 +1105,19 @@ def get_error_recovery_policy(self) -> ErrorRecoveryPolicy: """ return self._state.error_recovery_policy + def get_state_update_for_false_positive(self) -> update_types.StateUpdate: + """Return the state update for if the current recovery target was a false positive. + + If we're currently in error recovery mode, and you have decided that the + underlying command error was a false positive, this returns a state update + that will undo the error's effects on engine state. + See `ProtocolEngine.resume_from_recovery(reconcile_false_positive=True)`. + """ + if self._state.recovery_target is None: + return update_types.StateUpdate() # Empty/no-op. + else: + return self._state.recovery_target.state_update_if_false_positive + def _may_run_with_door_open( self, *, fixit_command: Command | CommandCreate ) -> bool: diff --git a/api/src/opentrons/protocol_engine/state/fluid_stack.py b/api/src/opentrons/protocol_engine/state/fluid_stack.py new file mode 100644 index 00000000000..95465e531b2 --- /dev/null +++ b/api/src/opentrons/protocol_engine/state/fluid_stack.py @@ -0,0 +1,138 @@ +"""Implements fluid stack tracking for pipettes. + +Inside a pipette's tip, there can be a mix of kinds of fluids - here, "fluid" means "liquid" (i.e. a protocol-relevant +working liquid that is aspirated or dispensed from wells) or "air" (i.e. because there was an air gap). Since sometimes +you want air gaps in different places - physically-below liquid to prevent dripping, physically-above liquid to provide +extra room to push the plunger - we need to support some notion of at least phsyical ordinal position of air and liquid, +and we do so as a logical stack because that's physically relevant. +""" +from logging import getLogger +from numpy import isclose +from ..types import AspiratedFluid, FluidKind + +_LOG = getLogger(__name__) + + +class FluidStack: + """A FluidStack data structure is a list of AspiratedFluids, with stack-style (last-in-first-out) ordering. + + The front of the list is the physical-top of the liquid stack (logical-bottom of the stack data structure) + and the back of the list is the physical-bottom of the liquid stack (logical-top of the stack data structure). + The state is internal and the interaction surface is the methods. This is a mutating API. + """ + + _FluidStack = list[AspiratedFluid] + + _fluid_stack: _FluidStack + + def __init__(self, _fluid_stack: _FluidStack | None = None) -> None: + """Build a FluidStack. + + The argument is provided for testing and shouldn't be generally used. + """ + self._fluid_stack = _fluid_stack or [] + + def add_fluid(self, new: AspiratedFluid) -> None: + """Add fluid to a stack. + + If the new fluid is of a different kind than what's on the physical-bottom of the stack, add a new record. + If the new fluid is of the same kind as what's on the physical-bottom of the stack, add the new volume to + the same record. + """ + if len(self._fluid_stack) == 0 or self._fluid_stack[-1].kind != new.kind: + # this is a new kind of fluid, append the record + self._fluid_stack.append(new) + else: + # this is more of the same kind of fluid, add the volumes + old_fluid = self._fluid_stack.pop(-1) + self._fluid_stack.append( + AspiratedFluid(kind=new.kind, volume=old_fluid.volume + new.volume) + ) + + def _alter_fluid_records( + self, remove: int, new_last: AspiratedFluid | None + ) -> None: + if remove >= len(self._fluid_stack) or len(self._fluid_stack) == 0: + self._fluid_stack = [] + return + if remove != 0: + removed = self._fluid_stack[:-remove] + else: + removed = self._fluid_stack + if new_last: + removed[-1] = new_last + self._fluid_stack = removed + + def remove_fluid(self, volume: float) -> None: + """Remove a specific amount of fluid from the physical-bottom of the stack. + + This will consume records that are wholly included in the provided volume and alter the remaining + final records (if any) to decrement the amount of volume removed from it. + + This function is designed to be used inside pipette store action handlers, which are generally not + exception-safe, and therefore swallows and logs errors. + """ + self._fluid_stack_iterator = reversed(self._fluid_stack) + removed_elements: list[AspiratedFluid] = [] + while volume > 0: + try: + last_stack_element = next(self._fluid_stack_iterator) + except StopIteration: + _LOG.error( + f"Attempting to remove more fluid than present, {volume}uL left over" + ) + self._alter_fluid_records(len(removed_elements), None) + return + if last_stack_element.volume < volume: + removed_elements.append(last_stack_element) + volume -= last_stack_element.volume + elif isclose(last_stack_element.volume, volume): + self._alter_fluid_records(len(removed_elements) + 1, None) + return + else: + self._alter_fluid_records( + len(removed_elements), + AspiratedFluid( + kind=last_stack_element.kind, + volume=last_stack_element.volume - volume, + ), + ) + return + + _LOG.error(f"Failed to handle removing {volume}uL from {self._fluid_stack}") + + def aspirated_volume(self, kind: FluidKind | None = None) -> float: + """Measure the total amount of fluid (optionally filtered by kind) in the stack.""" + volume = 0.0 + for el in self._fluid_stack: + if kind is not None and el.kind != kind: + continue + volume += el.volume + return volume + + def liquid_part_of_dispense_volume(self, volume: float) -> float: + """Get the amount of liquid in the specified volume starting at the physical-bottom of the stack.""" + liquid_volume = 0.0 + for el in reversed(self._fluid_stack): + if el.kind == FluidKind.LIQUID: + liquid_volume += min(volume, el.volume) + volume -= min(el.volume, volume) + if isclose(volume, 0.0): + return liquid_volume + return liquid_volume + + def __eq__(self, other: object) -> bool: + """Equality.""" + if isinstance(other, type(self)): + return other._fluid_stack == self._fluid_stack + return False + + def __repr__(self) -> str: + """String representation of a fluid stack.""" + if self._fluid_stack: + stringified_stack = ( + f'(top) {", ".join([str(item) for item in self._fluid_stack])} (bottom)' + ) + else: + stringified_stack = "empty" + return f"<{self.__class__.__name__}: {stringified_stack}>" diff --git a/api/src/opentrons/protocol_engine/state/frustum_helpers.py b/api/src/opentrons/protocol_engine/state/frustum_helpers.py index 09f726de767..83499fb2510 100644 --- a/api/src/opentrons/protocol_engine/state/frustum_helpers.py +++ b/api/src/opentrons/protocol_engine/state/frustum_helpers.py @@ -11,6 +11,7 @@ SphericalSegment, ConicalFrustum, CuboidalFrustum, + SquaredConeSegment, ) @@ -127,6 +128,15 @@ def _volume_from_height_spherical( return volume +def _volume_from_height_squared_cone( + target_height: float, segment: SquaredConeSegment +) -> float: + """Find the volume given a height within a squared cone segment.""" + heights = segment.height_to_volume_table.keys() + best_fit_height = min(heights, key=lambda x: abs(x - target_height)) + return segment.height_to_volume_table[best_fit_height] + + def _height_from_volume_circular( volume: float, total_frustum_height: float, @@ -197,30 +207,52 @@ def _height_from_volume_spherical( return height +def _height_from_volume_squared_cone( + target_volume: float, segment: SquaredConeSegment +) -> float: + """Find the height given a volume within a squared cone segment.""" + volumes = segment.volume_to_height_table.keys() + best_fit_volume = min(volumes, key=lambda x: abs(x - target_volume)) + return segment.volume_to_height_table[best_fit_volume] + + def _get_segment_capacity(segment: WellSegment) -> float: + section_height = segment.topHeight - segment.bottomHeight match segment: case SphericalSegment(): - return _volume_from_height_spherical( - target_height=segment.topHeight, - radius_of_curvature=segment.radiusOfCurvature, + return ( + _volume_from_height_spherical( + target_height=segment.topHeight, + radius_of_curvature=segment.radiusOfCurvature, + ) + * segment.count ) case CuboidalFrustum(): - section_height = segment.topHeight - segment.bottomHeight - return _volume_from_height_rectangular( - target_height=section_height, - bottom_length=segment.bottomYDimension, - bottom_width=segment.bottomXDimension, - top_length=segment.topYDimension, - top_width=segment.topXDimension, - total_frustum_height=section_height, + return ( + _volume_from_height_rectangular( + target_height=section_height, + bottom_length=segment.bottomYDimension, + bottom_width=segment.bottomXDimension, + top_length=segment.topYDimension, + top_width=segment.topXDimension, + total_frustum_height=section_height, + ) + * segment.count ) case ConicalFrustum(): - section_height = segment.topHeight - segment.bottomHeight - return _volume_from_height_circular( - target_height=section_height, - total_frustum_height=section_height, - bottom_radius=(segment.bottomDiameter / 2), - top_radius=(segment.topDiameter / 2), + return ( + _volume_from_height_circular( + target_height=section_height, + total_frustum_height=section_height, + bottom_radius=(segment.bottomDiameter / 2), + top_radius=(segment.topDiameter / 2), + ) + * segment.count + ) + case SquaredConeSegment(): + return ( + _volume_from_height_squared_cone(section_height, segment) + * segment.count ) case _: # TODO: implement volume calculations for truncated circular and rounded rectangular segments @@ -252,6 +284,7 @@ def height_at_volume_within_section( section_height: float, ) -> float: """Calculate a height within a bounded section according to geometry.""" + target_volume_relative = target_volume_relative / section.count match section: case SphericalSegment(): return _height_from_volume_spherical( @@ -275,6 +308,8 @@ def height_at_volume_within_section( top_width=section.topXDimension, top_length=section.topYDimension, ) + case SquaredConeSegment(): + return _height_from_volume_squared_cone(target_volume_relative, section) case _: raise NotImplementedError( "Height from volume calculation not yet implemented for this well shape." @@ -289,25 +324,39 @@ def volume_at_height_within_section( """Calculate a volume within a bounded section according to geometry.""" match section: case SphericalSegment(): - return _volume_from_height_spherical( - target_height=target_height_relative, - radius_of_curvature=section.radiusOfCurvature, + return ( + _volume_from_height_spherical( + target_height=target_height_relative, + radius_of_curvature=section.radiusOfCurvature, + ) + * section.count ) case ConicalFrustum(): - return _volume_from_height_circular( - target_height=target_height_relative, - total_frustum_height=section_height, - bottom_radius=(section.bottomDiameter / 2), - top_radius=(section.topDiameter / 2), + return ( + _volume_from_height_circular( + target_height=target_height_relative, + total_frustum_height=section_height, + bottom_radius=(section.bottomDiameter / 2), + top_radius=(section.topDiameter / 2), + ) + * section.count ) case CuboidalFrustum(): - return _volume_from_height_rectangular( - target_height=target_height_relative, - total_frustum_height=section_height, - bottom_width=section.bottomXDimension, - bottom_length=section.bottomYDimension, - top_width=section.topXDimension, - top_length=section.topYDimension, + return ( + _volume_from_height_rectangular( + target_height=target_height_relative, + total_frustum_height=section_height, + bottom_width=section.bottomXDimension, + bottom_length=section.bottomYDimension, + top_width=section.topXDimension, + top_length=section.topYDimension, + ) + * section.count + ) + case SquaredConeSegment(): + return ( + _volume_from_height_squared_cone(target_height_relative, section) + * section.count ) case _: # TODO(cm): this would be the NEST-96 2uL wells referenced in EXEC-712 diff --git a/api/src/opentrons/protocol_engine/state/geometry.py b/api/src/opentrons/protocol_engine/state/geometry.py index 125be3339a9..471065adcc2 100644 --- a/api/src/opentrons/protocol_engine/state/geometry.py +++ b/api/src/opentrons/protocol_engine/state/geometry.py @@ -9,7 +9,6 @@ from opentrons.types import Point, DeckSlotName, StagingSlotName, MountType from opentrons_shared_data.labware.constants import WELL_NAME_PATTERN -from opentrons_shared_data.labware.labware_definition import InnerWellGeometry from opentrons_shared_data.deck.types import CutoutFixture from opentrons_shared_data.pipette import PIPETTE_X_SPAN from opentrons_shared_data.pipette.types import ChannelCount @@ -1372,6 +1371,7 @@ def get_well_offset_adjustment( Distance is with reference to the well bottom. """ + # TODO(pbm, 10-23-24): refactor to smartly reduce height/volume conversions initial_handling_height = self.get_well_handling_height( labware_id=labware_id, well_name=well_name, @@ -1386,9 +1386,9 @@ def get_well_offset_adjustment( volume = operation_volume or 0.0 if volume: - well_geometry = self._labware.get_well_geometry(labware_id, well_name) return self.get_well_height_after_volume( - well_geometry=well_geometry, + labware_id=labware_id, + well_name=well_name, initial_height=initial_handling_height, volume=volume, ) @@ -1401,15 +1401,36 @@ def get_meniscus_height( well_name: str, ) -> float: """Returns stored meniscus height in specified well.""" - meniscus_height = self._wells.get_last_measured_liquid_height( + well_liquid = self._wells.get_well_liquid_info( labware_id=labware_id, well_name=well_name ) - if meniscus_height is None: - raise errors.LiquidHeightUnknownError( - "Must liquid probe before specifying WellOrigin.MENISCUS." + if ( + well_liquid.probed_height is not None + and well_liquid.probed_height.height is not None + ): + return well_liquid.probed_height.height + elif ( + well_liquid.loaded_volume is not None + and well_liquid.loaded_volume.volume is not None + ): + return self.get_well_height_at_volume( + labware_id=labware_id, + well_name=well_name, + volume=well_liquid.loaded_volume.volume, + ) + elif ( + well_liquid.probed_volume is not None + and well_liquid.probed_volume.volume is not None + ): + return self.get_well_height_at_volume( + labware_id=labware_id, + well_name=well_name, + volume=well_liquid.probed_volume.volume, ) else: - return meniscus_height + raise errors.LiquidHeightUnknownError( + "Must LiquidProbe or LoadLiquid before specifying WellOrigin.MENISCUS." + ) def get_well_handling_height( self, @@ -1431,12 +1452,15 @@ def get_well_handling_height( return float(handling_height) def get_well_height_after_volume( - self, well_geometry: InnerWellGeometry, initial_height: float, volume: float + self, labware_id: str, well_name: str, initial_height: float, volume: float ) -> float: """Return the height of liquid in a labware well after a given volume has been handled. This is given an initial handling height, with reference to the well bottom. """ + well_geometry = self._labware.get_well_geometry( + labware_id=labware_id, well_name=well_name + ) initial_volume = find_volume_at_well_height( target_height=initial_height, well_geometry=well_geometry ) @@ -1445,6 +1469,24 @@ def get_well_height_after_volume( target_volume=final_volume, well_geometry=well_geometry ) + def get_well_height_at_volume( + self, labware_id: str, well_name: str, volume: float + ) -> float: + """Convert well volume to height.""" + well_geometry = self._labware.get_well_geometry(labware_id, well_name) + return find_height_at_well_volume( + target_volume=volume, well_geometry=well_geometry + ) + + def get_well_volume_at_height( + self, labware_id: str, well_name: str, height: float + ) -> float: + """Convert well height to volume.""" + well_geometry = self._labware.get_well_geometry(labware_id, well_name) + return find_volume_at_well_height( + target_height=height, well_geometry=well_geometry + ) + def validate_dispense_volume_into_well( self, labware_id: str, @@ -1456,6 +1498,7 @@ def validate_dispense_volume_into_well( well_def = self._labware.get_well_definition(labware_id, well_name) well_volumetric_capacity = well_def.totalLiquidVolume if well_location.origin == WellOrigin.MENISCUS: + # TODO(pbm, 10-23-24): refactor to smartly reduce height/volume conversions well_geometry = self._labware.get_well_geometry(labware_id, well_name) meniscus_height = self.get_meniscus_height( labware_id=labware_id, well_name=well_name diff --git a/api/src/opentrons/protocol_engine/state/labware.py b/api/src/opentrons/protocol_engine/state/labware.py index dad9fe54dd0..7cea4f9765b 100644 --- a/api/src/opentrons/protocol_engine/state/labware.py +++ b/api/src/opentrons/protocol_engine/state/labware.py @@ -53,7 +53,7 @@ Action, AddLabwareOffsetAction, AddLabwareDefinitionAction, - get_state_update, + get_state_updates, ) from ._abstract_store import HasState, HandlesActions from ._move_types import EdgePathType @@ -149,8 +149,7 @@ def __init__( def handle_action(self, action: Action) -> None: """Modify state in reaction to an action.""" - state_update = get_state_update(action) - if state_update is not None: + for state_update in get_state_updates(action): self._add_loaded_labware(state_update) self._set_labware_location(state_update) diff --git a/api/src/opentrons/protocol_engine/state/liquids.py b/api/src/opentrons/protocol_engine/state/liquids.py index 9394e4261b1..775223c6a60 100644 --- a/api/src/opentrons/protocol_engine/state/liquids.py +++ b/api/src/opentrons/protocol_engine/state/liquids.py @@ -1,11 +1,11 @@ """Basic liquid data state and store.""" from dataclasses import dataclass from typing import Dict, List -from opentrons.protocol_engine.types import Liquid +from opentrons.protocol_engine.types import Liquid, LiquidId from ._abstract_store import HasState, HandlesActions from ..actions import Action, AddLiquidAction -from ..errors import LiquidDoesNotExistError +from ..errors import LiquidDoesNotExistError, InvalidLiquidError @dataclass @@ -51,11 +51,23 @@ def get_all(self) -> List[Liquid]: """Get all protocol liquids.""" return list(self._state.liquids_by_id.values()) - def validate_liquid_id(self, liquid_id: str) -> str: + def validate_liquid_id(self, liquid_id: LiquidId) -> LiquidId: """Check if liquid_id exists in liquids.""" + is_empty = liquid_id == "EMPTY" + if is_empty: + return liquid_id has_liquid = liquid_id in self._state.liquids_by_id if not has_liquid: raise LiquidDoesNotExistError( f"Supplied liquidId: {liquid_id} does not exist in the loaded liquids." ) return liquid_id + + def validate_liquid_allowed(self, liquid: Liquid) -> Liquid: + """Validate that a liquid is legal to load.""" + is_empty = liquid.id == "EMPTY" + if is_empty: + raise InvalidLiquidError( + message='Protocols may not define a liquid with the special id "EMPTY".' + ) + return liquid diff --git a/api/src/opentrons/protocol_engine/state/pipettes.py b/api/src/opentrons/protocol_engine/state/pipettes.py index ced8b6076f7..8277204a4be 100644 --- a/api/src/opentrons/protocol_engine/state/pipettes.py +++ b/api/src/opentrons/protocol_engine/state/pipettes.py @@ -2,15 +2,17 @@ from __future__ import annotations import dataclasses +from logging import getLogger from typing import ( Dict, List, Mapping, Optional, Tuple, - Union, ) +from typing_extensions import assert_never + from opentrons_shared_data.pipette import pipette_definition from opentrons.config.defaults_ot2 import Z_RETRACT_DISTANCE from opentrons.hardware_control.dev_types import PipetteDict @@ -21,8 +23,7 @@ ) from opentrons.types import MountType, Mount as HwMount, Point -from . import update_types -from .. import commands +from . import update_types, fluid_stack from .. import errors from ..types import ( LoadedPipette, @@ -36,13 +37,13 @@ ) from ..actions import ( Action, - FailCommandAction, SetPipetteMovementSpeedAction, - SucceedCommandAction, - get_state_update, + get_state_updates, ) from ._abstract_store import HasState, HandlesActions +LOG = getLogger(__name__) + @dataclasses.dataclass(frozen=True) class HardwarePipette: @@ -108,7 +109,7 @@ class PipetteState: # attributes are populated at the appropriate times. Refactor to a # single dict-of-many-things instead of many dicts-of-single-things. pipettes_by_id: Dict[str, LoadedPipette] - aspirated_volume_by_id: Dict[str, Optional[float]] + pipette_contents_by_id: Dict[str, Optional[fluid_stack.FluidStack]] current_location: Optional[CurrentPipetteLocation] current_deck_point: CurrentDeckPoint attached_tip_by_id: Dict[str, Optional[TipGeometry]] @@ -128,7 +129,7 @@ def __init__(self) -> None: """Initialize a PipetteStore and its state.""" self._state = PipetteState( pipettes_by_id={}, - aspirated_volume_by_id={}, + pipette_contents_by_id={}, attached_tip_by_id={}, current_location=None, current_deck_point=CurrentDeckPoint(mount=None, deck_point=None), @@ -141,18 +142,15 @@ def __init__(self) -> None: def handle_action(self, action: Action) -> None: """Modify state in reaction to an action.""" - state_update = get_state_update(action) - if state_update is not None: + for state_update in get_state_updates(action): self._set_load_pipette(state_update) self._update_current_location(state_update) self._update_pipette_config(state_update) self._update_pipette_nozzle_map(state_update) self._update_tip_state(state_update) + self._update_volumes(state_update) - if isinstance(action, (SucceedCommandAction, FailCommandAction)): - self._update_volumes(action) - - elif isinstance(action, SetPipetteMovementSpeedAction): + if isinstance(action, SetPipetteMovementSpeedAction): self._state.movement_speed_by_id[action.pipette_id] = action.speed def _set_load_pipette(self, state_update: update_types.StateUpdate) -> None: @@ -167,7 +165,6 @@ def _set_load_pipette(self, state_update: update_types.StateUpdate) -> None: self._state.liquid_presence_detection_by_id[pipette_id] = ( state_update.loaded_pipette.liquid_presence_detection or False ) - self._state.aspirated_volume_by_id[pipette_id] = None self._state.movement_speed_by_id[pipette_id] = None self._state.attached_tip_by_id[pipette_id] = None @@ -178,7 +175,6 @@ def _update_tip_state(self, state_update: update_types.StateUpdate) -> None: attached_tip = state_update.pipette_tip_state.tip_geometry self._state.attached_tip_by_id[pipette_id] = attached_tip - self._state.aspirated_volume_by_id[pipette_id] = 0 static_config = self._state.static_config_by_id.get(pipette_id) if static_config: @@ -205,7 +201,6 @@ def _update_tip_state(self, state_update: update_types.StateUpdate) -> None: else: pipette_id = state_update.pipette_tip_state.pipette_id - self._state.aspirated_volume_by_id[pipette_id] = None self._state.attached_tip_by_id[pipette_id] = None static_config = self._state.static_config_by_id.get(pipette_id) @@ -309,51 +304,40 @@ def _update_pipette_nozzle_map( state_update.pipette_nozzle_map.pipette_id ] = state_update.pipette_nozzle_map.nozzle_map - def _update_volumes( - self, action: Union[SucceedCommandAction, FailCommandAction] + def _update_volumes(self, state_update: update_types.StateUpdate) -> None: + if state_update.pipette_aspirated_fluid == update_types.NO_CHANGE: + return + if state_update.pipette_aspirated_fluid.type == "aspirated": + self._update_aspirated(state_update.pipette_aspirated_fluid) + elif state_update.pipette_aspirated_fluid.type == "ejected": + self._update_ejected(state_update.pipette_aspirated_fluid) + elif state_update.pipette_aspirated_fluid.type == "empty": + self._update_empty(state_update.pipette_aspirated_fluid) + elif state_update.pipette_aspirated_fluid.type == "unknown": + self._update_unknown(state_update.pipette_aspirated_fluid) + else: + assert_never(state_update.pipette_aspirated_fluid.type) + + def _update_aspirated( + self, update: update_types.PipetteAspiratedFluidUpdate ) -> None: - # todo(mm, 2024-10-10): Port these isinstance checks to StateUpdate. - # https://opentrons.atlassian.net/browse/EXEC-754 + self._fluid_stack_log_if_empty(update.pipette_id).add_fluid(update.fluid) - if isinstance(action, SucceedCommandAction) and isinstance( - action.command.result, - (commands.AspirateResult, commands.AspirateInPlaceResult), - ): - pipette_id = action.command.params.pipetteId - previous_volume = self._state.aspirated_volume_by_id[pipette_id] or 0 - # PipetteHandler will have clamped action.command.result.volume for us, so - # next_volume should always be in bounds. - next_volume = previous_volume + action.command.result.volume + def _update_ejected(self, update: update_types.PipetteEjectedFluidUpdate) -> None: + self._fluid_stack_log_if_empty(update.pipette_id).remove_fluid(update.volume) - self._state.aspirated_volume_by_id[pipette_id] = next_volume + def _update_empty(self, update: update_types.PipetteEmptyFluidUpdate) -> None: + self._state.pipette_contents_by_id[update.pipette_id] = fluid_stack.FluidStack() - elif isinstance(action, SucceedCommandAction) and isinstance( - action.command.result, - (commands.DispenseResult, commands.DispenseInPlaceResult), - ): - pipette_id = action.command.params.pipetteId - previous_volume = self._state.aspirated_volume_by_id[pipette_id] or 0 - # PipetteHandler will have clamped action.command.result.volume for us, so - # next_volume should always be in bounds. - next_volume = previous_volume - action.command.result.volume - self._state.aspirated_volume_by_id[pipette_id] = next_volume - - elif isinstance(action, SucceedCommandAction) and isinstance( - action.command.result, - ( - commands.BlowOutResult, - commands.BlowOutInPlaceResult, - commands.unsafe.UnsafeBlowOutInPlaceResult, - ), - ): - pipette_id = action.command.params.pipetteId - self._state.aspirated_volume_by_id[pipette_id] = None + def _update_unknown(self, update: update_types.PipetteUnknownFluidUpdate) -> None: + self._state.pipette_contents_by_id[update.pipette_id] = None - elif isinstance(action, SucceedCommandAction) and isinstance( - action.command.result, commands.PrepareToAspirateResult - ): - pipette_id = action.command.params.pipetteId - self._state.aspirated_volume_by_id[pipette_id] = 0 + def _fluid_stack_log_if_empty(self, pipette_id: str) -> fluid_stack.FluidStack: + stack = self._state.pipette_contents_by_id[pipette_id] + if stack is None: + LOG.error("Pipette state tried to alter an unknown-contents pipette") + return fluid_stack.FluidStack() + return stack class PipetteView(HasState[PipetteState]): @@ -458,6 +442,10 @@ def get_all_attached_tips(self) -> List[Tuple[str, TipGeometry]]: def get_aspirated_volume(self, pipette_id: str) -> Optional[float]: """Get the currently aspirated volume of a pipette by ID. + This is the volume currently displaced by the plunger relative to its bottom position, + regardless of whether that volume likely contains liquid or air. This makes it the right + function to call to know how much more volume the plunger may displace. + Returns: The volume the pipette has aspirated. None, after blow-out and the plunger is in an unsafe position. @@ -469,13 +457,50 @@ def get_aspirated_volume(self, pipette_id: str) -> Optional[float]: self.validate_tip_state(pipette_id, True) try: - return self._state.aspirated_volume_by_id[pipette_id] + stack = self._state.pipette_contents_by_id[pipette_id] + if stack is None: + return None + return stack.aspirated_volume() except KeyError as e: raise errors.PipetteNotLoadedError( f"Pipette {pipette_id} not found; unable to get current volume." ) from e + def get_liquid_dispensed_by_ejecting_volume( + self, pipette_id: str, volume: float + ) -> Optional[float]: + """Get the amount of liquid (not air) that will be dispensed if the pipette ejects a specified volume. + + For instance, if the pipette contains, in vertical order, + 10 ul air + 80 ul liquid + 5 ul air + + then dispensing 10ul would result in 5ul of liquid; dispensing 85 ul would result in 80ul liquid; dispensing + 95ul would result in 80ul liquid. + + Returns: + The volume of liquid that would be dispensed by the requested volume. + None, after blow-out or when the plunger is in an unsafe position. + + Raises: + PipetteNotLoadedError: pipette ID does not exist. + TipnotAttachedError: No tip is attached to the pipette. + """ + self.validate_tip_state(pipette_id, True) + + try: + stack = self._state.pipette_contents_by_id[pipette_id] + if stack is None: + return None + return stack.liquid_part_of_dispense_volume(volume) + + except KeyError as e: + raise errors.PipetteNotLoadedError( + f"Pipette {pipette_id} not found; unable to get current liquid volume." + ) from e + def get_working_volume(self, pipette_id: str) -> float: """Get the working maximum volume of a pipette by ID. diff --git a/api/src/opentrons/protocol_engine/state/state_summary.py b/api/src/opentrons/protocol_engine/state/state_summary.py index b1c4dd8f766..7e47ccbbb37 100644 --- a/api/src/opentrons/protocol_engine/state/state_summary.py +++ b/api/src/opentrons/protocol_engine/state/state_summary.py @@ -6,12 +6,12 @@ from ..errors import ErrorOccurrence from ..types import ( EngineStatus, - LiquidHeightSummary, LoadedLabware, LabwareOffset, LoadedModule, LoadedPipette, Liquid, + WellInfoSummary, ) @@ -30,5 +30,5 @@ class StateSummary(BaseModel): startedAt: Optional[datetime] completedAt: Optional[datetime] liquids: List[Liquid] = Field(default_factory=list) - wells: List[LiquidHeightSummary] = Field(default_factory=list) + wells: List[WellInfoSummary] = Field(default_factory=list) files: List[str] = Field(default_factory=list) diff --git a/api/src/opentrons/protocol_engine/state/tips.py b/api/src/opentrons/protocol_engine/state/tips.py index f744b1a01b4..1ac3e91f795 100644 --- a/api/src/opentrons/protocol_engine/state/tips.py +++ b/api/src/opentrons/protocol_engine/state/tips.py @@ -6,12 +6,7 @@ from opentrons.protocol_engine.state import update_types from ._abstract_store import HasState, HandlesActions -from ..actions import Action, SucceedCommandAction, ResetTipsAction, get_state_update -from ..commands import ( - Command, - LoadLabwareResult, -) -from ..commands.configuring_common import PipetteConfigUpdateResultMixin +from ..actions import Action, ResetTipsAction, get_state_updates from opentrons.hardware_control.nozzle_manager import NozzleMap @@ -63,23 +58,10 @@ def __init__(self) -> None: def handle_action(self, action: Action) -> None: """Modify state in reaction to an action.""" - state_update = get_state_update(action) - if state_update is not None: + for state_update in get_state_updates(action): self._handle_state_update(state_update) - if isinstance(action, SucceedCommandAction): - if isinstance(action.private_result, PipetteConfigUpdateResultMixin): - pipette_id = action.private_result.pipette_id - config = action.private_result.config - self._state.pipette_info_by_pipette_id[pipette_id] = _PipetteInfo( - channels=config.channels, - active_channels=config.channels, - nozzle_map=config.nozzle_map, - ) - - self._handle_succeeded_command(action.command) - - elif isinstance(action, ResetTipsAction): + if isinstance(action, ResetTipsAction): labware_id = action.labware_id for well_name in self._state.tips_by_labware_id[labware_id].keys(): @@ -87,23 +69,16 @@ def handle_action(self, action: Action) -> None: well_name ] = TipRackWellState.CLEAN - def _handle_succeeded_command(self, command: Command) -> None: - if ( - isinstance(command.result, LoadLabwareResult) - and command.result.definition.parameters.isTiprack - ): - labware_id = command.result.labwareId - definition = command.result.definition - self._state.tips_by_labware_id[labware_id] = { - well_name: TipRackWellState.CLEAN - for column in definition.ordering - for well_name in column - } - self._state.column_by_labware_id[labware_id] = [ - column for column in definition.ordering - ] - def _handle_state_update(self, state_update: update_types.StateUpdate) -> None: + if state_update.pipette_config != update_types.NO_CHANGE: + self._state.pipette_info_by_pipette_id[ + state_update.pipette_config.pipette_id + ] = _PipetteInfo( + channels=state_update.pipette_config.config.channels, + active_channels=state_update.pipette_config.config.channels, + nozzle_map=state_update.pipette_config.config.nozzle_map, + ) + if state_update.tips_used != update_types.NO_CHANGE: self._set_used_tips( pipette_id=state_update.tips_used.pipette_id, @@ -120,6 +95,19 @@ def _handle_state_update(self, state_update: update_types.StateUpdate) -> None: ) pipette_info.nozzle_map = state_update.pipette_nozzle_map.nozzle_map + if state_update.loaded_labware != update_types.NO_CHANGE: + labware_id = state_update.loaded_labware.labware_id + definition = state_update.loaded_labware.definition + if definition.parameters.isTiprack: + self._state.tips_by_labware_id[labware_id] = { + well_name: TipRackWellState.CLEAN + for column in definition.ordering + for well_name in column + } + self._state.column_by_labware_id[labware_id] = [ + column for column in definition.ordering + ] + def _set_used_tips( # noqa: C901 self, pipette_id: str, well_name: str, labware_id: str ) -> None: diff --git a/api/src/opentrons/protocol_engine/state/update_types.py b/api/src/opentrons/protocol_engine/state/update_types.py index 5d941d33933..4487a503173 100644 --- a/api/src/opentrons/protocol_engine/state/update_types.py +++ b/api/src/opentrons/protocol_engine/state/update_types.py @@ -4,10 +4,16 @@ import dataclasses import enum import typing +from datetime import datetime from opentrons.hardware_control.nozzle_manager import NozzleMap from opentrons.protocol_engine.resources import pipette_data_provider -from opentrons.protocol_engine.types import DeckPoint, LabwareLocation, TipGeometry +from opentrons.protocol_engine.types import ( + DeckPoint, + LabwareLocation, + TipGeometry, + AspiratedFluid, +) from opentrons.types import MountType from opentrons_shared_data.labware.labware_definition import LabwareDefinition from opentrons_shared_data.pipette.types import PipetteNameType @@ -175,6 +181,69 @@ class TipsUsedUpdate: """ +@dataclasses.dataclass +class LiquidLoadedUpdate: + """An update from loading a liquid.""" + + labware_id: str + volumes: typing.Dict[str, float] + last_loaded: datetime + + +@dataclasses.dataclass +class LiquidProbedUpdate: + """An update from probing a liquid.""" + + labware_id: str + well_name: str + last_probed: datetime + height: float | ClearType + volume: float | ClearType + + +@dataclasses.dataclass +class LiquidOperatedUpdate: + """An update from operating a liquid.""" + + labware_id: str + well_name: str + volume_added: float | ClearType + + +@dataclasses.dataclass +class PipetteAspiratedFluidUpdate: + """Represents the pipette aspirating something. Might be air or liquid from a well.""" + + pipette_id: str + fluid: AspiratedFluid + type: typing.Literal["aspirated"] = "aspirated" + + +@dataclasses.dataclass +class PipetteEjectedFluidUpdate: + """Represents the pipette pushing something out. Might be air or liquid.""" + + pipette_id: str + volume: float + type: typing.Literal["ejected"] = "ejected" + + +@dataclasses.dataclass +class PipetteUnknownFluidUpdate: + """Represents the amount of fluid in the pipette becoming unknown.""" + + pipette_id: str + type: typing.Literal["unknown"] = "unknown" + + +@dataclasses.dataclass +class PipetteEmptyFluidUpdate: + """Sets the pipette to be valid and empty.""" + + pipette_id: str + type: typing.Literal["empty"] = "empty" + + @dataclasses.dataclass class StateUpdate: """Represents an update to perform on engine state.""" @@ -189,12 +258,22 @@ class StateUpdate: pipette_tip_state: PipetteTipStateUpdate | NoChangeType = NO_CHANGE + pipette_aspirated_fluid: PipetteAspiratedFluidUpdate | PipetteEjectedFluidUpdate | PipetteUnknownFluidUpdate | PipetteEmptyFluidUpdate | NoChangeType = ( + NO_CHANGE + ) + labware_location: LabwareLocationUpdate | NoChangeType = NO_CHANGE loaded_labware: LoadedLabwareUpdate | NoChangeType = NO_CHANGE tips_used: TipsUsedUpdate | NoChangeType = NO_CHANGE + liquid_loaded: LiquidLoadedUpdate | NoChangeType = NO_CHANGE + + liquid_probed: LiquidProbedUpdate | NoChangeType = NO_CHANGE + + liquid_operated: LiquidOperatedUpdate | NoChangeType = NO_CHANGE + # These convenience functions let the caller avoid the boilerplate of constructing a # complicated dataclass tree. @@ -330,3 +409,67 @@ def mark_tips_as_used( self.tips_used = TipsUsedUpdate( pipette_id=pipette_id, labware_id=labware_id, well_name=well_name ) + + def set_liquid_loaded( + self, + labware_id: str, + volumes: typing.Dict[str, float], + last_loaded: datetime, + ) -> None: + """Add liquid volumes to well state. See `LoadLiquidUpdate`.""" + self.liquid_loaded = LiquidLoadedUpdate( + labware_id=labware_id, + volumes=volumes, + last_loaded=last_loaded, + ) + + def set_liquid_probed( + self, + labware_id: str, + well_name: str, + last_probed: datetime, + height: float | ClearType, + volume: float | ClearType, + ) -> None: + """Add a liquid height and volume to well state. See `ProbeLiquidUpdate`.""" + self.liquid_probed = LiquidProbedUpdate( + labware_id=labware_id, + well_name=well_name, + height=height, + volume=volume, + last_probed=last_probed, + ) + + def set_liquid_operated( + self, labware_id: str, well_name: str, volume_added: float | ClearType + ) -> None: + """Update liquid volumes in well state. See `OperateLiquidUpdate`.""" + self.liquid_operated = LiquidOperatedUpdate( + labware_id=labware_id, + well_name=well_name, + volume_added=volume_added, + ) + + def set_fluid_aspirated(self, pipette_id: str, fluid: AspiratedFluid) -> None: + """Update record of fluid held inside a pipette. See `PipetteAspiratedFluidUpdate`.""" + self.pipette_aspirated_fluid = PipetteAspiratedFluidUpdate( + type="aspirated", pipette_id=pipette_id, fluid=fluid + ) + + def set_fluid_ejected(self, pipette_id: str, volume: float) -> None: + """Update record of fluid held inside a pipette. See `PipetteEjectedFluidUpdate`.""" + self.pipette_aspirated_fluid = PipetteEjectedFluidUpdate( + type="ejected", pipette_id=pipette_id, volume=volume + ) + + def set_fluid_unknown(self, pipette_id: str) -> None: + """Update record of fluid held inside a pipette. See `PipetteUnknownFluidUpdate`.""" + self.pipette_aspirated_fluid = PipetteUnknownFluidUpdate( + type="unknown", pipette_id=pipette_id + ) + + def set_fluid_empty(self, pipette_id: str) -> None: + """Update record fo fluid held inside a pipette. See `PipetteEmptyFluidUpdate`.""" + self.pipette_aspirated_fluid = PipetteEmptyFluidUpdate( + type="empty", pipette_id=pipette_id + ) diff --git a/api/src/opentrons/protocol_engine/state/wells.py b/api/src/opentrons/protocol_engine/state/wells.py index d74d94a1be0..5b4d3bb8d77 100644 --- a/api/src/opentrons/protocol_engine/state/wells.py +++ b/api/src/opentrons/protocol_engine/state/wells.py @@ -1,25 +1,32 @@ """Basic well data state and store.""" from dataclasses import dataclass -from datetime import datetime -from typing import Dict, List, Optional -from opentrons.protocol_engine.actions.actions import ( - FailCommandAction, - SucceedCommandAction, +from typing import Dict, List, Union, Iterator, Optional, Tuple, overload, TypeVar + +from opentrons.protocol_engine.types import ( + ProbedHeightInfo, + ProbedVolumeInfo, + LoadedVolumeInfo, + WellInfoSummary, + WellLiquidInfo, ) -from opentrons.protocol_engine.commands.liquid_probe import LiquidProbeResult -from opentrons.protocol_engine.commands.pipetting_common import LiquidNotFoundError -from opentrons.protocol_engine.types import LiquidHeightInfo, LiquidHeightSummary +from . import update_types from ._abstract_store import HasState, HandlesActions from ..actions import Action -from ..commands import Command +from ..actions.get_state_update import get_state_updates + + +LabwareId = str +WellName = str @dataclass class WellState: """State of all wells.""" - measured_liquid_heights: Dict[str, Dict[str, LiquidHeightInfo]] + loaded_volumes: Dict[LabwareId, Dict[WellName, LoadedVolumeInfo]] + probed_heights: Dict[LabwareId, Dict[WellName, ProbedHeightInfo]] + probed_volumes: Dict[LabwareId, Dict[WellName, ProbedVolumeInfo]] class WellStore(HasState[WellState], HandlesActions): @@ -29,41 +36,95 @@ class WellStore(HasState[WellState], HandlesActions): def __init__(self) -> None: """Initialize a well store and its state.""" - self._state = WellState(measured_liquid_heights={}) + self._state = WellState(loaded_volumes={}, probed_heights={}, probed_volumes={}) def handle_action(self, action: Action) -> None: """Modify state in reaction to an action.""" - if isinstance(action, SucceedCommandAction): - self._handle_succeeded_command(action.command) - if isinstance(action, FailCommandAction): - self._handle_failed_command(action) - - def _handle_succeeded_command(self, command: Command) -> None: - if isinstance(command.result, LiquidProbeResult): - self._set_liquid_height( - labware_id=command.params.labwareId, - well_name=command.params.wellName, - height=command.result.z_position, - time=command.createdAt, - ) - - def _handle_failed_command(self, action: FailCommandAction) -> None: - if isinstance(action.error, LiquidNotFoundError): - self._set_liquid_height( - labware_id=action.error.private.labware_id, - well_name=action.error.private.well_name, - height=None, - time=action.failed_at, + for state_update in get_state_updates(action): + if state_update.liquid_loaded != update_types.NO_CHANGE: + self._handle_liquid_loaded_update(state_update.liquid_loaded) + if state_update.liquid_probed != update_types.NO_CHANGE: + self._handle_liquid_probed_update(state_update.liquid_probed) + if state_update.liquid_operated != update_types.NO_CHANGE: + self._handle_liquid_operated_update(state_update.liquid_operated) + + def _handle_liquid_loaded_update( + self, state_update: update_types.LiquidLoadedUpdate + ) -> None: + labware_id = state_update.labware_id + if labware_id not in self._state.loaded_volumes: + self._state.loaded_volumes[labware_id] = {} + for (well, volume) in state_update.volumes.items(): + self._state.loaded_volumes[labware_id][well] = LoadedVolumeInfo( + volume=_none_from_clear(volume), + last_loaded=state_update.last_loaded, + operations_since_load=0, ) - def _set_liquid_height( - self, labware_id: str, well_name: str, height: float, time: datetime + def _handle_liquid_probed_update( + self, state_update: update_types.LiquidProbedUpdate + ) -> None: + labware_id = state_update.labware_id + well_name = state_update.well_name + if labware_id not in self._state.probed_heights: + self._state.probed_heights[labware_id] = {} + if labware_id not in self._state.probed_volumes: + self._state.probed_volumes[labware_id] = {} + self._state.probed_heights[labware_id][well_name] = ProbedHeightInfo( + height=_none_from_clear(state_update.height), + last_probed=state_update.last_probed, + ) + self._state.probed_volumes[labware_id][well_name] = ProbedVolumeInfo( + volume=_none_from_clear(state_update.volume), + last_probed=state_update.last_probed, + operations_since_probe=0, + ) + + def _handle_liquid_operated_update( + self, state_update: update_types.LiquidOperatedUpdate ) -> None: - """Set the liquid height of the well.""" - lhi = LiquidHeightInfo(height=height, last_measured=time) - if labware_id not in self._state.measured_liquid_heights: - self._state.measured_liquid_heights[labware_id] = {} - self._state.measured_liquid_heights[labware_id][well_name] = lhi + labware_id = state_update.labware_id + well_name = state_update.well_name + if ( + labware_id in self._state.loaded_volumes + and well_name in self._state.loaded_volumes[labware_id] + ): + if state_update.volume_added is update_types.CLEAR: + del self._state.loaded_volumes[labware_id][well_name] + else: + prev_loaded_vol_info = self._state.loaded_volumes[labware_id][well_name] + assert prev_loaded_vol_info.volume is not None + self._state.loaded_volumes[labware_id][well_name] = LoadedVolumeInfo( + volume=prev_loaded_vol_info.volume + state_update.volume_added, + last_loaded=prev_loaded_vol_info.last_loaded, + operations_since_load=prev_loaded_vol_info.operations_since_load + + 1, + ) + if ( + labware_id in self._state.probed_heights + and well_name in self._state.probed_heights[labware_id] + ): + del self._state.probed_heights[labware_id][well_name] + if ( + labware_id in self._state.probed_volumes + and well_name in self._state.probed_volumes[labware_id] + ): + if state_update.volume_added is update_types.CLEAR: + del self._state.probed_volumes[labware_id][well_name] + else: + prev_probed_vol_info = self._state.probed_volumes[labware_id][well_name] + if prev_probed_vol_info.volume is None: + new_vol_info: float | None = None + else: + new_vol_info = ( + prev_probed_vol_info.volume + state_update.volume_added + ) + self._state.probed_volumes[labware_id][well_name] = ProbedVolumeInfo( + volume=new_vol_info, + last_probed=prev_probed_vol_info.last_probed, + operations_since_probe=prev_probed_vol_info.operations_since_probe + + 1, + ) class WellView(HasState[WellState]): @@ -79,51 +140,97 @@ def __init__(self, state: WellState) -> None: """ self._state = state - def get_all(self) -> List[LiquidHeightSummary]: - """Get all well liquid heights.""" - all_heights: List[LiquidHeightSummary] = [] - for labware, wells in self._state.measured_liquid_heights.items(): - for well, lhi in wells.items(): - lhs = LiquidHeightSummary( - labware_id=labware, - well_name=well, - height=lhi.height, - last_measured=lhi.last_measured, - ) - all_heights.append(lhs) - return all_heights - - def get_all_in_labware(self, labware_id: str) -> List[LiquidHeightSummary]: - """Get all well liquid heights for a particular labware.""" - all_heights: List[LiquidHeightSummary] = [] - for well, lhi in self._state.measured_liquid_heights[labware_id].items(): - lhs = LiquidHeightSummary( - labware_id=labware_id, - well_name=well, - height=lhi.height, - last_measured=lhi.last_measured, - ) - all_heights.append(lhs) - return all_heights - - def get_last_measured_liquid_height( - self, labware_id: str, well_name: str - ) -> Optional[float]: - """Returns the height of the liquid according to the most recent liquid level probe to this well. - - Returns None if no liquid probe has been done. - """ - try: - height = self._state.measured_liquid_heights[labware_id][well_name].height - return height - except KeyError: - return None - - def has_measured_liquid_height(self, labware_id: str, well_name: str) -> bool: - """Returns True if the well has been liquid level probed previously.""" - try: - return bool( - self._state.measured_liquid_heights[labware_id][well_name].height - ) - except KeyError: - return False + def get_well_liquid_info(self, labware_id: str, well_name: str) -> WellLiquidInfo: + """Return all the liquid info for a well.""" + if ( + labware_id not in self._state.loaded_volumes + or well_name not in self._state.loaded_volumes[labware_id] + ): + loaded_volume_info = None + else: + loaded_volume_info = self._state.loaded_volumes[labware_id][well_name] + if ( + labware_id not in self._state.probed_heights + or well_name not in self._state.probed_heights[labware_id] + ): + probed_height_info = None + else: + probed_height_info = self._state.probed_heights[labware_id][well_name] + if ( + labware_id not in self._state.probed_volumes + or well_name not in self._state.probed_volumes[labware_id] + ): + probed_volume_info = None + else: + probed_volume_info = self._state.probed_volumes[labware_id][well_name] + return WellLiquidInfo( + loaded_volume=loaded_volume_info, + probed_height=probed_height_info, + probed_volume=probed_volume_info, + ) + + def get_all(self) -> List[WellInfoSummary]: + """Get all well liquid info summaries.""" + + def _all_well_combos() -> Iterator[Tuple[str, str, str]]: + for labware, lv_wells in self._state.loaded_volumes.items(): + for well_name in lv_wells.keys(): + yield f"{labware}{well_name}", labware, well_name + for labware, ph_wells in self._state.probed_heights.items(): + for well_name in ph_wells.keys(): + yield f"{labware}{well_name}", labware, well_name + for labware, pv_wells in self._state.probed_volumes.items(): + for well_name in pv_wells.keys(): + yield f"{labware}{well_name}", labware, well_name + + wells = { + key: (labware_id, well_name) + for key, labware_id, well_name in _all_well_combos() + } + return [ + self._summarize_well(labware_id, well_name) + for labware_id, well_name in wells.values() + ] + + def _summarize_well(self, labware_id: str, well_name: str) -> WellInfoSummary: + well_liquid_info = self.get_well_liquid_info(labware_id, well_name) + return WellInfoSummary( + labware_id=labware_id, + well_name=well_name, + loaded_volume=_volume_from_info(well_liquid_info.loaded_volume), + probed_volume=_volume_from_info(well_liquid_info.probed_volume), + probed_height=_height_from_info(well_liquid_info.probed_height), + ) + + +@overload +def _volume_from_info(info: Optional[ProbedVolumeInfo]) -> Optional[float]: + ... + + +@overload +def _volume_from_info(info: Optional[LoadedVolumeInfo]) -> Optional[float]: + ... + + +def _volume_from_info( + info: Union[ProbedVolumeInfo, LoadedVolumeInfo, None] +) -> Optional[float]: + if info is None: + return None + return info.volume + + +def _height_from_info(info: Optional[ProbedHeightInfo]) -> Optional[float]: + if info is None: + return None + return info.height + + +MaybeClear = TypeVar("MaybeClear") + + +def _none_from_clear(inval: MaybeClear | update_types.ClearType) -> MaybeClear | None: + if inval == update_types.CLEAR: + return None + return inval diff --git a/api/src/opentrons/protocol_engine/types.py b/api/src/opentrons/protocol_engine/types.py index 72daafd3a52..5aa4c8c26e9 100644 --- a/api/src/opentrons/protocol_engine/types.py +++ b/api/src/opentrons/protocol_engine/types.py @@ -355,20 +355,46 @@ class CurrentWell: well_name: str -class LiquidHeightInfo(BaseModel): - """Payload required to store recent measured liquid heights.""" +class LoadedVolumeInfo(BaseModel): + """A well's liquid volume, initialized by a LoadLiquid, updated by Aspirate and Dispense.""" - height: float - last_measured: datetime + volume: Optional[float] = None + last_loaded: datetime + operations_since_load: int -class LiquidHeightSummary(BaseModel): - """Payload for liquid state height in StateSummary.""" +class ProbedHeightInfo(BaseModel): + """A well's liquid height, initialized by a LiquidProbe, cleared by Aspirate and Dispense.""" + + height: Optional[float] = None + last_probed: datetime + + +class ProbedVolumeInfo(BaseModel): + """A well's liquid volume, initialized by a LiquidProbe, updated by Aspirate and Dispense.""" + + volume: Optional[float] = None + last_probed: datetime + operations_since_probe: int + + +class WellInfoSummary(BaseModel): + """Payload for a well's liquid info in StateSummary.""" labware_id: str well_name: str - height: float - last_measured: datetime + loaded_volume: Optional[float] = None + probed_height: Optional[float] = None + probed_volume: Optional[float] = None + + +@dataclass +class WellLiquidInfo: + """Tracked and sensed information about liquid in a well.""" + + probed_height: Optional[ProbedHeightInfo] + loaded_volume: Optional[LoadedVolumeInfo] + probed_volume: Optional[ProbedVolumeInfo] @dataclass(frozen=True) @@ -397,6 +423,21 @@ class TipGeometry: volume: float +class FluidKind(str, Enum): + """A kind of fluid that can be inside a pipette.""" + + LIQUID = "LIQUID" + AIR = "AIR" + + +@dataclass(frozen=True) +class AspiratedFluid: + """Fluid inside a pipette.""" + + kind: FluidKind + volume: float + + class MovementAxis(str, Enum): """Axis on which to issue a relative movement.""" @@ -787,6 +828,10 @@ def _color_is_a_valid_hex(cls, v: str) -> str: return v +EmptyLiquidId = Literal["EMPTY"] +LiquidId = str | EmptyLiquidId + + class Liquid(BaseModel): """Payload required to create a liquid.""" diff --git a/api/src/opentrons/protocol_runner/legacy_command_mapper.py b/api/src/opentrons/protocol_runner/legacy_command_mapper.py index 686560c1ca2..27b1c7ea331 100644 --- a/api/src/opentrons/protocol_runner/legacy_command_mapper.py +++ b/api/src/opentrons/protocol_runner/legacy_command_mapper.py @@ -271,7 +271,6 @@ def map_command( # noqa: C901 results.append( pe_actions.SucceedCommandAction( completed_command, - private_result=None, state_update=StateUpdate(), ) ) @@ -689,7 +688,6 @@ def _map_labware_load( succeed_action = pe_actions.SucceedCommandAction( command=succeeded_command, - private_result=None, state_update=state_update, ) @@ -731,7 +729,14 @@ def _map_instrument_load( result=pe_commands.LoadPipetteResult.construct(pipetteId=pipette_id), ) serial = instrument_load_info.pipette_dict.get("pipette_id", None) or "" - pipette_config_result = pe_commands.LoadPipettePrivateResult( + state_update = StateUpdate() + state_update.set_load_pipette( + pipette_id=pipette_id, + mount=succeeded_command.params.mount, + pipette_name=succeeded_command.params.pipetteName, + liquid_presence_detection=succeeded_command.params.liquidPresenceDetection, + ) + state_update.update_pipette_config( pipette_id=pipette_id, serial_number=serial, config=pipette_data_provider.get_pipette_static_config( @@ -754,16 +759,9 @@ def _map_instrument_load( # We just set this above, so we know it's not None. started_at=succeeded_command.startedAt, # type: ignore[arg-type] ) - state_update = StateUpdate() - state_update.set_load_pipette( - pipette_id=pipette_id, - mount=succeeded_command.params.mount, - pipette_name=succeeded_command.params.pipetteName, - liquid_presence_detection=succeeded_command.params.liquidPresenceDetection, - ) + succeed_action = pe_actions.SucceedCommandAction( command=succeeded_command, - private_result=pipette_config_result, state_update=state_update, ) @@ -829,7 +827,7 @@ def _map_module_load( started_at=succeeded_command.startedAt, # type: ignore[arg-type] ) succeed_action = pe_actions.SucceedCommandAction( - command=succeeded_command, private_result=None, state_update=StateUpdate() + command=succeeded_command, state_update=StateUpdate() ) self._command_count["LOAD_MODULE"] = count + 1 diff --git a/api/src/opentrons/protocol_runner/protocol_runner.py b/api/src/opentrons/protocol_runner/protocol_runner.py index dcf4f224811..aec2aae80df 100644 --- a/api/src/opentrons/protocol_runner/protocol_runner.py +++ b/api/src/opentrons/protocol_runner/protocol_runner.py @@ -123,9 +123,9 @@ async def stop(self) -> None: post_run_hardware_state=PostRunHardwareState.STAY_ENGAGED_IN_PLACE, ) - def resume_from_recovery(self) -> None: + def resume_from_recovery(self, reconcile_false_positive: bool) -> None: """See `ProtocolEngine.resume_from_recovery()`.""" - self._protocol_engine.resume_from_recovery() + self._protocol_engine.resume_from_recovery(reconcile_false_positive) @abstractmethod async def run( diff --git a/api/src/opentrons/protocol_runner/run_orchestrator.py b/api/src/opentrons/protocol_runner/run_orchestrator.py index 697e4a14e3a..dfa66e6a55a 100644 --- a/api/src/opentrons/protocol_runner/run_orchestrator.py +++ b/api/src/opentrons/protocol_runner/run_orchestrator.py @@ -205,9 +205,9 @@ async def stop(self) -> None: post_run_hardware_state=PostRunHardwareState.STAY_ENGAGED_IN_PLACE, ) - def resume_from_recovery(self) -> None: + def resume_from_recovery(self, reconcile_false_positive: bool) -> None: """Resume the run from recovery.""" - self._protocol_engine.resume_from_recovery() + self._protocol_engine.resume_from_recovery(reconcile_false_positive) async def finish( self, @@ -257,6 +257,22 @@ def get_current_command(self) -> Optional[CommandPointer]: """Get the "current" command, if any.""" return self._protocol_engine.state_view.commands.get_current() + def get_most_recently_finalized_command(self) -> Optional[CommandPointer]: + """Get the most recently finalized command, if any.""" + most_recently_finalized_command = ( + self._protocol_engine.state_view.commands.get_most_recently_finalized_command() + ) + return ( + CommandPointer( + command_id=most_recently_finalized_command.command.id, + command_key=most_recently_finalized_command.command.key, + created_at=most_recently_finalized_command.command.createdAt, + index=most_recently_finalized_command.index, + ) + if most_recently_finalized_command + else None + ) + def get_command_slice( self, cursor: Optional[int], length: int, include_fixit_commands: bool ) -> CommandSlice: diff --git a/api/src/opentrons/protocols/api_support/definitions.py b/api/src/opentrons/protocols/api_support/definitions.py index ad692e03828..e2f6aee1a2a 100644 --- a/api/src/opentrons/protocols/api_support/definitions.py +++ b/api/src/opentrons/protocols/api_support/definitions.py @@ -1,6 +1,6 @@ from .types import APIVersion -MAX_SUPPORTED_VERSION = APIVersion(2, 21) +MAX_SUPPORTED_VERSION = APIVersion(2, 22) """The maximum supported protocol API version in this release.""" MIN_SUPPORTED_VERSION = APIVersion(2, 0) diff --git a/api/src/opentrons/util/logging_config.py b/api/src/opentrons/util/logging_config.py index e9a4d2042a2..f9a59799d9d 100644 --- a/api/src/opentrons/util/logging_config.py +++ b/api/src/opentrons/util/logging_config.py @@ -5,10 +5,17 @@ from opentrons.config import CONFIG, ARCHITECTURE, SystemArchitecture +if ARCHITECTURE is SystemArchitecture.YOCTO: + from opentrons_hardware.sensors import SENSOR_LOG_NAME +else: + # we don't use the sensor log on ot2 or host + SENSOR_LOG_NAME = "unused" + def _host_config(level_value: int) -> Dict[str, Any]: serial_log_filename = CONFIG["serial_log_file"] api_log_filename = CONFIG["api_log_file"] + sensor_log_filename = CONFIG["sensor_log_file"] return { "version": 1, "disable_existing_loggers": False, @@ -29,7 +36,7 @@ def _host_config(level_value: int) -> Dict[str, Any]: "class": "logging.handlers.RotatingFileHandler", "formatter": "basic", "filename": serial_log_filename, - "maxBytes": 5000000, + "maxBytes": 1000000, "level": logging.DEBUG, "backupCount": 3, }, @@ -41,6 +48,14 @@ def _host_config(level_value: int) -> Dict[str, Any]: "level": logging.DEBUG, "backupCount": 5, }, + "sensor": { + "class": "logging.handlers.RotatingFileHandler", + "formatter": "basic", + "filename": sensor_log_filename, + "maxBytes": 1000000, + "level": logging.DEBUG, + "backupCount": 5, + }, }, "loggers": { "opentrons": { @@ -66,6 +81,11 @@ def _host_config(level_value: int) -> Dict[str, Any]: "level": logging.DEBUG, "propagate": False, }, + SENSOR_LOG_NAME: { + "handlers": ["sensor"], + "level": logging.DEBUG, + "propagate": False, + }, "__main__": {"handlers": ["api"], "level": level_value}, }, } @@ -75,6 +95,7 @@ def _buildroot_config(level_value: int) -> Dict[str, Any]: # Import systemd.journald here since it is generally unavailble on non # linux systems and we probably don't want to use it on linux desktops # either + sensor_log_filename = CONFIG["sensor_log_file"] return { "version": 1, "disable_existing_loggers": False, @@ -106,6 +127,14 @@ def _buildroot_config(level_value: int) -> Dict[str, Any]: "formatter": "message_only", "SYSLOG_IDENTIFIER": "opentrons-api-serial-usbbin", }, + "sensor": { + "class": "logging.handlers.RotatingFileHandler", + "formatter": "message_only", + "filename": sensor_log_filename, + "maxBytes": 1000000, + "level": logging.DEBUG, + "backupCount": 3, + }, }, "loggers": { "opentrons.drivers.asyncio.communication.serial_connection": { @@ -131,6 +160,11 @@ def _buildroot_config(level_value: int) -> Dict[str, Any]: "level": logging.DEBUG, "propagate": False, }, + SENSOR_LOG_NAME: { + "handlers": ["sensor"], + "level": logging.DEBUG, + "propagate": False, + }, "__main__": {"handlers": ["api"], "level": level_value}, }, } diff --git a/api/tests/opentrons/config/ot3_settings.py b/api/tests/opentrons/config/ot3_settings.py index 38353c05a3c..04370fd6c09 100644 --- a/api/tests/opentrons/config/ot3_settings.py +++ b/api/tests/opentrons/config/ot3_settings.py @@ -1,5 +1,3 @@ -from opentrons.config.types import OutputOptions - ot3_dummy_settings = { "name": "Marie Curie", "model": "OT-3 Standard", @@ -122,13 +120,11 @@ "plunger_speed": 10, "plunger_impulse_time": 0.2, "sensor_threshold_pascals": 17, - "output_option": OutputOptions.stream_to_csv, "aspirate_while_sensing": False, "z_overlap_between_passes_mm": 0.1, "plunger_reset_offset": 2.0, "samples_for_baselining": 20, "sample_time_sec": 0.004, - "data_files": {"PRIMARY": "/data/pressure_sensor_data.csv"}, }, "calibration": { "z_offset": { @@ -137,8 +133,6 @@ "max_overrun_distance_mm": 2, "speed_mm_per_s": 3, "sensor_threshold_pf": 4, - "output_option": OutputOptions.sync_only, - "data_files": None, }, }, "edge_sense": { @@ -149,8 +143,6 @@ "max_overrun_distance_mm": 5, "speed_mm_per_s": 6, "sensor_threshold_pf": 7, - "output_option": OutputOptions.sync_only, - "data_files": None, }, "search_initial_tolerance_mm": 18, "search_iteration_limit": 3, diff --git a/api/tests/opentrons/hardware_control/backends/test_ot3_controller.py b/api/tests/opentrons/hardware_control/backends/test_ot3_controller.py index ac25d19a3e2..5ffee581de4 100644 --- a/api/tests/opentrons/hardware_control/backends/test_ot3_controller.py +++ b/api/tests/opentrons/hardware_control/backends/test_ot3_controller.py @@ -39,7 +39,6 @@ OT3Config, GantryLoad, LiquidProbeSettings, - OutputOptions, ) from opentrons.config.robot_configs import build_config_ot3 from opentrons_hardware.firmware_bindings.arbitration_id import ArbitrationId @@ -61,7 +60,6 @@ UpdateState, EstopState, CurrentConfig, - InstrumentProbeType, ) from opentrons.hardware_control.errors import ( InvalidPipetteName, @@ -180,13 +178,11 @@ def fake_liquid_settings() -> LiquidProbeSettings: plunger_speed=10, plunger_impulse_time=0.2, sensor_threshold_pascals=15, - output_option=OutputOptions.can_bus_only, aspirate_while_sensing=False, z_overlap_between_passes_mm=0.1, plunger_reset_offset=2.0, samples_for_baselining=20, sample_time_sec=0.004, - data_files={InstrumentProbeType.PRIMARY: "fake_file_name"}, ) @@ -707,6 +703,17 @@ async def test_ready_for_movement( assert controller.check_motor_status(axes) == ready +def probe_move_group_run_side_effect( + head: NodeId, tool: NodeId +) -> Iterator[Dict[NodeId, MotorPositionStatus]]: + """Return homed position for axis that is present and was commanded to home.""" + positions = { + head: MotorPositionStatus(0.0, 0.0, True, True, MoveCompleteAck(1)), + tool: MotorPositionStatus(0.0, 0.0, True, True, MoveCompleteAck(1)), + } + yield positions + + @pytest.mark.parametrize("mount", [OT3Mount.LEFT, OT3Mount.RIGHT]) async def test_liquid_probe( mount: OT3Mount, @@ -716,6 +723,11 @@ async def test_liquid_probe( mock_send_stop_threshold: mock.AsyncMock, ) -> None: fake_max_p_dist = 70 + head_node = axis_to_node(Axis.by_mount(mount)) + tool_node = sensor_node_for_mount(mount) + mock_move_group_run.side_effect = probe_move_group_run_side_effect( + head_node, tool_node + ) try: await controller.liquid_probe( mount=mount, @@ -725,18 +737,17 @@ async def test_liquid_probe( threshold_pascals=fake_liquid_settings.sensor_threshold_pascals, plunger_impulse_time=fake_liquid_settings.plunger_impulse_time, num_baseline_reads=fake_liquid_settings.samples_for_baselining, - output_option=fake_liquid_settings.output_option, ) except PipetteLiquidNotFoundError: # the move raises a liquid not found now since we don't call the move group and it doesn't # get any positions back pass move_groups = mock_move_group_run.call_args_list[0][0][0]._move_groups - head_node = axis_to_node(Axis.by_mount(mount)) - tool_node = sensor_node_for_mount(mount) # in tool_sensors, pipette moves down, then sensor move goes assert move_groups[0][0][tool_node].stop_condition == MoveStopCondition.none - assert move_groups[1][0][tool_node].stop_condition == MoveStopCondition.sync_line + assert ( + move_groups[1][0][tool_node].stop_condition == MoveStopCondition.sensor_report + ) assert len(move_groups) == 2 assert move_groups[0][0][tool_node] assert move_groups[1][0][head_node], move_groups[2][0][tool_node] diff --git a/api/tests/opentrons/hardware_control/test_ot3_api.py b/api/tests/opentrons/hardware_control/test_ot3_api.py index 3c574e4373a..064ea087c6b 100644 --- a/api/tests/opentrons/hardware_control/test_ot3_api.py +++ b/api/tests/opentrons/hardware_control/test_ot3_api.py @@ -1,5 +1,6 @@ """ Tests for behaviors specific to the OT3 hardware controller. """ +import asyncio from typing import ( AsyncIterator, Iterator, @@ -26,7 +27,6 @@ GantryLoad, CapacitivePassSettings, LiquidProbeSettings, - OutputOptions, ) from opentrons.hardware_control.dev_types import ( AttachedGripper, @@ -98,6 +98,8 @@ from opentrons.hardware_control.module_control import AttachedModulesControl from opentrons.hardware_control.backends.types import HWStopCondition +from opentrons_hardware.firmware_bindings.constants import SensorId +from opentrons_hardware.sensors.types import SensorDataType # TODO (spp, 2023-08-22): write tests for ot3api.stop & ot3api.halt @@ -109,7 +111,6 @@ def fake_settings() -> CapacitivePassSettings: max_overrun_distance_mm=2, speed_mm_per_s=4, sensor_threshold_pf=1.0, - output_option=OutputOptions.sync_only, ) @@ -120,13 +121,11 @@ def fake_liquid_settings() -> LiquidProbeSettings: plunger_speed=15, plunger_impulse_time=0.2, sensor_threshold_pascals=15, - output_option=OutputOptions.can_bus_only, aspirate_while_sensing=False, z_overlap_between_passes_mm=0.1, plunger_reset_offset=2.0, samples_for_baselining=20, sample_time_sec=0.004, - data_files={InstrumentProbeType.PRIMARY: "fake_file_name"}, ) @@ -488,8 +487,6 @@ def _update_position( speed_mm_per_s: float, threshold_pf: float, probe: InstrumentProbeType, - output_option: OutputOptions = OutputOptions.sync_only, - data_file: Optional[str] = None, ) -> None: hardware_backend._position[moving] += distance_mm / 2 @@ -827,13 +824,11 @@ async def test_liquid_probe( plunger_speed=15, plunger_impulse_time=0.2, sensor_threshold_pascals=15, - output_option=OutputOptions.can_bus_only, aspirate_while_sensing=True, z_overlap_between_passes_mm=0.1, plunger_reset_offset=2.0, samples_for_baselining=20, sample_time_sec=0.004, - data_files={InstrumentProbeType.PRIMARY: "fake_file_name"}, ) fake_max_z_dist = 10.0 non_responsive_z_mm = ot3_hardware.liquid_probe_non_responsive_z_distance( @@ -860,10 +855,9 @@ async def test_liquid_probe( fake_settings_aspirate.sensor_threshold_pascals, fake_settings_aspirate.plunger_impulse_time, fake_settings_aspirate.samples_for_baselining, - fake_settings_aspirate.output_option, - fake_settings_aspirate.data_files, probe=InstrumentProbeType.PRIMARY, force_both_sensors=False, + response_queue=None, ) await ot3_hardware.liquid_probe( @@ -1098,13 +1092,11 @@ async def test_multi_liquid_probe( plunger_speed=71.5, plunger_impulse_time=0.2, sensor_threshold_pascals=15, - output_option=OutputOptions.can_bus_only, aspirate_while_sensing=True, z_overlap_between_passes_mm=0.1, plunger_reset_offset=2.0, samples_for_baselining=20, sample_time_sec=0.004, - data_files={InstrumentProbeType.PRIMARY: "fake_file_name"}, ) fake_max_z_dist = 10.0 await ot3_hardware.liquid_probe( @@ -1119,10 +1111,9 @@ async def test_multi_liquid_probe( fake_settings_aspirate.sensor_threshold_pascals, fake_settings_aspirate.plunger_impulse_time, fake_settings_aspirate.samples_for_baselining, - fake_settings_aspirate.output_option, - fake_settings_aspirate.data_files, probe=InstrumentProbeType.PRIMARY, force_both_sensors=False, + response_queue=None, ) assert mock_liquid_probe.call_count == 3 @@ -1155,10 +1146,11 @@ async def _fake_pos_update_and_raise( threshold_pascals: float, plunger_impulse_time: float, num_baseline_reads: int, - output_format: OutputOptions = OutputOptions.can_bus_only, - data_files: Optional[Dict[InstrumentProbeType, str]] = None, probe: InstrumentProbeType = InstrumentProbeType.PRIMARY, force_both_sensors: bool = False, + response_queue: Optional[ + asyncio.Queue[Dict[SensorId, List[SensorDataType]]] + ] = None, ) -> float: pos = self._position pos[Axis.by_mount(mount)] += mount_speed * ( @@ -1176,13 +1168,11 @@ async def _fake_pos_update_and_raise( plunger_speed=71.5, plunger_impulse_time=0.2, sensor_threshold_pascals=15, - output_option=OutputOptions.can_bus_only, aspirate_while_sensing=True, z_overlap_between_passes_mm=0.1, plunger_reset_offset=2.0, samples_for_baselining=20, sample_time_sec=0.004, - data_files={InstrumentProbeType.PRIMARY: "fake_file_name"}, ) # with a mount speed of 5, pass overlap of 0.5 and a 0.2s delay on z # the actual distance traveled is 3.5mm per pass @@ -1233,8 +1223,6 @@ async def test_capacitive_probe( 4, 1.0, InstrumentProbeType.PRIMARY, - fake_settings.output_option, - fake_settings.data_files, ) original = moving.set_in_point(here, 0) diff --git a/api/tests/opentrons/protocol_api/core/engine/test_absorbance_reader_core.py b/api/tests/opentrons/protocol_api/core/engine/test_absorbance_reader_core.py index a5fadde09cc..fd537d4cad9 100644 --- a/api/tests/opentrons/protocol_api/core/engine/test_absorbance_reader_core.py +++ b/api/tests/opentrons/protocol_api/core/engine/test_absorbance_reader_core.py @@ -11,6 +11,7 @@ from opentrons.protocol_engine.clients import SyncClient as EngineClient from opentrons.protocol_api.core.engine.module_core import AbsorbanceReaderCore from opentrons.protocol_api import MAX_SUPPORTED_VERSION +from opentrons.protocol_engine.errors.exceptions import CannotPerformModuleAction from opentrons.protocol_engine.state.module_substates import AbsorbanceReaderSubState from opentrons.protocol_engine.state.module_substates.absorbance_reader_substate import ( AbsorbanceReaderId, @@ -67,6 +68,7 @@ def test_initialize( decoy: Decoy, mock_engine_client: EngineClient, subject: AbsorbanceReaderCore ) -> None: """It should set the sample wavelength with the engine client.""" + subject._ready_to_initialize = True subject.initialize("single", [123]) decoy.verify( @@ -115,10 +117,18 @@ def test_initialize( assert subject._initialized_value == [124, 125, 126] +def test_initialize_not_ready(subject: AbsorbanceReaderCore) -> None: + """It should raise CannotPerformModuleAction if you dont call .close_lid() command.""" + subject._ready_to_initialize = False + with pytest.raises(CannotPerformModuleAction): + subject.initialize("single", [123]) + + def test_read( decoy: Decoy, mock_engine_client: EngineClient, subject: AbsorbanceReaderCore ) -> None: """It should call absorbance reader to read with the engine client.""" + subject._ready_to_initialize = True subject._initialized_value = [123] substate = AbsorbanceReaderSubState( module_id=AbsorbanceReaderId(subject.module_id), diff --git a/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py b/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py index 3d07bfe07d8..0ab9ac9da73 100644 --- a/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py +++ b/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py @@ -545,7 +545,7 @@ def test_aspirate_from_well( ) -def test_aspirate_from_location( +def test_aspirate_from_coordinates( decoy: Decoy, mock_engine_client: EngineClient, mock_protocol_core: ProtocolCore, @@ -583,6 +583,72 @@ def test_aspirate_from_location( ) +def test_aspirate_from_meniscus( + decoy: Decoy, + mock_engine_client: EngineClient, + mock_protocol_core: ProtocolCore, + subject: InstrumentCore, +) -> None: + """It should aspirate from a well.""" + location = Location(point=Point(1, 2, 3), labware=None) + + well_core = WellCore( + name="my cool well", labware_id="123abc", engine_client=mock_engine_client + ) + + decoy.when( + mock_engine_client.state.geometry.get_relative_liquid_handling_well_location( + labware_id="123abc", + well_name="my cool well", + absolute_point=Point(1, 2, 3), + is_meniscus=True, + ) + ).then_return( + LiquidHandlingWellLocation( + origin=WellOrigin.MENISCUS, offset=WellOffset(x=3, y=2, z=1), volumeOffset=0 + ) + ) + + subject.aspirate( + location=location, + well_core=well_core, + volume=12.34, + rate=5.6, + flow_rate=7.8, + in_place=False, + is_meniscus=True, + ) + + decoy.verify( + pipette_movement_conflict.check_safe_for_pipette_movement( + engine_state=mock_engine_client.state, + pipette_id="abc123", + labware_id="123abc", + well_name="my cool well", + well_location=LiquidHandlingWellLocation( + origin=WellOrigin.MENISCUS, + offset=WellOffset(x=3, y=2, z=1), + volumeOffset="operationVolume", + ), + ), + mock_engine_client.execute_command( + cmd.AspirateParams( + pipetteId="abc123", + labwareId="123abc", + wellName="my cool well", + wellLocation=LiquidHandlingWellLocation( + origin=WellOrigin.MENISCUS, + offset=WellOffset(x=3, y=2, z=1), + volumeOffset="operationVolume", + ), + volume=12.34, + flowRate=7.8, + ) + ), + mock_protocol_core.set_last_location(location=location, mount=Mount.LEFT), + ) + + def test_aspirate_in_place( decoy: Decoy, mock_engine_client: EngineClient, diff --git a/api/tests/opentrons/protocol_api/core/engine/test_protocol_core.py b/api/tests/opentrons/protocol_api/core/engine/test_protocol_core.py index 7b549fc035d..9ccaac498f0 100644 --- a/api/tests/opentrons/protocol_api/core/engine/test_protocol_core.py +++ b/api/tests/opentrons/protocol_api/core/engine/test_protocol_core.py @@ -1763,7 +1763,7 @@ def test_define_liquid_class( ) -> None: """It should create a LiquidClass and cache the definition.""" expected_liquid_class = LiquidClass( - _name="water1", _display_name="water 1", _by_pipette_setting=[] + _name="water1", _display_name="water 1", _by_pipette_setting={} ) decoy.when(liquid_classes.load_definition("water")).then_return( minimal_liquid_class_def1 diff --git a/api/tests/opentrons/protocol_api/test_instrument_context.py b/api/tests/opentrons/protocol_api/test_instrument_context.py index 069330036ec..27d2f6ebb33 100644 --- a/api/tests/opentrons/protocol_api/test_instrument_context.py +++ b/api/tests/opentrons/protocol_api/test_instrument_context.py @@ -62,6 +62,7 @@ from opentrons_shared_data.errors.exceptions import ( CommandPreconditionViolated, ) +from . import versions_at_or_above, versions_between @pytest.fixture(autouse=True) @@ -1542,7 +1543,12 @@ def test_96_tip_config_invalid( assert subject._96_tip_config_valid() is True -@pytest.mark.parametrize("api_version", [APIVersion(2, 21)]) +@pytest.mark.parametrize( + "api_version", + versions_between( + low_exclusive_bound=APIVersion(2, 13), high_inclusive_bound=APIVersion(2, 21) + ), +) def test_mix_no_lpd( decoy: Decoy, mock_instrument_core: InstrumentCore, @@ -1593,7 +1599,7 @@ def test_mix_no_lpd( @pytest.mark.ot3_only -@pytest.mark.parametrize("api_version", [APIVersion(2, 21)]) +@pytest.mark.parametrize("api_version", versions_at_or_above(APIVersion(2, 21))) def test_mix_with_lpd( decoy: Decoy, mock_instrument_core: InstrumentCore, @@ -1641,3 +1647,60 @@ def test_mix_with_lpd( ignore_extra_args=True, times=1, ) + + +@pytest.mark.parametrize( + "api_version", + versions_between( + low_exclusive_bound=APIVersion(2, 13), high_inclusive_bound=APIVersion(2, 21) + ), +) +def test_air_gap_uses_aspirate( + decoy: Decoy, + mock_instrument_core: InstrumentCore, + mock_protocol_core: ProtocolCore, + subject: InstrumentContext, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """It should use its own aspirate function to aspirate air.""" + mock_well = decoy.mock(cls=Well) + top_location = Location(point=Point(9, 9, 14), labware=mock_well) + last_location = Location(point=Point(9, 9, 9), labware=mock_well) + mock_aspirate = decoy.mock(func=subject.aspirate) + mock_move_to = decoy.mock(func=subject.move_to) + monkeypatch.setattr(subject, "aspirate", mock_aspirate) + monkeypatch.setattr(subject, "move_to", mock_move_to) + + decoy.when(mock_instrument_core.has_tip()).then_return(True) + decoy.when(mock_protocol_core.get_last_location()).then_return(last_location) + decoy.when(mock_well.top(z=5.0)).then_return(top_location) + subject.air_gap(volume=10, height=5) + + decoy.verify(mock_move_to(top_location, publish=False)) + decoy.verify(mock_aspirate(10)) + + +@pytest.mark.parametrize("api_version", versions_at_or_above(APIVersion(2, 22))) +def test_air_gap_uses_air_gap( + decoy: Decoy, + mock_instrument_core: InstrumentCore, + mock_protocol_core: ProtocolCore, + subject: InstrumentContext, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """It should use its own aspirate function to aspirate air.""" + mock_well = decoy.mock(cls=Well) + top_location = Location(point=Point(9, 9, 14), labware=mock_well) + last_location = Location(point=Point(9, 9, 9), labware=mock_well) + mock_move_to = decoy.mock(func=subject.move_to) + monkeypatch.setattr(subject, "move_to", mock_move_to) + + decoy.when(mock_instrument_core.has_tip()).then_return(True) + decoy.when(mock_protocol_core.get_last_location()).then_return(last_location) + decoy.when(mock_well.top(z=5.0)).then_return(top_location) + decoy.when(mock_instrument_core.get_aspirate_flow_rate()).then_return(11) + + subject.air_gap(volume=10, height=5) + + decoy.verify(mock_move_to(top_location, publish=False)) + decoy.verify(mock_instrument_core.air_gap_in_place(10, 11)) diff --git a/api/tests/opentrons/protocol_api/test_liquid_class.py b/api/tests/opentrons/protocol_api/test_liquid_class.py index 48f3788f496..be0b432e32f 100644 --- a/api/tests/opentrons/protocol_api/test_liquid_class.py +++ b/api/tests/opentrons/protocol_api/test_liquid_class.py @@ -12,7 +12,7 @@ def test_create_liquid_class( ) -> None: """It should create a LiquidClass from provided definition.""" assert LiquidClass.create(minimal_liquid_class_def1) == LiquidClass( - _name="water1", _display_name="water 1", _by_pipette_setting=[] + _name="water1", _display_name="water 1", _by_pipette_setting={} ) @@ -22,7 +22,7 @@ def test_get_for_pipette_and_tip( """It should get the properties for the specified pipette and tip.""" liq_class = LiquidClass.create(minimal_liquid_class_def2) result = liq_class.get_for("p20_single_gen2", "opentrons_96_tiprack_20ul") - assert result.aspirate.flowRateByVolume == {"default": 50, "10": 40, "20": 30} + assert result.aspirate.flow_rate_by_volume == {"default": 50, "10": 40, "20": 30} def test_get_for_raises_for_incorrect_pipette_or_tip( diff --git a/api/tests/opentrons/protocol_api/test_liquid_class_properties.py b/api/tests/opentrons/protocol_api/test_liquid_class_properties.py new file mode 100644 index 00000000000..b1699701f3c --- /dev/null +++ b/api/tests/opentrons/protocol_api/test_liquid_class_properties.py @@ -0,0 +1,175 @@ +"""Tests for LiquidClass properties and related functions.""" + +from opentrons_shared_data import load_shared_data +from opentrons_shared_data.liquid_classes.liquid_class_definition import ( + LiquidClassSchemaV1, + Coordinate, +) + +from opentrons.protocol_api._liquid_properties import ( + build_aspirate_properties, + build_single_dispense_properties, + build_multi_dispense_properties, +) + + +def test_build_aspirate_settings() -> None: + """It should convert the shared data aspirate settings to the PAPI type.""" + fixture_data = load_shared_data("liquid-class/fixtures/fixture_glycerol50.json") + liquid_class_model = LiquidClassSchemaV1.parse_raw(fixture_data) + aspirate_data = liquid_class_model.byPipette[0].byTipType[0].aspirate + + aspirate_properties = build_aspirate_properties(aspirate_data) + + assert aspirate_properties.submerge.position_reference.value == "liquid-meniscus" + assert aspirate_properties.submerge.offset == Coordinate(x=0, y=0, z=-5) + assert aspirate_properties.submerge.speed == 100 + assert aspirate_properties.submerge.delay.enabled is True + assert aspirate_properties.submerge.delay.duration == 1.5 + + assert aspirate_properties.retract.position_reference.value == "well-top" + assert aspirate_properties.retract.offset == Coordinate(x=0, y=0, z=5) + assert aspirate_properties.retract.speed == 100 + assert aspirate_properties.retract.air_gap_by_volume == { + "default": 2, + "5": 3, + "10": 4, + } + assert aspirate_properties.retract.touch_tip.enabled is True + assert aspirate_properties.retract.touch_tip.z_offset == 2 + assert aspirate_properties.retract.touch_tip.mm_to_edge == 1 + assert aspirate_properties.retract.touch_tip.speed == 50 + assert aspirate_properties.retract.delay.enabled is True + assert aspirate_properties.retract.delay.duration == 1 + + assert aspirate_properties.position_reference.value == "well-bottom" + assert aspirate_properties.offset == Coordinate(x=0, y=0, z=-5) + assert aspirate_properties.flow_rate_by_volume == { + "default": 50, + "10": 40, + "20": 30, + } + assert aspirate_properties.pre_wet is True + assert aspirate_properties.mix.enabled is True + assert aspirate_properties.mix.repetitions == 3 + assert aspirate_properties.mix.volume == 15 + assert aspirate_properties.delay.enabled is True + assert aspirate_properties.delay.duration == 2 + + +def test_build_single_dispense_settings() -> None: + """It should convert the shared data single dispense settings to the PAPI type.""" + fixture_data = load_shared_data("liquid-class/fixtures/fixture_glycerol50.json") + liquid_class_model = LiquidClassSchemaV1.parse_raw(fixture_data) + single_dispense_data = liquid_class_model.byPipette[0].byTipType[0].singleDispense + + single_dispense_properties = build_single_dispense_properties(single_dispense_data) + + assert ( + single_dispense_properties.submerge.position_reference.value + == "liquid-meniscus" + ) + assert single_dispense_properties.submerge.offset == Coordinate(x=0, y=0, z=-5) + assert single_dispense_properties.submerge.speed == 100 + assert single_dispense_properties.submerge.delay.enabled is True + assert single_dispense_properties.submerge.delay.duration == 1.5 + + assert single_dispense_properties.retract.position_reference.value == "well-top" + assert single_dispense_properties.retract.offset == Coordinate(x=0, y=0, z=5) + assert single_dispense_properties.retract.speed == 100 + assert single_dispense_properties.retract.air_gap_by_volume == { + "default": 2, + "5": 3, + "10": 4, + } + assert single_dispense_properties.retract.touch_tip.enabled is True + assert single_dispense_properties.retract.touch_tip.z_offset == 2 + assert single_dispense_properties.retract.touch_tip.mm_to_edge == 1 + assert single_dispense_properties.retract.touch_tip.speed == 50 + assert single_dispense_properties.retract.blowout.enabled is True + assert single_dispense_properties.retract.blowout.location is not None + assert single_dispense_properties.retract.blowout.location.value == "trash" + assert single_dispense_properties.retract.blowout.flow_rate == 100 + assert single_dispense_properties.retract.delay.enabled is True + assert single_dispense_properties.retract.delay.duration == 1 + + assert single_dispense_properties.position_reference.value == "well-bottom" + assert single_dispense_properties.offset == Coordinate(x=0, y=0, z=-5) + assert single_dispense_properties.flow_rate_by_volume == { + "default": 50, + "10": 40, + "20": 30, + } + assert single_dispense_properties.mix.enabled is True + assert single_dispense_properties.mix.repetitions == 3 + assert single_dispense_properties.mix.volume == 15 + assert single_dispense_properties.push_out_by_volume == { + "default": 5, + "10": 7, + "20": 10, + } + assert single_dispense_properties.delay.enabled is True + assert single_dispense_properties.delay.duration == 2.5 + + +def test_build_multi_dispense_settings() -> None: + """It should convert the shared data multi dispense settings to the PAPI type.""" + fixture_data = load_shared_data("liquid-class/fixtures/fixture_glycerol50.json") + liquid_class_model = LiquidClassSchemaV1.parse_raw(fixture_data) + multi_dispense_data = liquid_class_model.byPipette[0].byTipType[0].multiDispense + + assert multi_dispense_data is not None + multi_dispense_properties = build_multi_dispense_properties(multi_dispense_data) + assert multi_dispense_properties is not None + + assert ( + multi_dispense_properties.submerge.position_reference.value == "liquid-meniscus" + ) + assert multi_dispense_properties.submerge.offset == Coordinate(x=0, y=0, z=-5) + assert multi_dispense_properties.submerge.speed == 100 + assert multi_dispense_properties.submerge.delay.enabled is True + assert multi_dispense_properties.submerge.delay.duration == 1.5 + + assert multi_dispense_properties.retract.position_reference.value == "well-top" + assert multi_dispense_properties.retract.offset == Coordinate(x=0, y=0, z=5) + assert multi_dispense_properties.retract.speed == 100 + assert multi_dispense_properties.retract.air_gap_by_volume == { + "default": 2, + "5": 3, + "10": 4, + } + assert multi_dispense_properties.retract.touch_tip.enabled is True + assert multi_dispense_properties.retract.touch_tip.z_offset == 2 + assert multi_dispense_properties.retract.touch_tip.mm_to_edge == 1 + assert multi_dispense_properties.retract.touch_tip.speed == 50 + assert multi_dispense_properties.retract.blowout.enabled is False + assert multi_dispense_properties.retract.blowout.location is None + assert multi_dispense_properties.retract.blowout.flow_rate is None + assert multi_dispense_properties.retract.delay.enabled is True + assert multi_dispense_properties.retract.delay.duration == 1 + + assert multi_dispense_properties.position_reference.value == "well-bottom" + assert multi_dispense_properties.offset == Coordinate(x=0, y=0, z=-5) + assert multi_dispense_properties.flow_rate_by_volume == { + "default": 50, + "10": 40, + "20": 30, + } + assert multi_dispense_properties.conditioning_by_volume == { + "default": 10, + "5": 5, + } + assert multi_dispense_properties.disposal_by_volume == { + "default": 2, + "5": 3, + } + assert multi_dispense_properties.delay.enabled is True + assert multi_dispense_properties.delay.duration == 1 + + +def test_build_multi_dispense_settings_none( + minimal_liquid_class_def2: LiquidClassSchemaV1, +) -> None: + """It should return None if there are no multi dispense properties in the model.""" + transfer_settings = minimal_liquid_class_def2.byPipette[0].byTipType[0] + assert build_multi_dispense_properties(transfer_settings.multiDispense) is None diff --git a/api/tests/opentrons/protocol_api/test_protocol_context.py b/api/tests/opentrons/protocol_api/test_protocol_context.py index 2bedbd5fb6f..2c8e8b158af 100644 --- a/api/tests/opentrons/protocol_api/test_protocol_context.py +++ b/api/tests/opentrons/protocol_api/test_protocol_context.py @@ -1227,7 +1227,7 @@ def test_define_liquid_class( ) -> None: """It should create the liquid class definition.""" expected_liquid_class = LiquidClass( - _name="volatile_100", _display_name="volatile 100%", _by_pipette_setting=[] + _name="volatile_100", _display_name="volatile 100%", _by_pipette_setting={} ) decoy.when(mock_core.define_liquid_class("volatile_90")).then_return( expected_liquid_class diff --git a/api/tests/opentrons/protocol_api/test_well.py b/api/tests/opentrons/protocol_api/test_well.py index ef1eed84c62..b4817567dde 100644 --- a/api/tests/opentrons/protocol_api/test_well.py +++ b/api/tests/opentrons/protocol_api/test_well.py @@ -8,6 +8,8 @@ from opentrons.protocol_api._liquid import Liquid from opentrons.types import Point, Location +from . import versions_at_or_above + @pytest.fixture def mock_well_core(decoy: Decoy) -> WellCore: @@ -140,6 +142,13 @@ def test_load_liquid(decoy: Decoy, mock_well_core: WellCore, subject: Well) -> N ) +@pytest.mark.parametrize("api_version", versions_at_or_above(APIVersion(2, 22))) +def test_load_empty(decoy: Decoy, mock_well_core: WellCore, subject: Well) -> None: + """It should mark a location as empty.""" + subject.load_empty() + decoy.verify(mock_well_core.load_empty(), times=1) + + def test_diameter(decoy: Decoy, mock_well_core: WellCore, subject: Well) -> None: """It should get the diameter from the core.""" decoy.when(mock_well_core.diameter).then_return(12.3) diff --git a/api/tests/opentrons/protocol_api_integration/test_liquid_classes.py b/api/tests/opentrons/protocol_api_integration/test_liquid_classes.py index 049edae5c0f..eed90cc2478 100644 --- a/api/tests/opentrons/protocol_api_integration/test_liquid_classes.py +++ b/api/tests/opentrons/protocol_api_integration/test_liquid_classes.py @@ -27,7 +27,7 @@ def test_liquid_class_creation_and_property_fetching( assert ( glycerol_50.get_for( pipette_left.name, tiprack.load_name - ).dispense.flowRateByVolume["default"] + ).dispense.flow_rate_by_volume["default"] == 50 ) assert ( diff --git a/api/tests/opentrons/protocol_engine/commands/calibration/test_calibrate_gripper.py b/api/tests/opentrons/protocol_engine/commands/calibration/test_calibrate_gripper.py index 6ecf768c4eb..4145e1f0b5c 100644 --- a/api/tests/opentrons/protocol_engine/commands/calibration/test_calibrate_gripper.py +++ b/api/tests/opentrons/protocol_engine/commands/calibration/test_calibrate_gripper.py @@ -72,7 +72,6 @@ async def test_calibrate_gripper( result = await subject.execute(params) assert result == SuccessData( public=CalibrateGripperResult(jawOffset=Vec3f(x=1.1, y=2.2, z=3.3)), - private=None, ) diff --git a/api/tests/opentrons/protocol_engine/commands/calibration/test_calibrate_module.py b/api/tests/opentrons/protocol_engine/commands/calibration/test_calibrate_module.py index 0226453c72e..0713bfa37d1 100644 --- a/api/tests/opentrons/protocol_engine/commands/calibration/test_calibrate_module.py +++ b/api/tests/opentrons/protocol_engine/commands/calibration/test_calibrate_module.py @@ -95,7 +95,6 @@ async def test_calibrate_module_implementation( ), location=location, ), - private=None, ) diff --git a/api/tests/opentrons/protocol_engine/commands/calibration/test_calibrate_pipette.py b/api/tests/opentrons/protocol_engine/commands/calibration/test_calibrate_pipette.py index ba949f0e2df..073db3bf295 100644 --- a/api/tests/opentrons/protocol_engine/commands/calibration/test_calibrate_pipette.py +++ b/api/tests/opentrons/protocol_engine/commands/calibration/test_calibrate_pipette.py @@ -64,7 +64,6 @@ async def test_calibrate_pipette_implementation( public=CalibratePipetteResult( pipetteOffset=InstrumentOffsetVector(x=3, y=4, z=6) ), - private=None, ) diff --git a/api/tests/opentrons/protocol_engine/commands/calibration/test_move_to_maintenance_position.py b/api/tests/opentrons/protocol_engine/commands/calibration/test_move_to_maintenance_position.py index ca8bb9de5bd..7051d1e44fc 100644 --- a/api/tests/opentrons/protocol_engine/commands/calibration/test_move_to_maintenance_position.py +++ b/api/tests/opentrons/protocol_engine/commands/calibration/test_move_to_maintenance_position.py @@ -56,7 +56,9 @@ async def test_calibration_move_to_location_implementation_for_attach_instrument decoy.when(ot3_hardware_api.get_instrument_max_height(Mount.LEFT)).then_return(300) result = await subject.execute(params=params) - assert result == SuccessData(public=MoveToMaintenancePositionResult(), private=None) + assert result == SuccessData( + public=MoveToMaintenancePositionResult(), + ) hw_mount = mount_type.to_hw_mount() decoy.verify( @@ -100,7 +102,9 @@ async def test_calibration_move_to_location_implementation_for_attach_plate( decoy.when(ot3_hardware_api.get_instrument_max_height(Mount.LEFT)).then_return(300) result = await subject.execute(params=params) - assert result == SuccessData(public=MoveToMaintenancePositionResult(), private=None) + assert result == SuccessData( + public=MoveToMaintenancePositionResult(), + ) decoy.verify( await ot3_hardware_api.prepare_for_mount_movement(Mount.LEFT), @@ -150,7 +154,9 @@ async def test_calibration_move_to_location_implementation_for_gripper( decoy.when(ot3_hardware_api.get_instrument_max_height(Mount.LEFT)).then_return(300) result = await subject.execute(params=params) - assert result == SuccessData(public=MoveToMaintenancePositionResult(), private=None) + assert result == SuccessData( + public=MoveToMaintenancePositionResult(), + ) decoy.verify( await ot3_hardware_api.prepare_for_mount_movement(Mount.LEFT), diff --git a/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_close_labware_latch.py b/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_close_labware_latch.py index fbd1fadcc23..d481ef33b9b 100644 --- a/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_close_labware_latch.py +++ b/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_close_labware_latch.py @@ -45,7 +45,7 @@ async def test_close_labware_latch( result = await subject.execute(data) decoy.verify(await heater_shaker_hardware.close_labware_latch(), times=1) assert result == SuccessData( - public=heater_shaker.CloseLabwareLatchResult(), private=None + public=heater_shaker.CloseLabwareLatchResult(), ) @@ -77,5 +77,5 @@ async def test_close_labware_latch_virtual( result = await subject.execute(data) assert result == SuccessData( - public=heater_shaker.CloseLabwareLatchResult(), private=None + public=heater_shaker.CloseLabwareLatchResult(), ) diff --git a/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_deactivate_heater.py b/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_deactivate_heater.py index 5e8a65a06e8..6ce4336c9a3 100644 --- a/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_deactivate_heater.py +++ b/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_deactivate_heater.py @@ -46,5 +46,5 @@ async def test_deactivate_heater( result = await subject.execute(data) decoy.verify(await hs_hardware.deactivate_heater(), times=1) assert result == SuccessData( - public=heater_shaker.DeactivateHeaterResult(), private=None + public=heater_shaker.DeactivateHeaterResult(), ) diff --git a/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_deactivate_shaker.py b/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_deactivate_shaker.py index db5e1aba138..466fa79dcc5 100644 --- a/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_deactivate_shaker.py +++ b/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_deactivate_shaker.py @@ -46,7 +46,7 @@ async def test_deactivate_shaker( result = await subject.execute(data) decoy.verify(await hs_hardware.deactivate_shaker(), times=1) assert result == SuccessData( - public=heater_shaker.DeactivateShakerResult(), private=None + public=heater_shaker.DeactivateShakerResult(), ) @@ -78,5 +78,5 @@ async def test_deactivate_shaker_virtual( result = await subject.execute(data) assert result == SuccessData( - public=heater_shaker.DeactivateShakerResult(), private=None + public=heater_shaker.DeactivateShakerResult(), ) diff --git a/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_open_labware_latch.py b/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_open_labware_latch.py index 6a5e7e97db2..4b122f2d7e2 100644 --- a/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_open_labware_latch.py +++ b/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_open_labware_latch.py @@ -81,7 +81,6 @@ async def test_open_labware_latch( public=heater_shaker.OpenLabwareLatchResult( pipetteRetracted=expect_pipette_retracted ), - private=None, state_update=update_types.StateUpdate(pipette_location=update_types.CLEAR) if expect_pipette_retracted else update_types.StateUpdate(), diff --git a/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_set_and_wait_for_shake_speed.py b/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_set_and_wait_for_shake_speed.py index 005f46f89cb..9db4bb27d00 100644 --- a/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_set_and_wait_for_shake_speed.py +++ b/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_set_and_wait_for_shake_speed.py @@ -84,7 +84,6 @@ async def test_set_and_wait_for_shake_speed( public=heater_shaker.SetAndWaitForShakeSpeedResult( pipetteRetracted=expect_pipette_retracted ), - private=None, state_update=update_types.StateUpdate(pipette_location=update_types.CLEAR) if expect_pipette_retracted else update_types.StateUpdate(), diff --git a/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_set_target_temperature.py b/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_set_target_temperature.py index 51df5f560b3..977a76bfdf2 100644 --- a/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_set_target_temperature.py +++ b/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_set_target_temperature.py @@ -55,5 +55,5 @@ async def test_set_target_temperature( result = await subject.execute(data) decoy.verify(await hs_hardware.start_set_temperature(celsius=45.6), times=1) assert result == SuccessData( - public=heater_shaker.SetTargetTemperatureResult(), private=None + public=heater_shaker.SetTargetTemperatureResult(), ) diff --git a/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_wait_for_temperature.py b/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_wait_for_temperature.py index c256a480f16..f9804b90944 100644 --- a/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_wait_for_temperature.py +++ b/api/tests/opentrons/protocol_engine/commands/heater_shaker/test_wait_for_temperature.py @@ -50,5 +50,5 @@ async def test_wait_for_temperature( await hs_hardware.await_temperature(awaiting_temperature=123.45), times=1 ) assert result == SuccessData( - public=heater_shaker.WaitForTemperatureResult(), private=None + public=heater_shaker.WaitForTemperatureResult(), ) diff --git a/api/tests/opentrons/protocol_engine/commands/magnetic_module/test_disengage.py b/api/tests/opentrons/protocol_engine/commands/magnetic_module/test_disengage.py index e1103518178..03d76db9e03 100644 --- a/api/tests/opentrons/protocol_engine/commands/magnetic_module/test_disengage.py +++ b/api/tests/opentrons/protocol_engine/commands/magnetic_module/test_disengage.py @@ -46,4 +46,4 @@ async def test_magnetic_module_disengage_implementation( result = await subject.execute(params=params) decoy.verify(await magnetic_module_hw.deactivate(), times=1) - assert result == SuccessData(public=DisengageResult(), private=None) + assert result == SuccessData(public=DisengageResult()) diff --git a/api/tests/opentrons/protocol_engine/commands/magnetic_module/test_engage.py b/api/tests/opentrons/protocol_engine/commands/magnetic_module/test_engage.py index 5feddee3e2e..e1f14cb3f24 100644 --- a/api/tests/opentrons/protocol_engine/commands/magnetic_module/test_engage.py +++ b/api/tests/opentrons/protocol_engine/commands/magnetic_module/test_engage.py @@ -51,4 +51,4 @@ async def test_magnetic_module_engage_implementation( result = await subject.execute(params=params) decoy.verify(await magnetic_module_hw.engage(9001), times=1) - assert result == SuccessData(public=EngageResult(), private=None) + assert result == SuccessData(public=EngageResult()) diff --git a/api/tests/opentrons/protocol_engine/commands/temperature_module/test_deactivate.py b/api/tests/opentrons/protocol_engine/commands/temperature_module/test_deactivate.py index dfe821c6bbb..91dc274f14c 100644 --- a/api/tests/opentrons/protocol_engine/commands/temperature_module/test_deactivate.py +++ b/api/tests/opentrons/protocol_engine/commands/temperature_module/test_deactivate.py @@ -45,5 +45,5 @@ async def test_await_temperature( result = await subject.execute(data) decoy.verify(await tempdeck_hardware.deactivate(), times=1) assert result == SuccessData( - public=temperature_module.DeactivateTemperatureResult(), private=None + public=temperature_module.DeactivateTemperatureResult(), ) diff --git a/api/tests/opentrons/protocol_engine/commands/temperature_module/test_set_target_temperature.py b/api/tests/opentrons/protocol_engine/commands/temperature_module/test_set_target_temperature.py index 0af71263e96..0bbd31f7a1d 100644 --- a/api/tests/opentrons/protocol_engine/commands/temperature_module/test_set_target_temperature.py +++ b/api/tests/opentrons/protocol_engine/commands/temperature_module/test_set_target_temperature.py @@ -52,5 +52,4 @@ async def test_set_target_temperature( decoy.verify(await tempdeck_hardware.start_set_temperature(celsius=1), times=1) assert result == SuccessData( public=temperature_module.SetTargetTemperatureResult(targetTemperature=1), - private=None, ) diff --git a/api/tests/opentrons/protocol_engine/commands/temperature_module/test_wait_for_temperature.py b/api/tests/opentrons/protocol_engine/commands/temperature_module/test_wait_for_temperature.py index fb9456321b9..99e76f68774 100644 --- a/api/tests/opentrons/protocol_engine/commands/temperature_module/test_wait_for_temperature.py +++ b/api/tests/opentrons/protocol_engine/commands/temperature_module/test_wait_for_temperature.py @@ -48,7 +48,7 @@ async def test_wait_for_temperature( await tempdeck_hardware.await_temperature(awaiting_temperature=123), times=1 ) assert result == SuccessData( - public=temperature_module.WaitForTemperatureResult(), private=None + public=temperature_module.WaitForTemperatureResult(), ) @@ -90,5 +90,5 @@ async def test_wait_for_temperature_requested_celsius( await tempdeck_hardware.await_temperature(awaiting_temperature=12), times=1 ) assert result == SuccessData( - public=temperature_module.WaitForTemperatureResult(), private=None + public=temperature_module.WaitForTemperatureResult(), ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_air_gap_in_place.py b/api/tests/opentrons/protocol_engine/commands/test_air_gap_in_place.py new file mode 100644 index 00000000000..5d66a845dcc --- /dev/null +++ b/api/tests/opentrons/protocol_engine/commands/test_air_gap_in_place.py @@ -0,0 +1,284 @@ +"""Test aspirate-in-place commands.""" +from datetime import datetime + +import pytest +from decoy import Decoy, matchers + +from opentrons_shared_data.errors.exceptions import PipetteOverpressureError + +from opentrons.types import Point +from opentrons.hardware_control import API as HardwareAPI + +from opentrons.protocol_engine.execution import PipettingHandler, GantryMover +from opentrons.protocol_engine.commands.air_gap_in_place import ( + AirGapInPlaceParams, + AirGapInPlaceResult, + AirGapInPlaceImplementation, +) +from opentrons.protocol_engine.commands.command import SuccessData, DefinedErrorData +from opentrons.protocol_engine.errors.exceptions import PipetteNotReadyToAspirateError +from opentrons.protocol_engine.notes import CommandNoteAdder +from opentrons.protocol_engine.resources import ModelUtils +from opentrons.protocol_engine.state.state import StateStore +from opentrons.protocol_engine.commands.pipetting_common import OverpressureError +from opentrons.protocol_engine.types import ( + CurrentWell, + CurrentPipetteLocation, + CurrentAddressableArea, + AspiratedFluid, + FluidKind, +) +from opentrons.protocol_engine.state import update_types + + +@pytest.fixture +def hardware_api(decoy: Decoy) -> HardwareAPI: + """Get a mock in the shape of a HardwareAPI.""" + return decoy.mock(cls=HardwareAPI) + + +@pytest.fixture +def state_store(decoy: Decoy) -> StateStore: + """Get a mock in the shape of a StateStore.""" + return decoy.mock(cls=StateStore) + + +@pytest.fixture +def pipetting(decoy: Decoy) -> PipettingHandler: + """Get a mock in the shape of a PipettingHandler.""" + return decoy.mock(cls=PipettingHandler) + + +@pytest.fixture +def subject( + pipetting: PipettingHandler, + state_store: StateStore, + hardware_api: HardwareAPI, + mock_command_note_adder: CommandNoteAdder, + model_utils: ModelUtils, + gantry_mover: GantryMover, +) -> AirGapInPlaceImplementation: + """Get the impelementation subject.""" + return AirGapInPlaceImplementation( + pipetting=pipetting, + hardware_api=hardware_api, + state_view=state_store, + command_note_adder=mock_command_note_adder, + model_utils=model_utils, + gantry_mover=gantry_mover, + ) + + +@pytest.mark.parametrize( + "location,stateupdateLabware,stateupdateWell", + [ + ( + CurrentWell( + pipette_id="pipette-id-abc", + labware_id="labware-id-1", + well_name="well-name-1", + ), + "labware-id-1", + "well-name-1", + ), + (None, None, None), + (CurrentAddressableArea("pipette-id-abc", "addressable-area-1"), None, None), + ], +) +async def test_air_gap_in_place_implementation( + decoy: Decoy, + pipetting: PipettingHandler, + state_store: StateStore, + hardware_api: HardwareAPI, + mock_command_note_adder: CommandNoteAdder, + subject: AirGapInPlaceImplementation, + location: CurrentPipetteLocation | None, + stateupdateLabware: str, + stateupdateWell: str, +) -> None: + """It should aspirate in place.""" + data = AirGapInPlaceParams( + pipetteId="pipette-id-abc", + volume=123, + flowRate=1.234, + ) + + decoy.when( + pipetting.get_is_ready_to_aspirate( + pipette_id="pipette-id-abc", + ) + ).then_return(True) + + decoy.when( + await pipetting.aspirate_in_place( + pipette_id="pipette-id-abc", + volume=123, + flow_rate=1.234, + command_note_adder=mock_command_note_adder, + ) + ).then_return(123) + + decoy.when(state_store.pipettes.get_current_location()).then_return(location) + + result = await subject.execute(params=data) + + if isinstance(location, CurrentWell): + assert result == SuccessData( + public=AirGapInPlaceResult(volume=123), + state_update=update_types.StateUpdate( + pipette_aspirated_fluid=update_types.PipetteAspiratedFluidUpdate( + pipette_id="pipette-id-abc", + fluid=AspiratedFluid(kind=FluidKind.AIR, volume=123), + ) + ), + ) + else: + assert result == SuccessData( + public=AirGapInPlaceResult(volume=123), + state_update=update_types.StateUpdate( + pipette_aspirated_fluid=update_types.PipetteAspiratedFluidUpdate( + pipette_id="pipette-id-abc", + fluid=AspiratedFluid(kind=FluidKind.AIR, volume=123), + ) + ), + ) + + +async def test_handle_air_gap_in_place_request_not_ready_to_aspirate( + decoy: Decoy, + pipetting: PipettingHandler, + state_store: StateStore, + hardware_api: HardwareAPI, + subject: AirGapInPlaceImplementation, +) -> None: + """Should raise an exception for not ready to aspirate.""" + data = AirGapInPlaceParams( + pipetteId="pipette-id-abc", + volume=123, + flowRate=1.234, + ) + + decoy.when( + pipetting.get_is_ready_to_aspirate( + pipette_id="pipette-id-abc", + ) + ).then_return(False) + + with pytest.raises( + PipetteNotReadyToAspirateError, + match="Pipette cannot air gap in place because of a previous blow out." + " The first aspirate following a blow-out must be from a specific well" + " so the plunger can be reset in a known safe position.", + ): + await subject.execute(params=data) + + +async def test_aspirate_raises_volume_error( + decoy: Decoy, + pipetting: PipettingHandler, + subject: AirGapInPlaceImplementation, + mock_command_note_adder: CommandNoteAdder, +) -> None: + """Should raise an assertion error for volume larger than working volume.""" + data = AirGapInPlaceParams( + pipetteId="abc", + volume=50, + flowRate=1.23, + ) + + decoy.when(pipetting.get_is_ready_to_aspirate(pipette_id="abc")).then_return(True) + + decoy.when( + await pipetting.aspirate_in_place( + pipette_id="abc", + volume=50, + flow_rate=1.23, + command_note_adder=mock_command_note_adder, + ) + ).then_raise(AssertionError("blah blah")) + + with pytest.raises(AssertionError): + await subject.execute(data) + + +@pytest.mark.parametrize( + "location,stateupdateLabware,stateupdateWell", + [ + ( + CurrentWell( + pipette_id="pipette-id", + labware_id="labware-id-1", + well_name="well-name-1", + ), + "labware-id-1", + "well-name-1", + ), + (None, None, None), + (CurrentAddressableArea("pipette-id", "addressable-area-1"), None, None), + ], +) +async def test_overpressure_error( + decoy: Decoy, + gantry_mover: GantryMover, + pipetting: PipettingHandler, + subject: AirGapInPlaceImplementation, + model_utils: ModelUtils, + mock_command_note_adder: CommandNoteAdder, + state_store: StateStore, + location: CurrentPipetteLocation | None, + stateupdateLabware: str, + stateupdateWell: str, +) -> None: + """It should return an overpressure error if the hardware API indicates that.""" + pipette_id = "pipette-id" + + position = Point(x=1, y=2, z=3) + + error_id = "error-id" + error_timestamp = datetime(year=2020, month=1, day=2) + + data = AirGapInPlaceParams( + pipetteId=pipette_id, + volume=50, + flowRate=1.23, + ) + + decoy.when(pipetting.get_is_ready_to_aspirate(pipette_id=pipette_id)).then_return( + True + ) + + decoy.when( + await pipetting.aspirate_in_place( + pipette_id=pipette_id, + volume=50, + flow_rate=1.23, + command_note_adder=mock_command_note_adder, + ), + ).then_raise(PipetteOverpressureError()) + + decoy.when(model_utils.generate_id()).then_return(error_id) + decoy.when(model_utils.get_timestamp()).then_return(error_timestamp) + decoy.when(await gantry_mover.get_position(pipette_id)).then_return(position) + decoy.when(state_store.pipettes.get_current_location()).then_return(location) + + result = await subject.execute(data) + + if isinstance(location, CurrentWell): + assert result == DefinedErrorData( + public=OverpressureError.construct( + id=error_id, + createdAt=error_timestamp, + wrappedErrors=[matchers.Anything()], + errorInfo={"retryLocation": (position.x, position.y, position.z)}, + ), + state_update=update_types.StateUpdate(), + ) + else: + assert result == DefinedErrorData( + public=OverpressureError.construct( + id=error_id, + createdAt=error_timestamp, + wrappedErrors=[matchers.Anything()], + errorInfo={"retryLocation": (position.x, position.y, position.z)}, + ), + ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_aspirate.py b/api/tests/opentrons/protocol_engine/commands/test_aspirate.py index 8d6f6d92179..55950d51934 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_aspirate.py +++ b/api/tests/opentrons/protocol_engine/commands/test_aspirate.py @@ -29,7 +29,12 @@ PipettingHandler, ) from opentrons.protocol_engine.resources.model_utils import ModelUtils -from opentrons.protocol_engine.types import CurrentWell, LoadedPipette +from opentrons.protocol_engine.types import ( + CurrentWell, + LoadedPipette, + AspiratedFluid, + FluidKind, +) from opentrons.hardware_control import HardwareControlAPI from opentrons.protocol_engine.notes import CommandNoteAdder @@ -103,13 +108,20 @@ async def test_aspirate_implementation_no_prep( assert result == SuccessData( public=AspirateResult(volume=50, position=DeckPoint(x=1, y=2, z=3)), - private=None, state_update=update_types.StateUpdate( pipette_location=update_types.PipetteLocationUpdate( pipette_id="abc", new_location=update_types.Well(labware_id="123", well_name="A3"), new_deck_point=DeckPoint(x=1, y=2, z=3), - ) + ), + liquid_operated=update_types.LiquidOperatedUpdate( + labware_id="123", + well_name="A3", + volume_added=-50, + ), + pipette_aspirated_fluid=update_types.PipetteAspiratedFluidUpdate( + pipette_id="abc", fluid=AspiratedFluid(kind=FluidKind.LIQUID, volume=50) + ), ), ) @@ -172,13 +184,20 @@ async def test_aspirate_implementation_with_prep( assert result == SuccessData( public=AspirateResult(volume=50, position=DeckPoint(x=1, y=2, z=3)), - private=None, state_update=update_types.StateUpdate( pipette_location=update_types.PipetteLocationUpdate( pipette_id="abc", new_location=update_types.Well(labware_id="123", well_name="A3"), new_deck_point=DeckPoint(x=1, y=2, z=3), - ) + ), + liquid_operated=update_types.LiquidOperatedUpdate( + labware_id="123", + well_name="A3", + volume_added=-50, + ), + pipette_aspirated_fluid=update_types.PipetteAspiratedFluidUpdate( + pipette_id="abc", fluid=AspiratedFluid(kind=FluidKind.LIQUID, volume=50) + ), ), ) @@ -313,7 +332,15 @@ async def test_overpressure_error( labware_id=labware_id, well_name=well_name ), new_deck_point=DeckPoint(x=position.x, y=position.y, z=position.z), - ) + ), + liquid_operated=update_types.LiquidOperatedUpdate( + labware_id=labware_id, + well_name=well_name, + volume_added=update_types.CLEAR, + ), + pipette_aspirated_fluid=update_types.PipetteUnknownFluidUpdate( + pipette_id=pipette_id + ), ), ) @@ -329,14 +356,10 @@ async def test_aspirate_implementation_meniscus( ) -> None: """Aspirate should update WellVolumeOffset when called with WellOrigin.MENISCUS.""" location = LiquidHandlingWellLocation( - origin=WellOrigin.MENISCUS, offset=WellOffset(x=0, y=0, z=-1) - ) - updated_location = LiquidHandlingWellLocation( origin=WellOrigin.MENISCUS, offset=WellOffset(x=0, y=0, z=-1), volumeOffset="operationVolume", ) - data = AspirateParams( pipetteId="abc", labwareId="123", @@ -353,7 +376,7 @@ async def test_aspirate_implementation_meniscus( pipette_id="abc", labware_id="123", well_name="A3", - well_location=updated_location, + well_location=location, current_well=None, operation_volume=-50, ), @@ -372,12 +395,19 @@ async def test_aspirate_implementation_meniscus( assert result == SuccessData( public=AspirateResult(volume=50, position=DeckPoint(x=1, y=2, z=3)), - private=None, state_update=update_types.StateUpdate( pipette_location=update_types.PipetteLocationUpdate( pipette_id="abc", new_location=update_types.Well(labware_id="123", well_name="A3"), new_deck_point=DeckPoint(x=1, y=2, z=3), - ) + ), + liquid_operated=update_types.LiquidOperatedUpdate( + labware_id="123", + well_name="A3", + volume_added=-50, + ), + pipette_aspirated_fluid=update_types.PipetteAspiratedFluidUpdate( + pipette_id="abc", fluid=AspiratedFluid(kind=FluidKind.LIQUID, volume=50) + ), ), ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_aspirate_in_place.py b/api/tests/opentrons/protocol_engine/commands/test_aspirate_in_place.py index 3891dd90294..034c7f51ede 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_aspirate_in_place.py +++ b/api/tests/opentrons/protocol_engine/commands/test_aspirate_in_place.py @@ -21,6 +21,14 @@ from opentrons.protocol_engine.resources import ModelUtils from opentrons.protocol_engine.state.state import StateStore from opentrons.protocol_engine.commands.pipetting_common import OverpressureError +from opentrons.protocol_engine.types import ( + CurrentWell, + CurrentPipetteLocation, + CurrentAddressableArea, + AspiratedFluid, + FluidKind, +) +from opentrons.protocol_engine.state import update_types @pytest.fixture @@ -61,6 +69,22 @@ def subject( ) +@pytest.mark.parametrize( + "location,stateupdateLabware,stateupdateWell", + [ + ( + CurrentWell( + pipette_id="pipette-id-abc", + labware_id="labware-id-1", + well_name="well-name-1", + ), + "labware-id-1", + "well-name-1", + ), + (None, None, None), + (CurrentAddressableArea("pipette-id-abc", "addressable-area-1"), None, None), + ], +) async def test_aspirate_in_place_implementation( decoy: Decoy, pipetting: PipettingHandler, @@ -68,6 +92,9 @@ async def test_aspirate_in_place_implementation( hardware_api: HardwareAPI, mock_command_note_adder: CommandNoteAdder, subject: AspirateInPlaceImplementation, + location: CurrentPipetteLocation | None, + stateupdateLabware: str, + stateupdateWell: str, ) -> None: """It should aspirate in place.""" data = AspirateInPlaceParams( @@ -91,9 +118,35 @@ async def test_aspirate_in_place_implementation( ) ).then_return(123) + decoy.when(state_store.pipettes.get_current_location()).then_return(location) + result = await subject.execute(params=data) - assert result == SuccessData(public=AspirateInPlaceResult(volume=123), private=None) + if isinstance(location, CurrentWell): + assert result == SuccessData( + public=AspirateInPlaceResult(volume=123), + state_update=update_types.StateUpdate( + liquid_operated=update_types.LiquidOperatedUpdate( + labware_id=stateupdateLabware, + well_name=stateupdateWell, + volume_added=-123, + ), + pipette_aspirated_fluid=update_types.PipetteAspiratedFluidUpdate( + pipette_id="pipette-id-abc", + fluid=AspiratedFluid(kind=FluidKind.LIQUID, volume=123), + ), + ), + ) + else: + assert result == SuccessData( + public=AspirateInPlaceResult(volume=123), + state_update=update_types.StateUpdate( + pipette_aspirated_fluid=update_types.PipetteAspiratedFluidUpdate( + pipette_id="pipette-id-abc", + fluid=AspiratedFluid(kind=FluidKind.LIQUID, volume=123), + ) + ), + ) async def test_handle_aspirate_in_place_request_not_ready_to_aspirate( @@ -153,6 +206,22 @@ async def test_aspirate_raises_volume_error( await subject.execute(data) +@pytest.mark.parametrize( + "location,stateupdateLabware,stateupdateWell", + [ + ( + CurrentWell( + pipette_id="pipette-id", + labware_id="labware-id-1", + well_name="well-name-1", + ), + "labware-id-1", + "well-name-1", + ), + (None, None, None), + (CurrentAddressableArea("pipette-id", "addressable-area-1"), None, None), + ], +) async def test_overpressure_error( decoy: Decoy, gantry_mover: GantryMover, @@ -160,6 +229,10 @@ async def test_overpressure_error( subject: AspirateInPlaceImplementation, model_utils: ModelUtils, mock_command_note_adder: CommandNoteAdder, + state_store: StateStore, + location: CurrentPipetteLocation | None, + stateupdateLabware: str, + stateupdateWell: str, ) -> None: """It should return an overpressure error if the hardware API indicates that.""" pipette_id = "pipette-id" @@ -191,14 +264,40 @@ async def test_overpressure_error( decoy.when(model_utils.generate_id()).then_return(error_id) decoy.when(model_utils.get_timestamp()).then_return(error_timestamp) decoy.when(await gantry_mover.get_position(pipette_id)).then_return(position) + decoy.when(state_store.pipettes.get_current_location()).then_return(location) result = await subject.execute(data) - assert result == DefinedErrorData( - public=OverpressureError.construct( - id=error_id, - createdAt=error_timestamp, - wrappedErrors=[matchers.Anything()], - errorInfo={"retryLocation": (position.x, position.y, position.z)}, - ), - ) + if isinstance(location, CurrentWell): + assert result == DefinedErrorData( + public=OverpressureError.construct( + id=error_id, + createdAt=error_timestamp, + wrappedErrors=[matchers.Anything()], + errorInfo={"retryLocation": (position.x, position.y, position.z)}, + ), + state_update=update_types.StateUpdate( + liquid_operated=update_types.LiquidOperatedUpdate( + labware_id=stateupdateLabware, + well_name=stateupdateWell, + volume_added=update_types.CLEAR, + ), + pipette_aspirated_fluid=update_types.PipetteUnknownFluidUpdate( + pipette_id="pipette-id" + ), + ), + ) + else: + assert result == DefinedErrorData( + public=OverpressureError.construct( + id=error_id, + createdAt=error_timestamp, + wrappedErrors=[matchers.Anything()], + errorInfo={"retryLocation": (position.x, position.y, position.z)}, + ), + state_update=update_types.StateUpdate( + pipette_aspirated_fluid=update_types.PipetteUnknownFluidUpdate( + pipette_id="pipette-id" + ) + ), + ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_blow_out.py b/api/tests/opentrons/protocol_engine/commands/test_blow_out.py index 93504c6904d..d053aac0f0d 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_blow_out.py +++ b/api/tests/opentrons/protocol_engine/commands/test_blow_out.py @@ -76,7 +76,6 @@ async def test_blow_out_implementation( assert result == SuccessData( public=BlowOutResult(position=DeckPoint(x=1, y=2, z=3)), - private=None, state_update=update_types.StateUpdate( pipette_location=update_types.PipetteLocationUpdate( pipette_id="pipette-id", @@ -85,7 +84,10 @@ async def test_blow_out_implementation( well_name="C6", ), new_deck_point=DeckPoint(x=1, y=2, z=3), - ) + ), + pipette_aspirated_fluid=update_types.PipetteEmptyFluidUpdate( + pipette_id="pipette-id" + ), ), ) @@ -146,4 +148,17 @@ async def test_overpressure_error( wrappedErrors=[matchers.Anything()], errorInfo={"retryLocation": (1, 2, 3)}, ), + state_update=update_types.StateUpdate( + pipette_location=update_types.PipetteLocationUpdate( + pipette_id="pipette-id", + new_location=update_types.Well( + labware_id="labware-id", + well_name="C6", + ), + new_deck_point=DeckPoint(x=1, y=2, z=3), + ), + pipette_aspirated_fluid=update_types.PipetteUnknownFluidUpdate( + pipette_id="pipette-id" + ), + ), ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_blow_out_in_place.py b/api/tests/opentrons/protocol_engine/commands/test_blow_out_in_place.py index e2735709253..bc4ab782f64 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_blow_out_in_place.py +++ b/api/tests/opentrons/protocol_engine/commands/test_blow_out_in_place.py @@ -1,11 +1,14 @@ """Test blow-out-in-place commands.""" from datetime import datetime + +import pytest from decoy import Decoy, matchers from opentrons.protocol_engine.commands.pipetting_common import OverpressureError from opentrons.protocol_engine.execution.gantry_mover import GantryMover from opentrons.protocol_engine.resources.model_utils import ModelUtils from opentrons.protocol_engine.state.state import StateView +from opentrons.protocol_engine.state import update_types from opentrons.protocol_engine.commands.blow_out_in_place import ( BlowOutInPlaceParams, BlowOutInPlaceResult, @@ -18,7 +21,6 @@ from opentrons.hardware_control import HardwareControlAPI from opentrons.types import Point from opentrons_shared_data.errors.exceptions import PipetteOverpressureError -import pytest @pytest.fixture @@ -52,7 +54,14 @@ async def test_blow_out_in_place_implementation( result = await subject.execute(data) - assert result == SuccessData(public=BlowOutInPlaceResult(), private=None) + assert result == SuccessData( + public=BlowOutInPlaceResult(), + state_update=update_types.StateUpdate( + pipette_aspirated_fluid=update_types.PipetteEmptyFluidUpdate( + pipette_id="pipette-id" + ) + ), + ) decoy.verify( await pipetting.blow_out_in_place(pipette_id="pipette-id", flow_rate=1.234) @@ -100,4 +109,9 @@ async def test_overpressure_error( wrappedErrors=[matchers.Anything()], errorInfo={"retryLocation": (position.x, position.y, position.z)}, ), + state_update=update_types.StateUpdate( + pipette_aspirated_fluid=update_types.PipetteUnknownFluidUpdate( + pipette_id="pipette-id" + ) + ), ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_comment.py b/api/tests/opentrons/protocol_engine/commands/test_comment.py index 4010f2ec56c..9b62afa7fe3 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_comment.py +++ b/api/tests/opentrons/protocol_engine/commands/test_comment.py @@ -15,4 +15,4 @@ async def test_comment_implementation() -> None: result = await subject.execute(data) - assert result == SuccessData(public=CommentResult(), private=None) + assert result == SuccessData(public=CommentResult()) diff --git a/api/tests/opentrons/protocol_engine/commands/test_configure_for_volume.py b/api/tests/opentrons/protocol_engine/commands/test_configure_for_volume.py index 2279f2a0ebf..d237c9e6090 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_configure_for_volume.py +++ b/api/tests/opentrons/protocol_engine/commands/test_configure_for_volume.py @@ -19,7 +19,6 @@ from opentrons.protocol_engine.commands.configure_for_volume import ( ConfigureForVolumeParams, ConfigureForVolumeResult, - ConfigureForVolumePrivateResult, ConfigureForVolumeImplementation, ) from opentrons_shared_data.pipette.types import PipetteNameType @@ -85,9 +84,6 @@ async def test_configure_for_volume_implementation( assert result == SuccessData( public=ConfigureForVolumeResult(), - private=ConfigureForVolumePrivateResult( - pipette_id="pipette-id", serial_number="some number", config=config - ), state_update=StateUpdate( pipette_config=PipetteConfigUpdate( pipette_id="pipette-id", serial_number="some number", config=config diff --git a/api/tests/opentrons/protocol_engine/commands/test_configure_nozzle_layout.py b/api/tests/opentrons/protocol_engine/commands/test_configure_nozzle_layout.py index e72b659a83c..cfe6f80c3a8 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_configure_nozzle_layout.py +++ b/api/tests/opentrons/protocol_engine/commands/test_configure_nozzle_layout.py @@ -145,7 +145,6 @@ async def test_configure_nozzle_layout_implementation( assert result == SuccessData( public=ConfigureNozzleLayoutResult(), - private=None, state_update=StateUpdate( pipette_nozzle_map=PipetteNozzleMapUpdate( pipette_id="pipette-id", diff --git a/api/tests/opentrons/protocol_engine/commands/test_dispense.py b/api/tests/opentrons/protocol_engine/commands/test_dispense.py index 167223e6d9d..a51b2cc7b84 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_dispense.py +++ b/api/tests/opentrons/protocol_engine/commands/test_dispense.py @@ -48,6 +48,7 @@ async def test_dispense_implementation( movement: MovementHandler, pipetting: PipettingHandler, subject: DispenseImplementation, + state_view: StateView, ) -> None: """It should move to the target location and then dispense.""" well_location = LiquidHandlingWellLocation( @@ -77,12 +78,16 @@ async def test_dispense_implementation( pipette_id="pipette-id-abc123", volume=50, flow_rate=1.23, push_out=None ) ).then_return(42) + decoy.when( + state_view.pipettes.get_liquid_dispensed_by_ejecting_volume( + pipette_id="pipette-id-abc123", volume=42 + ) + ).then_return(34) result = await subject.execute(data) assert result == SuccessData( public=DispenseResult(volume=42, position=DeckPoint(x=1, y=2, z=3)), - private=None, state_update=update_types.StateUpdate( pipette_location=update_types.PipetteLocationUpdate( pipette_id="pipette-id-abc123", @@ -92,6 +97,14 @@ async def test_dispense_implementation( ), new_deck_point=DeckPoint.construct(x=1, y=2, z=3), ), + liquid_operated=update_types.LiquidOperatedUpdate( + labware_id="labware-id-abc123", + well_name="A3", + volume_added=34, + ), + pipette_aspirated_fluid=update_types.PipetteEjectedFluidUpdate( + pipette_id="pipette-id-abc123", volume=42 + ), ), ) @@ -161,5 +174,13 @@ async def test_overpressure_error( ), new_deck_point=DeckPoint.construct(x=1, y=2, z=3), ), + liquid_operated=update_types.LiquidOperatedUpdate( + labware_id="labware-id", + well_name="well-name", + volume_added=update_types.CLEAR, + ), + pipette_aspirated_fluid=update_types.PipetteUnknownFluidUpdate( + pipette_id="pipette-id" + ), ), ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_dispense_in_place.py b/api/tests/opentrons/protocol_engine/commands/test_dispense_in_place.py index 53a491ad211..bcfdba0ed57 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_dispense_in_place.py +++ b/api/tests/opentrons/protocol_engine/commands/test_dispense_in_place.py @@ -1,6 +1,7 @@ """Test dispense-in-place commands.""" from datetime import datetime +import pytest from decoy import Decoy, matchers from opentrons_shared_data.errors.exceptions import PipetteOverpressureError @@ -16,19 +17,57 @@ ) from opentrons.protocol_engine.commands.pipetting_common import OverpressureError from opentrons.protocol_engine.resources import ModelUtils +from opentrons.protocol_engine.state.state import StateView +from opentrons.protocol_engine.types import ( + CurrentWell, + CurrentPipetteLocation, + CurrentAddressableArea, +) +from opentrons.protocol_engine.state import update_types -async def test_dispense_in_place_implementation( - decoy: Decoy, +@pytest.fixture +def subject( pipetting: PipettingHandler, + state_view: StateView, gantry_mover: GantryMover, model_utils: ModelUtils, -) -> None: - """It should dispense in place.""" - subject = DispenseInPlaceImplementation( - pipetting=pipetting, gantry_mover=gantry_mover, model_utils=model_utils +) -> DispenseInPlaceImplementation: + """Build a command implementation.""" + return DispenseInPlaceImplementation( + pipetting=pipetting, + state_view=state_view, + gantry_mover=gantry_mover, + model_utils=model_utils, ) + +@pytest.mark.parametrize( + "location,stateupdateLabware,stateupdateWell", + [ + ( + CurrentWell( + pipette_id="pipette-id-abc", + labware_id="labware-id-1", + well_name="well-name-1", + ), + "labware-id-1", + "well-name-1", + ), + (None, None, None), + (CurrentAddressableArea("pipette-id-abc", "addressable-area-1"), None, None), + ], +) +async def test_dispense_in_place_implementation( + decoy: Decoy, + pipetting: PipettingHandler, + state_view: StateView, + subject: DispenseInPlaceImplementation, + location: CurrentPipetteLocation | None, + stateupdateLabware: str, + stateupdateWell: str, +) -> None: + """It should dispense in place.""" data = DispenseInPlaceParams( pipetteId="pipette-id-abc", volume=123, @@ -41,22 +80,68 @@ async def test_dispense_in_place_implementation( ) ).then_return(42) + decoy.when(state_view.pipettes.get_current_location()).then_return(location) + decoy.when( + state_view.pipettes.get_liquid_dispensed_by_ejecting_volume( + pipette_id="pipette-id-abc", volume=42 + ) + ).then_return(34) + result = await subject.execute(data) - assert result == SuccessData(public=DispenseInPlaceResult(volume=42), private=None) + if isinstance(location, CurrentWell): + assert result == SuccessData( + public=DispenseInPlaceResult(volume=42), + state_update=update_types.StateUpdate( + pipette_aspirated_fluid=update_types.PipetteEjectedFluidUpdate( + pipette_id="pipette-id-abc", volume=42 + ), + liquid_operated=update_types.LiquidOperatedUpdate( + labware_id=stateupdateLabware, + well_name=stateupdateWell, + volume_added=34, + ), + ), + ) + else: + assert result == SuccessData( + public=DispenseInPlaceResult(volume=42), + state_update=update_types.StateUpdate( + pipette_aspirated_fluid=update_types.PipetteEjectedFluidUpdate( + pipette_id="pipette-id-abc", volume=42 + ) + ), + ) +@pytest.mark.parametrize( + "location,stateupdateLabware,stateupdateWell", + [ + ( + CurrentWell( + pipette_id="pipette-id", + labware_id="labware-id-1", + well_name="well-name-1", + ), + "labware-id-1", + "well-name-1", + ), + (None, None, None), + (CurrentAddressableArea("pipette-id", "addressable-area-1"), None, None), + ], +) async def test_overpressure_error( decoy: Decoy, gantry_mover: GantryMover, pipetting: PipettingHandler, + state_view: StateView, model_utils: ModelUtils, + subject: DispenseInPlaceImplementation, + location: CurrentPipetteLocation | None, + stateupdateLabware: str, + stateupdateWell: str, ) -> None: """It should return an overpressure error if the hardware API indicates that.""" - subject = DispenseInPlaceImplementation( - pipetting=pipetting, gantry_mover=gantry_mover, model_utils=model_utils - ) - pipette_id = "pipette-id" position = Point(x=1, y=2, z=3) @@ -83,14 +168,40 @@ async def test_overpressure_error( decoy.when(model_utils.generate_id()).then_return(error_id) decoy.when(model_utils.get_timestamp()).then_return(error_timestamp) decoy.when(await gantry_mover.get_position(pipette_id)).then_return(position) + decoy.when(state_view.pipettes.get_current_location()).then_return(location) result = await subject.execute(data) - assert result == DefinedErrorData( - public=OverpressureError.construct( - id=error_id, - createdAt=error_timestamp, - wrappedErrors=[matchers.Anything()], - errorInfo={"retryLocation": (position.x, position.y, position.z)}, - ), - ) + if isinstance(location, CurrentWell): + assert result == DefinedErrorData( + public=OverpressureError.construct( + id=error_id, + createdAt=error_timestamp, + wrappedErrors=[matchers.Anything()], + errorInfo={"retryLocation": (position.x, position.y, position.z)}, + ), + state_update=update_types.StateUpdate( + liquid_operated=update_types.LiquidOperatedUpdate( + labware_id=stateupdateLabware, + well_name=stateupdateWell, + volume_added=update_types.CLEAR, + ), + pipette_aspirated_fluid=update_types.PipetteUnknownFluidUpdate( + pipette_id="pipette-id" + ), + ), + ) + else: + assert result == DefinedErrorData( + public=OverpressureError.construct( + id=error_id, + createdAt=error_timestamp, + wrappedErrors=[matchers.Anything()], + errorInfo={"retryLocation": (position.x, position.y, position.z)}, + ), + state_update=update_types.StateUpdate( + pipette_aspirated_fluid=update_types.PipetteUnknownFluidUpdate( + pipette_id="pipette-id" + ) + ), + ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_drop_tip.py b/api/tests/opentrons/protocol_engine/commands/test_drop_tip.py index 4a8e32c05d0..9217a4a4287 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_drop_tip.py +++ b/api/tests/opentrons/protocol_engine/commands/test_drop_tip.py @@ -129,7 +129,6 @@ async def test_drop_tip_implementation( assert result == SuccessData( public=DropTipResult(position=DeckPoint(x=111, y=222, z=333)), - private=None, state_update=update_types.StateUpdate( pipette_location=update_types.PipetteLocationUpdate( pipette_id="abc", @@ -142,6 +141,9 @@ async def test_drop_tip_implementation( pipette_tip_state=update_types.PipetteTipStateUpdate( pipette_id="abc", tip_geometry=None ), + pipette_aspirated_fluid=update_types.PipetteUnknownFluidUpdate( + pipette_id="abc" + ), ), ) @@ -207,7 +209,6 @@ async def test_drop_tip_with_alternating_locations( result = await subject.execute(params) assert result == SuccessData( public=DropTipResult(position=DeckPoint(x=111, y=222, z=333)), - private=None, state_update=update_types.StateUpdate( pipette_location=update_types.PipetteLocationUpdate( pipette_id="abc", @@ -220,6 +221,9 @@ async def test_drop_tip_with_alternating_locations( pipette_tip_state=update_types.PipetteTipStateUpdate( pipette_id="abc", tip_geometry=None ), + pipette_aspirated_fluid=update_types.PipetteUnknownFluidUpdate( + pipette_id="abc" + ), ), ) @@ -293,5 +297,14 @@ async def test_tip_attached_error( ), new_deck_point=DeckPoint(x=111, y=222, z=333), ), + pipette_aspirated_fluid=update_types.PipetteUnknownFluidUpdate( + pipette_id="abc" + ), + ), + state_update_if_false_positive=update_types.StateUpdate( + pipette_tip_state=update_types.PipetteTipStateUpdate( + pipette_id="abc", + tip_geometry=None, + ), ), ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_drop_tip_in_place.py b/api/tests/opentrons/protocol_engine/commands/test_drop_tip_in_place.py index f2061c3d552..9ea78e7dadd 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_drop_tip_in_place.py +++ b/api/tests/opentrons/protocol_engine/commands/test_drop_tip_in_place.py @@ -19,6 +19,7 @@ from opentrons.protocol_engine.state.update_types import ( PipetteTipStateUpdate, StateUpdate, + PipetteUnknownFluidUpdate, ) @@ -49,9 +50,11 @@ async def test_success( assert result == SuccessData( public=DropTipInPlaceResult(), - private=None, state_update=StateUpdate( - pipette_tip_state=PipetteTipStateUpdate(pipette_id="abc", tip_geometry=None) + pipette_tip_state=PipetteTipStateUpdate( + pipette_id="abc", tip_geometry=None + ), + pipette_aspirated_fluid=PipetteUnknownFluidUpdate(pipette_id="abc"), ), ) @@ -90,5 +93,12 @@ async def test_tip_attached_error( createdAt=datetime(year=1, month=2, day=3), wrappedErrors=[matchers.Anything()], ), - state_update=StateUpdate(), + state_update=StateUpdate( + pipette_aspirated_fluid=PipetteUnknownFluidUpdate(pipette_id="abc") + ), + state_update_if_false_positive=StateUpdate( + pipette_tip_state=PipetteTipStateUpdate( + pipette_id="abc", tip_geometry=None + ), + ), ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_get_tip_presence.py b/api/tests/opentrons/protocol_engine/commands/test_get_tip_presence.py index a1d0230f74a..99e5b231e1a 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_get_tip_presence.py +++ b/api/tests/opentrons/protocol_engine/commands/test_get_tip_presence.py @@ -41,5 +41,5 @@ async def test_get_tip_presence_implementation( result = await subject.execute(data) assert result == SuccessData( - public=GetTipPresenceResult(status=status), private=None + public=GetTipPresenceResult(status=status), ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_home.py b/api/tests/opentrons/protocol_engine/commands/test_home.py index 5a9446d6308..b3578c400e5 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_home.py +++ b/api/tests/opentrons/protocol_engine/commands/test_home.py @@ -24,7 +24,6 @@ async def test_home_implementation(decoy: Decoy, movement: MovementHandler) -> N assert result == SuccessData( public=HomeResult(), - private=None, state_update=update_types.StateUpdate(pipette_location=update_types.CLEAR), ) decoy.verify(await movement.home(axes=[MotorAxis.X, MotorAxis.Y])) @@ -40,7 +39,6 @@ async def test_home_all_implementation(decoy: Decoy, movement: MovementHandler) assert result == SuccessData( public=HomeResult(), - private=None, state_update=update_types.StateUpdate(pipette_location=update_types.CLEAR), ) decoy.verify(await movement.home(axes=None)) @@ -63,7 +61,6 @@ async def test_home_with_invalid_position( result = await subject.execute(data) assert result == SuccessData( public=HomeResult(), - private=None, state_update=update_types.StateUpdate(pipette_location=update_types.CLEAR), ) @@ -76,7 +73,6 @@ async def test_home_with_invalid_position( result = await subject.execute(data) assert result == SuccessData( public=HomeResult(), - private=None, state_update=update_types.StateUpdate(pipette_location=update_types.CLEAR), ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_liquid_probe.py b/api/tests/opentrons/protocol_engine/commands/test_liquid_probe.py index 6fb6ebc6935..2cada4f3e24 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_liquid_probe.py +++ b/api/tests/opentrons/protocol_engine/commands/test_liquid_probe.py @@ -104,6 +104,7 @@ async def test_liquid_probe_implementation( subject: EitherImplementation, params_type: EitherParamsType, result_type: EitherResultType, + model_utils: ModelUtils, ) -> None: """It should move to the destination and do a liquid probe there.""" location = WellLocation(origin=WellOrigin.BOTTOM, offset=WellOffset(x=0, y=0, z=1)) @@ -137,18 +138,35 @@ async def test_liquid_probe_implementation( ), ).then_return(15.0) + decoy.when( + state_view.geometry.get_well_volume_at_height( + labware_id="123", + well_name="A3", + height=15.0, + ), + ).then_return(30.0) + + timestamp = datetime(year=2020, month=1, day=2) + decoy.when(model_utils.get_timestamp()).then_return(timestamp) + result = await subject.execute(data) assert type(result.public) is result_type # Pydantic v1 only compares the fields. assert result == SuccessData( public=result_type(z_position=15.0, position=DeckPoint(x=1, y=2, z=3)), - private=None, state_update=update_types.StateUpdate( pipette_location=update_types.PipetteLocationUpdate( pipette_id="abc", new_location=update_types.Well(labware_id="123", well_name="A3"), new_deck_point=DeckPoint(x=1, y=2, z=3), - ) + ), + liquid_probed=update_types.LiquidProbedUpdate( + labware_id="123", + well_name="A3", + height=15.0, + volume=30.0, + last_probed=timestamp, + ), ), ) @@ -212,7 +230,14 @@ async def test_liquid_not_found_error( pipette_id=pipette_id, new_location=update_types.Well(labware_id=labware_id, well_name=well_name), new_deck_point=DeckPoint(x=position.x, y=position.y, z=position.z), - ) + ), + liquid_probed=update_types.LiquidProbedUpdate( + labware_id=labware_id, + well_name=well_name, + height=update_types.CLEAR, + volume=update_types.CLEAR, + last_probed=error_timestamp, + ), ) if isinstance(subject, LiquidProbeImplementation): assert result == DefinedErrorData( @@ -229,7 +254,6 @@ async def test_liquid_not_found_error( z_position=None, position=DeckPoint(x=position.x, y=position.y, z=position.z), ), - private=None, state_update=expected_state_update, ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_load_labware.py b/api/tests/opentrons/protocol_engine/commands/test_load_labware.py index 85cb7794d76..3873f9854b4 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_load_labware.py +++ b/api/tests/opentrons/protocol_engine/commands/test_load_labware.py @@ -94,7 +94,6 @@ async def test_load_labware_implementation( definition=well_plate_def, offsetId="labware-offset-id", ), - private=None, state_update=StateUpdate( loaded_labware=LoadedLabwareUpdate( labware_id="labware-id", @@ -178,7 +177,6 @@ async def test_load_labware_on_labware( definition=well_plate_def, offsetId="labware-offset-id", ), - private=None, state_update=StateUpdate( loaded_labware=LoadedLabwareUpdate( labware_id="labware-id", diff --git a/api/tests/opentrons/protocol_engine/commands/test_load_liquid.py b/api/tests/opentrons/protocol_engine/commands/test_load_liquid.py index 3ccaaea15d0..6bd61061f3c 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_load_liquid.py +++ b/api/tests/opentrons/protocol_engine/commands/test_load_liquid.py @@ -1,6 +1,7 @@ """Test load-liquid command.""" import pytest from decoy import Decoy +from datetime import datetime from opentrons.protocol_engine.commands.command import SuccessData from opentrons.protocol_engine.commands import ( @@ -8,7 +9,10 @@ LoadLiquidImplementation, LoadLiquidParams, ) +from opentrons.protocol_engine.errors import InvalidLiquidError from opentrons.protocol_engine.state.state import StateView +from opentrons.protocol_engine.resources.model_utils import ModelUtils +from opentrons.protocol_engine.state import update_types @pytest.fixture @@ -18,15 +22,18 @@ def mock_state_view(decoy: Decoy) -> StateView: @pytest.fixture -def subject(mock_state_view: StateView) -> LoadLiquidImplementation: +def subject( + mock_state_view: StateView, model_utils: ModelUtils +) -> LoadLiquidImplementation: """Load liquid implementation test subject.""" - return LoadLiquidImplementation(state_view=mock_state_view) + return LoadLiquidImplementation(state_view=mock_state_view, model_utils=model_utils) async def test_load_liquid_implementation( decoy: Decoy, subject: LoadLiquidImplementation, mock_state_view: StateView, + model_utils: ModelUtils, ) -> None: """Test LoadLiquid command execution.""" data = LoadLiquidParams( @@ -34,9 +41,22 @@ async def test_load_liquid_implementation( liquidId="liquid-id", volumeByWell={"A1": 30, "B2": 100}, ) + + timestamp = datetime(year=2020, month=1, day=2) + decoy.when(model_utils.get_timestamp()).then_return(timestamp) + result = await subject.execute(data) - assert result == SuccessData(public=LoadLiquidResult(), private=None) + assert result == SuccessData( + public=LoadLiquidResult(), + state_update=update_types.StateUpdate( + liquid_loaded=update_types.LiquidLoadedUpdate( + labware_id="labware-id", + volumes={"A1": 30, "B2": 100}, + last_loaded=timestamp, + ) + ), + ) decoy.verify(mock_state_view.liquid.validate_liquid_id("liquid-id")) @@ -45,3 +65,37 @@ async def test_load_liquid_implementation( "labware-id", {"A1": 30.0, "B2": 100.0} ) ) + + +async def test_load_empty_liquid_requires_zero_volume( + decoy: Decoy, + subject: LoadLiquidImplementation, + mock_state_view: StateView, + model_utils: ModelUtils, +) -> None: + """Test that loadLiquid requires empty liquids to have 0 volume.""" + data = LoadLiquidParams( + labwareId="labware-id", liquidId="EMPTY", volumeByWell={"A1": 1.0} + ) + timestamp = datetime(year=2020, month=1, day=2) + decoy.when(model_utils.get_timestamp()).then_return(timestamp) + + with pytest.raises(InvalidLiquidError): + await subject.execute(data) + + decoy.verify(mock_state_view.liquid.validate_liquid_id("EMPTY")) + + data2 = LoadLiquidParams( + labwareId="labware-id", liquidId="EMPTY", volumeByWell={"A1": 0.0} + ) + result = await subject.execute(data2) + assert result == SuccessData( + public=LoadLiquidResult(), + state_update=update_types.StateUpdate( + liquid_loaded=update_types.LiquidLoadedUpdate( + labware_id="labware-id", + volumes=data2.volumeByWell, + last_loaded=timestamp, + ) + ), + ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_load_module.py b/api/tests/opentrons/protocol_engine/commands/test_load_module.py index 9479a724110..ce68f5c9f8a 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_load_module.py +++ b/api/tests/opentrons/protocol_engine/commands/test_load_module.py @@ -92,7 +92,6 @@ async def test_load_module_implementation( model=ModuleModel.TEMPERATURE_MODULE_V2, definition=tempdeck_v2_def, ), - private=None, ) @@ -148,7 +147,6 @@ async def test_load_module_implementation_mag_block( model=ModuleModel.MAGNETIC_BLOCK_V1, definition=mag_block_v1_def, ), - private=None, ) @@ -204,7 +202,6 @@ async def test_load_module_implementation_abs_reader( model=ModuleModel.ABSORBANCE_READER_V1, definition=abs_reader_v1_def, ), - private=None, ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_load_pipette.py b/api/tests/opentrons/protocol_engine/commands/test_load_pipette.py index 5884e015342..a42bbc4e4d9 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_load_pipette.py +++ b/api/tests/opentrons/protocol_engine/commands/test_load_pipette.py @@ -3,6 +3,7 @@ LoadPipetteUpdate, PipetteConfigUpdate, StateUpdate, + PipetteUnknownFluidUpdate, ) import pytest from decoy import Decoy @@ -22,7 +23,6 @@ from opentrons.protocol_engine.commands.load_pipette import ( LoadPipetteParams, LoadPipetteResult, - LoadPipettePrivateResult, LoadPipetteImplementation, ) from ..pipette_fixtures import get_default_nozzle_map @@ -90,9 +90,6 @@ async def test_load_pipette_implementation( assert result == SuccessData( public=LoadPipetteResult(pipetteId="some id"), - private=LoadPipettePrivateResult( - pipette_id="some id", serial_number="some-serial-number", config=config_data - ), state_update=StateUpdate( loaded_pipette=LoadPipetteUpdate( pipette_name=PipetteNameType.P300_SINGLE, @@ -105,6 +102,7 @@ async def test_load_pipette_implementation( serial_number="some-serial-number", config=config_data, ), + pipette_aspirated_fluid=PipetteUnknownFluidUpdate(pipette_id="some id"), ), ) @@ -158,9 +156,6 @@ async def test_load_pipette_implementation_96_channel( assert result == SuccessData( public=LoadPipetteResult(pipetteId="pipette-id"), - private=LoadPipettePrivateResult( - pipette_id="pipette-id", serial_number="some id", config=config_data - ), state_update=StateUpdate( loaded_pipette=LoadPipetteUpdate( pipette_name=PipetteNameType.P1000_96, @@ -173,6 +168,7 @@ async def test_load_pipette_implementation_96_channel( serial_number="some id", config=config_data, ), + pipette_aspirated_fluid=PipetteUnknownFluidUpdate(pipette_id="pipette-id"), ), ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_move_labware.py b/api/tests/opentrons/protocol_engine/commands/test_move_labware.py index d1309761d8f..a946eccf05d 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_move_labware.py +++ b/api/tests/opentrons/protocol_engine/commands/test_move_labware.py @@ -126,7 +126,6 @@ async def test_manual_move_labware_implementation( public=MoveLabwareResult( offsetId="wowzers-a-new-offset-id", ), - private=None, state_update=update_types.StateUpdate( labware_location=update_types.LabwareLocationUpdate( labware_id="my-cool-labware-id", @@ -192,7 +191,6 @@ async def test_move_labware_implementation_on_labware( public=MoveLabwareResult( offsetId="wowzers-a-new-offset-id", ), - private=None, state_update=update_types.StateUpdate( labware_location=update_types.LabwareLocationUpdate( labware_id="my-cool-labware-id", @@ -280,7 +278,6 @@ async def test_gripper_move_labware_implementation( public=MoveLabwareResult( offsetId="wowzers-a-new-offset-id", ), - private=None, state_update=update_types.StateUpdate( pipette_location=update_types.CLEAR, labware_location=update_types.LabwareLocationUpdate( @@ -516,7 +513,6 @@ async def test_gripper_move_to_waste_chute_implementation( public=MoveLabwareResult( offsetId="wowzers-a-new-offset-id", ), - private=None, state_update=update_types.StateUpdate( pipette_location=update_types.CLEAR, labware_location=update_types.LabwareLocationUpdate( @@ -803,3 +799,38 @@ async def test_move_labware_raises_when_moving_fixed_trash_labware( match="Cannot move fixed trash labware 'My cool labware'.", ): await subject.execute(data) + + +async def test_labware_raises_when_moved_onto_itself( + decoy: Decoy, + subject: MoveLabwareImplementation, + state_view: StateView, +) -> None: + """It should raise when the OnLabwareLocation has the same labware ID as the labware being moved.""" + data = MoveLabwareParams( + labwareId="the-same-labware-id", + newLocation=OnLabwareLocation(labwareId="a-cool-labware-id"), + strategy=LabwareMovementStrategy.MANUAL_MOVE_WITH_PAUSE, + ) + + decoy.when(state_view.labware.get(labware_id="the-same-labware-id")).then_return( + LoadedLabware( + id="the-same-labware-id", + loadName="load-name", + definitionUri="opentrons-test/load-name/1", + location=DeckSlotLocation(slotName=DeckSlotName.SLOT_4), + offsetId=None, + ) + ) + + decoy.when( + state_view.geometry.ensure_location_not_occupied( + location=OnLabwareLocation(labwareId="a-cool-labware-id"), + ) + ).then_return(OnLabwareLocation(labwareId="the-same-labware-id")) + + with pytest.raises( + errors.LabwareMovementNotAllowedError, + match="Cannot move a labware onto itself.", + ): + await subject.execute(data) diff --git a/api/tests/opentrons/protocol_engine/commands/test_move_relative.py b/api/tests/opentrons/protocol_engine/commands/test_move_relative.py index ee874206f92..01522e4dc45 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_move_relative.py +++ b/api/tests/opentrons/protocol_engine/commands/test_move_relative.py @@ -38,7 +38,6 @@ async def test_move_relative_implementation( assert result == SuccessData( public=MoveRelativeResult(position=DeckPoint(x=1, y=2, z=3)), - private=None, state_update=update_types.StateUpdate( pipette_location=update_types.PipetteLocationUpdate( pipette_id="pipette-id", diff --git a/api/tests/opentrons/protocol_engine/commands/test_move_to_addressable_area.py b/api/tests/opentrons/protocol_engine/commands/test_move_to_addressable_area.py index 2b64f617b9f..6925fd7cce4 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_move_to_addressable_area.py +++ b/api/tests/opentrons/protocol_engine/commands/test_move_to_addressable_area.py @@ -75,7 +75,6 @@ async def test_move_to_addressable_area_implementation_non_gen1( assert result == SuccessData( public=MoveToAddressableAreaResult(position=DeckPoint(x=9, y=8, z=7)), - private=None, state_update=update_types.StateUpdate( pipette_location=update_types.PipetteLocationUpdate( pipette_id="abc", @@ -139,7 +138,6 @@ async def test_move_to_addressable_area_implementation_with_gen1( assert result == SuccessData( public=MoveToAddressableAreaResult(position=DeckPoint(x=9, y=8, z=7)), - private=None, state_update=update_types.StateUpdate( pipette_location=update_types.PipetteLocationUpdate( pipette_id="abc", diff --git a/api/tests/opentrons/protocol_engine/commands/test_move_to_addressable_area_for_drop_tip.py b/api/tests/opentrons/protocol_engine/commands/test_move_to_addressable_area_for_drop_tip.py index ebcb3db1243..faca36d8121 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_move_to_addressable_area_for_drop_tip.py +++ b/api/tests/opentrons/protocol_engine/commands/test_move_to_addressable_area_for_drop_tip.py @@ -58,7 +58,6 @@ async def test_move_to_addressable_area_for_drop_tip_implementation( assert result == SuccessData( public=MoveToAddressableAreaForDropTipResult(position=DeckPoint(x=9, y=8, z=7)), - private=None, state_update=update_types.StateUpdate( pipette_location=update_types.PipetteLocationUpdate( pipette_id="abc", diff --git a/api/tests/opentrons/protocol_engine/commands/test_move_to_coordinates.py b/api/tests/opentrons/protocol_engine/commands/test_move_to_coordinates.py index 81d74657953..2e3ada1d3d3 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_move_to_coordinates.py +++ b/api/tests/opentrons/protocol_engine/commands/test_move_to_coordinates.py @@ -58,7 +58,6 @@ async def test_move_to_coordinates_implementation( assert result == SuccessData( public=MoveToCoordinatesResult(position=DeckPoint(x=4.44, y=5.55, z=6.66)), - private=None, state_update=update_types.StateUpdate( pipette_location=update_types.PipetteLocationUpdate( pipette_id="pipette-id", diff --git a/api/tests/opentrons/protocol_engine/commands/test_move_to_well.py b/api/tests/opentrons/protocol_engine/commands/test_move_to_well.py index 1b01009dc0e..fdfcfb45af7 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_move_to_well.py +++ b/api/tests/opentrons/protocol_engine/commands/test_move_to_well.py @@ -61,7 +61,6 @@ async def test_move_to_well_implementation( assert result == SuccessData( public=MoveToWellResult(position=DeckPoint(x=9, y=8, z=7)), - private=None, state_update=update_types.StateUpdate( pipette_location=update_types.PipetteLocationUpdate( pipette_id="abc", diff --git a/api/tests/opentrons/protocol_engine/commands/test_pick_up_tip.py b/api/tests/opentrons/protocol_engine/commands/test_pick_up_tip.py index 3771fe00eb1..5fb97a2f78f 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_pick_up_tip.py +++ b/api/tests/opentrons/protocol_engine/commands/test_pick_up_tip.py @@ -2,6 +2,7 @@ from datetime import datetime from decoy import Decoy, matchers +from unittest.mock import sentinel from opentrons.types import MountType, Point @@ -11,7 +12,7 @@ WellOffset, DeckPoint, ) -from opentrons.protocol_engine.errors import TipNotAttachedError +from opentrons.protocol_engine.errors import PickUpTipTipNotAttachedError from opentrons.protocol_engine.execution import MovementHandler, TipHandler from opentrons.protocol_engine.resources import ModelUtils from opentrons.protocol_engine.state import update_types @@ -83,7 +84,6 @@ async def test_success( tipDiameter=5, position=DeckPoint(x=111, y=222, z=333), ), - private=None, state_update=update_types.StateUpdate( pipette_location=update_types.PipetteLocationUpdate( pipette_id="pipette-id", @@ -97,6 +97,9 @@ async def test_success( tips_used=update_types.TipsUsedUpdate( pipette_id="pipette-id", labware_id="labware-id", well_name="A3" ), + pipette_aspirated_fluid=update_types.PipetteEmptyFluidUpdate( + pipette_id="pipette-id" + ), ), ) @@ -140,7 +143,7 @@ async def test_tip_physically_missing_error( await tip_handler.pick_up_tip( pipette_id=pipette_id, labware_id=labware_id, well_name=well_name ) - ).then_raise(TipNotAttachedError()) + ).then_raise(PickUpTipTipNotAttachedError(tip_geometry=sentinel.tip_geometry)) decoy.when(model_utils.generate_id()).then_return(error_id) decoy.when(model_utils.get_timestamp()).then_return(error_created_at) @@ -163,5 +166,16 @@ async def test_tip_physically_missing_error( tips_used=update_types.TipsUsedUpdate( pipette_id="pipette-id", labware_id="labware-id", well_name="well-name" ), + pipette_aspirated_fluid=update_types.PipetteUnknownFluidUpdate( + pipette_id="pipette-id" + ), + ), + state_update_if_false_positive=update_types.StateUpdate( + pipette_tip_state=update_types.PipetteTipStateUpdate( + pipette_id="pipette-id", tip_geometry=sentinel.tip_geometry + ), + pipette_aspirated_fluid=update_types.PipetteEmptyFluidUpdate( + pipette_id="pipette-id" + ), ), ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_prepare_to_aspirate.py b/api/tests/opentrons/protocol_engine/commands/test_prepare_to_aspirate.py index 45e8db96837..2de35e38332 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_prepare_to_aspirate.py +++ b/api/tests/opentrons/protocol_engine/commands/test_prepare_to_aspirate.py @@ -17,6 +17,7 @@ from opentrons.protocol_engine.execution.gantry_mover import GantryMover from opentrons.protocol_engine.resources.model_utils import ModelUtils from opentrons.protocol_engine.commands.pipetting_common import OverpressureError +from opentrons.protocol_engine.state import update_types from opentrons_shared_data.errors.exceptions import PipetteOverpressureError @@ -32,7 +33,7 @@ def subject( ) -async def test_prepare_to_aspirate_implmenetation( +async def test_prepare_to_aspirate_implementation( decoy: Decoy, subject: PrepareToAspirateImplementation, pipetting: PipettingHandler ) -> None: """A PrepareToAspirate command should have an executing implementation.""" @@ -43,7 +44,14 @@ async def test_prepare_to_aspirate_implmenetation( ) result = await subject.execute(data) - assert result == SuccessData(public=PrepareToAspirateResult(), private=None) + assert result == SuccessData( + public=PrepareToAspirateResult(), + state_update=update_types.StateUpdate( + pipette_aspirated_fluid=update_types.PipetteEmptyFluidUpdate( + pipette_id="some id" + ) + ), + ) async def test_overpressure_error( @@ -84,4 +92,9 @@ async def test_overpressure_error( wrappedErrors=[matchers.Anything()], errorInfo={"retryLocation": (position.x, position.y, position.z)}, ), + state_update=update_types.StateUpdate( + pipette_aspirated_fluid=update_types.PipetteUnknownFluidUpdate( + pipette_id="pipette-id" + ) + ), ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_reload_labware.py b/api/tests/opentrons/protocol_engine/commands/test_reload_labware.py index c79727c9a31..51779c427d7 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_reload_labware.py +++ b/api/tests/opentrons/protocol_engine/commands/test_reload_labware.py @@ -66,7 +66,6 @@ async def test_reload_labware_implementation( labwareId="my-labware-id", offsetId="labware-offset-id", ), - private=None, state_update=StateUpdate( labware_location=LabwareLocationUpdate( labware_id="my-labware-id", diff --git a/api/tests/opentrons/protocol_engine/commands/test_retract_axis.py b/api/tests/opentrons/protocol_engine/commands/test_retract_axis.py index 7442460f9b1..6dedf5b2f19 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_retract_axis.py +++ b/api/tests/opentrons/protocol_engine/commands/test_retract_axis.py @@ -25,7 +25,6 @@ async def test_retract_axis_implementation( assert result == SuccessData( public=RetractAxisResult(), - private=None, state_update=update_types.StateUpdate(pipette_location=update_types.CLEAR), ) decoy.verify(await movement.retract_axis(axis=MotorAxis.Y)) diff --git a/api/tests/opentrons/protocol_engine/commands/test_save_position.py b/api/tests/opentrons/protocol_engine/commands/test_save_position.py index c0f5e091e30..bc6d8ed6668 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_save_position.py +++ b/api/tests/opentrons/protocol_engine/commands/test_save_position.py @@ -51,5 +51,4 @@ async def test_save_position_implementation( positionId="456", position=DeckPoint(x=1, y=2, z=3), ), - private=None, ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_set_rail_lights.py b/api/tests/opentrons/protocol_engine/commands/test_set_rail_lights.py index 161fb2d3fcf..956473f264f 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_set_rail_lights.py +++ b/api/tests/opentrons/protocol_engine/commands/test_set_rail_lights.py @@ -26,6 +26,6 @@ async def test_set_rail_lights_implementation( result = await subject.execute(data) - assert result == SuccessData(public=SetRailLightsResult(), private=None) + assert result == SuccessData(public=SetRailLightsResult()) decoy.verify(await rail_lights.set_rail_lights(True), times=1) diff --git a/api/tests/opentrons/protocol_engine/commands/test_set_status_bar.py b/api/tests/opentrons/protocol_engine/commands/test_set_status_bar.py index 53652ce6b87..41ae6703c61 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_set_status_bar.py +++ b/api/tests/opentrons/protocol_engine/commands/test_set_status_bar.py @@ -35,7 +35,7 @@ async def test_status_bar_busy( result = await subject.execute(params=data) - assert result == SuccessData(public=SetStatusBarResult(), private=None) + assert result == SuccessData(public=SetStatusBarResult()) decoy.verify(await status_bar.set_status_bar(status=StatusBarState.OFF), times=0) @@ -63,6 +63,6 @@ async def test_set_status_bar_animation( data = SetStatusBarParams(animation=animation) result = await subject.execute(params=data) - assert result == SuccessData(public=SetStatusBarResult(), private=None) + assert result == SuccessData(public=SetStatusBarResult()) decoy.verify(await status_bar.set_status_bar(status=expected_state), times=1) diff --git a/api/tests/opentrons/protocol_engine/commands/test_touch_tip.py b/api/tests/opentrons/protocol_engine/commands/test_touch_tip.py index f18e79e0a55..d00f44fd108 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_touch_tip.py +++ b/api/tests/opentrons/protocol_engine/commands/test_touch_tip.py @@ -124,7 +124,6 @@ async def test_touch_tip_implementation( assert result == SuccessData( public=TouchTipResult(position=DeckPoint(x=4, y=5, z=6)), - private=None, state_update=update_types.StateUpdate( pipette_location=update_types.PipetteLocationUpdate( pipette_id="abc", diff --git a/api/tests/opentrons/protocol_engine/commands/test_verify_tip_presence.py b/api/tests/opentrons/protocol_engine/commands/test_verify_tip_presence.py index 087d924f0d2..53eb1f5a59e 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_verify_tip_presence.py +++ b/api/tests/opentrons/protocol_engine/commands/test_verify_tip_presence.py @@ -32,4 +32,4 @@ async def test_verify_tip_presence_implementation( result = await subject.execute(data) - assert result == SuccessData(public=VerifyTipPresenceResult(), private=None) + assert result == SuccessData(public=VerifyTipPresenceResult()) diff --git a/api/tests/opentrons/protocol_engine/commands/test_wait_for_duration.py b/api/tests/opentrons/protocol_engine/commands/test_wait_for_duration.py index 9d351ce00d3..bc535a4b6a1 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_wait_for_duration.py +++ b/api/tests/opentrons/protocol_engine/commands/test_wait_for_duration.py @@ -22,5 +22,5 @@ async def test_pause_implementation( result = await subject.execute(data) - assert result == SuccessData(public=WaitForDurationResult(), private=None) + assert result == SuccessData(public=WaitForDurationResult()) decoy.verify(await run_control.wait_for_duration(42.0), times=1) diff --git a/api/tests/opentrons/protocol_engine/commands/test_wait_for_resume.py b/api/tests/opentrons/protocol_engine/commands/test_wait_for_resume.py index 752b85d3446..7d4b3a32edd 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_wait_for_resume.py +++ b/api/tests/opentrons/protocol_engine/commands/test_wait_for_resume.py @@ -23,7 +23,7 @@ async def test_wait_for_resume_implementation( result = await subject.execute(data) - assert result == SuccessData(public=WaitForResumeResult(), private=None) + assert result == SuccessData(public=WaitForResumeResult()) decoy.verify(await run_control.wait_for_resume(), times=1) diff --git a/api/tests/opentrons/protocol_engine/commands/thermocycler/test_close_lid.py b/api/tests/opentrons/protocol_engine/commands/thermocycler/test_close_lid.py index 0a3fc6e9fdf..9eb5536632d 100644 --- a/api/tests/opentrons/protocol_engine/commands/thermocycler/test_close_lid.py +++ b/api/tests/opentrons/protocol_engine/commands/thermocycler/test_close_lid.py @@ -58,6 +58,5 @@ async def test_close_lid( ) assert result == SuccessData( public=expected_result, - private=None, state_update=update_types.StateUpdate(pipette_location=update_types.CLEAR), ) diff --git a/api/tests/opentrons/protocol_engine/commands/thermocycler/test_deactivate_block.py b/api/tests/opentrons/protocol_engine/commands/thermocycler/test_deactivate_block.py index 9f4ced905dc..676a11731a8 100644 --- a/api/tests/opentrons/protocol_engine/commands/thermocycler/test_deactivate_block.py +++ b/api/tests/opentrons/protocol_engine/commands/thermocycler/test_deactivate_block.py @@ -45,4 +45,4 @@ async def test_deactivate_block( result = await subject.execute(data) decoy.verify(await tc_hardware.deactivate_block(), times=1) - assert result == SuccessData(public=expected_result, private=None) + assert result == SuccessData(public=expected_result) diff --git a/api/tests/opentrons/protocol_engine/commands/thermocycler/test_deactivate_lid.py b/api/tests/opentrons/protocol_engine/commands/thermocycler/test_deactivate_lid.py index 1ea0e218c06..83fc347236b 100644 --- a/api/tests/opentrons/protocol_engine/commands/thermocycler/test_deactivate_lid.py +++ b/api/tests/opentrons/protocol_engine/commands/thermocycler/test_deactivate_lid.py @@ -45,4 +45,4 @@ async def test_deactivate_lid( result = await subject.execute(data) decoy.verify(await tc_hardware.deactivate_lid(), times=1) - assert result == SuccessData(public=expected_result, private=None) + assert result == SuccessData(public=expected_result) diff --git a/api/tests/opentrons/protocol_engine/commands/thermocycler/test_open_lid.py b/api/tests/opentrons/protocol_engine/commands/thermocycler/test_open_lid.py index a3e547a88d7..6c26b7d2877 100644 --- a/api/tests/opentrons/protocol_engine/commands/thermocycler/test_open_lid.py +++ b/api/tests/opentrons/protocol_engine/commands/thermocycler/test_open_lid.py @@ -56,6 +56,5 @@ async def test_open_lid( ) assert result == SuccessData( public=expected_result, - private=None, state_update=update_types.StateUpdate(pipette_location=update_types.CLEAR), ) diff --git a/api/tests/opentrons/protocol_engine/commands/thermocycler/test_run_extended_profile.py b/api/tests/opentrons/protocol_engine/commands/thermocycler/test_run_extended_profile.py index 9dcefceb9f1..a4ed38a0dbf 100644 --- a/api/tests/opentrons/protocol_engine/commands/thermocycler/test_run_extended_profile.py +++ b/api/tests/opentrons/protocol_engine/commands/thermocycler/test_run_extended_profile.py @@ -112,4 +112,4 @@ async def test_run_extended_profile( ), times=1, ) - assert result == SuccessData(public=expected_result, private=None) + assert result == SuccessData(public=expected_result) diff --git a/api/tests/opentrons/protocol_engine/commands/thermocycler/test_run_profile.py b/api/tests/opentrons/protocol_engine/commands/thermocycler/test_run_profile.py index 9d3b79c66b1..6d6234a76e6 100644 --- a/api/tests/opentrons/protocol_engine/commands/thermocycler/test_run_profile.py +++ b/api/tests/opentrons/protocol_engine/commands/thermocycler/test_run_profile.py @@ -75,4 +75,4 @@ async def test_run_profile( ), times=1, ) - assert result == SuccessData(public=expected_result, private=None) + assert result == SuccessData(public=expected_result) diff --git a/api/tests/opentrons/protocol_engine/commands/thermocycler/test_set_target_block_temperature.py b/api/tests/opentrons/protocol_engine/commands/thermocycler/test_set_target_block_temperature.py index f05ac55c0ee..49d6dda3ca9 100644 --- a/api/tests/opentrons/protocol_engine/commands/thermocycler/test_set_target_block_temperature.py +++ b/api/tests/opentrons/protocol_engine/commands/thermocycler/test_set_target_block_temperature.py @@ -67,4 +67,4 @@ async def test_set_target_block_temperature( ), times=1, ) - assert result == SuccessData(public=expected_result, private=None) + assert result == SuccessData(public=expected_result) diff --git a/api/tests/opentrons/protocol_engine/commands/thermocycler/test_set_target_lid_temperature.py b/api/tests/opentrons/protocol_engine/commands/thermocycler/test_set_target_lid_temperature.py index b3565bc8a2d..372ae6a814c 100644 --- a/api/tests/opentrons/protocol_engine/commands/thermocycler/test_set_target_lid_temperature.py +++ b/api/tests/opentrons/protocol_engine/commands/thermocycler/test_set_target_lid_temperature.py @@ -56,4 +56,4 @@ async def test_set_target_lid_temperature( result = await subject.execute(data) decoy.verify(await tc_hardware.set_target_lid_temperature(celsius=45.6), times=1) - assert result == SuccessData(public=expected_result, private=None) + assert result == SuccessData(public=expected_result) diff --git a/api/tests/opentrons/protocol_engine/commands/thermocycler/test_wait_for_block_temperature.py b/api/tests/opentrons/protocol_engine/commands/thermocycler/test_wait_for_block_temperature.py index 880729bc149..426724cf16f 100644 --- a/api/tests/opentrons/protocol_engine/commands/thermocycler/test_wait_for_block_temperature.py +++ b/api/tests/opentrons/protocol_engine/commands/thermocycler/test_wait_for_block_temperature.py @@ -51,4 +51,4 @@ async def test_set_target_block_temperature( tc_module_substate.get_target_block_temperature(), await tc_hardware.wait_for_block_target(), ) - assert result == SuccessData(public=expected_result, private=None) + assert result == SuccessData(public=expected_result) diff --git a/api/tests/opentrons/protocol_engine/commands/thermocycler/test_wait_for_lid_temperature.py b/api/tests/opentrons/protocol_engine/commands/thermocycler/test_wait_for_lid_temperature.py index 47b4b006342..e358e80d6f4 100644 --- a/api/tests/opentrons/protocol_engine/commands/thermocycler/test_wait_for_lid_temperature.py +++ b/api/tests/opentrons/protocol_engine/commands/thermocycler/test_wait_for_lid_temperature.py @@ -51,4 +51,4 @@ async def test_set_target_block_temperature( tc_module_substate.get_target_lid_temperature(), await tc_hardware.wait_for_lid_target(), ) - assert result == SuccessData(public=expected_result, private=None) + assert result == SuccessData(public=expected_result) diff --git a/api/tests/opentrons/protocol_engine/commands/unsafe/test_engage_axes.py b/api/tests/opentrons/protocol_engine/commands/unsafe/test_engage_axes.py index 0130d7ce16b..72fb761ad23 100644 --- a/api/tests/opentrons/protocol_engine/commands/unsafe/test_engage_axes.py +++ b/api/tests/opentrons/protocol_engine/commands/unsafe/test_engage_axes.py @@ -45,7 +45,7 @@ async def test_engage_axes_implementation( result = await subject.execute(data) - assert result == SuccessData(public=UnsafeEngageAxesResult(), private=None) + assert result == SuccessData(public=UnsafeEngageAxesResult()) decoy.verify( await ot3_hardware_api.engage_axes([Axis.Z_L, Axis.P_L, Axis.X, Axis.Y]), diff --git a/api/tests/opentrons/protocol_engine/commands/unsafe/test_ungrip_labware.py b/api/tests/opentrons/protocol_engine/commands/unsafe/test_ungrip_labware.py index 1a41244d556..a4eae34a08d 100644 --- a/api/tests/opentrons/protocol_engine/commands/unsafe/test_ungrip_labware.py +++ b/api/tests/opentrons/protocol_engine/commands/unsafe/test_ungrip_labware.py @@ -22,7 +22,7 @@ async def test_ungrip_labware_implementation( result = await subject.execute(params=UnsafeUngripLabwareParams()) - assert result == SuccessData(public=UnsafeUngripLabwareResult(), private=None) + assert result == SuccessData(public=UnsafeUngripLabwareResult()) decoy.verify( await ot3_hardware_api.ungrip(), diff --git a/api/tests/opentrons/protocol_engine/commands/unsafe/test_unsafe_blow_out_in_place.py b/api/tests/opentrons/protocol_engine/commands/unsafe/test_unsafe_blow_out_in_place.py index a40f914e049..88ad9a8ecf8 100644 --- a/api/tests/opentrons/protocol_engine/commands/unsafe/test_unsafe_blow_out_in_place.py +++ b/api/tests/opentrons/protocol_engine/commands/unsafe/test_unsafe_blow_out_in_place.py @@ -3,6 +3,7 @@ from opentrons.types import MountType from opentrons.protocol_engine.state.state import StateView +from opentrons.protocol_engine.state import update_types from opentrons.protocol_engine.commands.unsafe.unsafe_blow_out_in_place import ( UnsafeBlowOutInPlaceParams, UnsafeBlowOutInPlaceResult, @@ -41,7 +42,14 @@ async def test_blow_out_in_place_implementation( result = await subject.execute(data) - assert result == SuccessData(public=UnsafeBlowOutInPlaceResult(), private=None) + assert result == SuccessData( + public=UnsafeBlowOutInPlaceResult(), + state_update=update_types.StateUpdate( + pipette_aspirated_fluid=update_types.PipetteEmptyFluidUpdate( + pipette_id="pipette-id" + ) + ), + ) decoy.verify( await ot3_hardware_api.update_axis_position_estimations([Axis.P_L]), diff --git a/api/tests/opentrons/protocol_engine/commands/unsafe/test_unsafe_drop_tip_in_place.py b/api/tests/opentrons/protocol_engine/commands/unsafe/test_unsafe_drop_tip_in_place.py index 4913fe3c444..e7c684554c8 100644 --- a/api/tests/opentrons/protocol_engine/commands/unsafe/test_unsafe_drop_tip_in_place.py +++ b/api/tests/opentrons/protocol_engine/commands/unsafe/test_unsafe_drop_tip_in_place.py @@ -1,6 +1,7 @@ """Test unsafe drop tip in place commands.""" from opentrons.protocol_engine.state.update_types import ( PipetteTipStateUpdate, + PipetteUnknownFluidUpdate, StateUpdate, ) import pytest @@ -50,9 +51,11 @@ async def test_drop_tip_implementation( assert result == SuccessData( public=UnsafeDropTipInPlaceResult(), - private=None, state_update=StateUpdate( - pipette_tip_state=PipetteTipStateUpdate(pipette_id="abc", tip_geometry=None) + pipette_tip_state=PipetteTipStateUpdate( + pipette_id="abc", tip_geometry=None + ), + pipette_aspirated_fluid=PipetteUnknownFluidUpdate(pipette_id="abc"), ), ) diff --git a/api/tests/opentrons/protocol_engine/commands/unsafe/test_update_position_estimators.py b/api/tests/opentrons/protocol_engine/commands/unsafe/test_update_position_estimators.py index da7ffe75012..79131994299 100644 --- a/api/tests/opentrons/protocol_engine/commands/unsafe/test_update_position_estimators.py +++ b/api/tests/opentrons/protocol_engine/commands/unsafe/test_update_position_estimators.py @@ -45,7 +45,7 @@ async def test_update_position_estimators_implementation( result = await subject.execute(data) - assert result == SuccessData(public=UpdatePositionEstimatorsResult(), private=None) + assert result == SuccessData(public=UpdatePositionEstimatorsResult()) decoy.verify( await ot3_hardware_api.update_axis_position_estimations( diff --git a/api/tests/opentrons/protocol_engine/execution/test_command_executor.py b/api/tests/opentrons/protocol_engine/execution/test_command_executor.py index f5f0ec063b0..eb84ceb018b 100644 --- a/api/tests/opentrons/protocol_engine/execution/test_command_executor.py +++ b/api/tests/opentrons/protocol_engine/execution/test_command_executor.py @@ -220,7 +220,7 @@ class _TestCommandDefinedError(ErrorOccurrence): _TestCommandReturn = Union[ - SuccessData[_TestCommandResult, None], + SuccessData[_TestCommandResult], DefinedErrorData[_TestCommandDefinedError], ] @@ -263,7 +263,7 @@ class _TestCommand( _ImplementationCls: Type[_TestCommandImpl] = TestCommandImplCls command_params = _TestCommandParams() - command_result = SuccessData(public=_TestCommandResult(), private=None) + command_result = SuccessData(public=_TestCommandResult()) queued_command = cast( Command, @@ -362,9 +362,7 @@ class _TestCommand( decoy.verify( action_dispatcher.dispatch( - SucceedCommandAction( - private_result=None, command=expected_completed_command - ) + SucceedCommandAction(command=expected_completed_command) ), ) diff --git a/api/tests/opentrons/protocol_engine/execution/test_hardware_stopper.py b/api/tests/opentrons/protocol_engine/execution/test_hardware_stopper.py index 4c3e629d2ed..503d681bced 100644 --- a/api/tests/opentrons/protocol_engine/execution/test_hardware_stopper.py +++ b/api/tests/opentrons/protocol_engine/execution/test_hardware_stopper.py @@ -158,7 +158,7 @@ async def test_hardware_stopping_sequence_no_tip_drop( decoy.verify(await hardware_api.stop(home_after=False), times=1) decoy.verify( - await mock_tip_handler.add_tip( + mock_tip_handler.cache_tip( pipette_id="pipette-id", tip=TipGeometry(length=1.0, volume=2.0, diameter=3.0), ), @@ -181,7 +181,7 @@ async def test_hardware_stopping_sequence_no_pipette( ) decoy.when( - await mock_tip_handler.add_tip( + mock_tip_handler.cache_tip( pipette_id="pipette-id", tip=TipGeometry(length=1.0, volume=2.0, diameter=3.0), ), @@ -271,7 +271,7 @@ async def test_hardware_stopping_sequence_with_fixed_trash( await movement.home( axes=[MotorAxis.X, MotorAxis.Y, MotorAxis.LEFT_Z, MotorAxis.RIGHT_Z] ), - await mock_tip_handler.add_tip( + mock_tip_handler.cache_tip( pipette_id="pipette-id", tip=TipGeometry(length=1.0, volume=2.0, diameter=3.0), ), @@ -320,7 +320,7 @@ async def test_hardware_stopping_sequence_with_OT2_addressable_area( await movement.home( axes=[MotorAxis.X, MotorAxis.Y, MotorAxis.LEFT_Z, MotorAxis.RIGHT_Z] ), - await mock_tip_handler.add_tip( + mock_tip_handler.cache_tip( pipette_id="pipette-id", tip=TipGeometry(length=1.0, volume=2.0, diameter=3.0), ), diff --git a/api/tests/opentrons/protocol_engine/execution/test_tip_handler.py b/api/tests/opentrons/protocol_engine/execution/test_tip_handler.py index af5c49faf6a..c03a611966c 100644 --- a/api/tests/opentrons/protocol_engine/execution/test_tip_handler.py +++ b/api/tests/opentrons/protocol_engine/execution/test_tip_handler.py @@ -266,7 +266,7 @@ async def test_drop_tip( ) -async def test_add_tip( +def test_add_tip( decoy: Decoy, mock_state_view: StateView, mock_hardware_api: HardwareAPI, @@ -289,10 +289,10 @@ async def test_add_tip( MountType.LEFT ) - await subject.add_tip(pipette_id="pipette-id", tip=tip) + subject.cache_tip(pipette_id="pipette-id", tip=tip) decoy.verify( - mock_hardware_api.add_tip(mount=Mount.LEFT, tip_length=50), + mock_hardware_api.cache_tip(mount=Mount.LEFT, tip_length=50), mock_hardware_api.set_current_tiprack_diameter( mount=Mount.LEFT, tiprack_diameter=5, @@ -301,6 +301,31 @@ async def test_add_tip( ) +def test_remove_tip( + decoy: Decoy, + mock_state_view: StateView, + mock_hardware_api: HardwareAPI, + mock_labware_data_provider: LabwareDataProvider, +) -> None: + """It should remove a tip manually from the hardware API.""" + subject = HardwareTipHandler( + state_view=mock_state_view, + hardware_api=mock_hardware_api, + labware_data_provider=mock_labware_data_provider, + ) + + decoy.when(mock_state_view.pipettes.get_mount("pipette-id")).then_return( + MountType.LEFT + ) + + subject.remove_tip(pipette_id="pipette-id") + + decoy.verify( + mock_hardware_api.remove_tip(Mount.LEFT), + mock_hardware_api.set_current_tiprack_diameter(Mount.LEFT, 0), + ) + + @pytest.mark.parametrize( argnames=[ "test_channels", diff --git a/api/tests/opentrons/protocol_engine/state/command_fixtures.py b/api/tests/opentrons/protocol_engine/state/command_fixtures.py index 9c4665d31a2..5ac522095f2 100644 --- a/api/tests/opentrons/protocol_engine/state/command_fixtures.py +++ b/api/tests/opentrons/protocol_engine/state/command_fixtures.py @@ -1,7 +1,7 @@ """Command factories to use in tests as data fixtures.""" from datetime import datetime from pydantic import BaseModel -from typing import Optional, cast +from typing import Optional, cast, Dict from opentrons_shared_data.pipette.types import PipetteNameType from opentrons.types import MountType @@ -338,6 +338,29 @@ def create_liquid_probe_command( ) +def create_load_liquid_command( + liquid_id: str = "liquid-id", + labware_id: str = "labware-id", + volume_by_well: Dict[str, float] = {"A1": 30, "B2": 100}, +) -> cmd.LoadLiquid: + """Get a completed Load Liquid command.""" + params = cmd.LoadLiquidParams( + liquidId=liquid_id, + labwareId=labware_id, + volumeByWell=volume_by_well, + ) + result = cmd.LoadLiquidResult() + + return cmd.LoadLiquid( + id="command-id", + key="command-key", + status=cmd.CommandStatus.SUCCEEDED, + createdAt=datetime.now(), + params=params, + result=result, + ) + + def create_pick_up_tip_command( pipette_id: str, labware_id: str = "labware-id", diff --git a/api/tests/opentrons/protocol_engine/state/test_addressable_area_store.py b/api/tests/opentrons/protocol_engine/state/test_addressable_area_store.py index b259e6a3f96..44c72e38e86 100644 --- a/api/tests/opentrons/protocol_engine/state/test_addressable_area_store.py +++ b/api/tests/opentrons/protocol_engine/state/test_addressable_area_store.py @@ -234,9 +234,7 @@ def test_addressable_area_referencing_commands_load_on_simulated_deck( simulated_subject: AddressableAreaStore, ) -> None: """It should check and store the addressable area when referenced in a command.""" - simulated_subject.handle_action( - SucceedCommandAction(private_result=None, command=command) - ) + simulated_subject.handle_action(SucceedCommandAction(command=command)) assert expected_area in simulated_subject.state.loaded_addressable_areas_by_name @@ -301,7 +299,7 @@ def test_addressable_area_referencing_commands_load( subject: AddressableAreaStore, ) -> None: """It should check that the addressable area is in the deck config.""" - subject.handle_action(SucceedCommandAction(private_result=None, command=command)) + subject.handle_action(SucceedCommandAction(command=command)) assert expected_area in subject.state.loaded_addressable_areas_by_name diff --git a/api/tests/opentrons/protocol_engine/state/test_command_state.py b/api/tests/opentrons/protocol_engine/state/test_command_state.py index 6f090612a74..fde0d66e654 100644 --- a/api/tests/opentrons/protocol_engine/state/test_command_state.py +++ b/api/tests/opentrons/protocol_engine/state/test_command_state.py @@ -19,7 +19,7 @@ PlayAction, SetErrorRecoveryPolicyAction, ) -from opentrons.protocol_engine.commands.command import CommandIntent +from opentrons.protocol_engine.commands.command import CommandIntent, DefinedErrorData from opentrons.protocol_engine.error_recovery_policy import ErrorRecoveryType from opentrons.protocol_engine.errors.error_occurrence import ErrorOccurrence from opentrons.protocol_engine.errors.exceptions import ( @@ -32,6 +32,7 @@ CommandView, ) from opentrons.protocol_engine.state.config import Config +from opentrons.protocol_engine.state.update_types import StateUpdate from opentrons.protocol_engine.types import DeckType, EngineStatus @@ -772,7 +773,7 @@ def test_recovery_target_tracking() -> None: assert recovery_target.command_id == "c1" assert subject_view.get_recovery_in_progress_for_command("c1") - resume_from_1_recovery = actions.ResumeFromRecoveryAction() + resume_from_1_recovery = actions.ResumeFromRecoveryAction(StateUpdate()) subject.handle_action(resume_from_1_recovery) # c1 failed recoverably, but we've already completed its recovery. @@ -808,7 +809,7 @@ def test_recovery_target_tracking() -> None: # even though it failed recoverably before. assert not subject_view.get_recovery_in_progress_for_command("c1") - resume_from_2_recovery = actions.ResumeFromRecoveryAction() + resume_from_2_recovery = actions.ResumeFromRecoveryAction(StateUpdate()) subject.handle_action(resume_from_2_recovery) queue_3 = actions.QueueCommandAction( "c3", @@ -837,6 +838,58 @@ def test_recovery_target_tracking() -> None: assert subject_view.get_has_entered_recovery_mode() is True +@pytest.mark.parametrize( + "ending_action", + [ + actions.StopAction(from_estop=False), + actions.StopAction(from_estop=True), + actions.FinishAction(set_run_status=False), + actions.FinishAction( + set_run_status=True, + error_details=actions.FinishErrorDetails( + error=Exception("blimey"), + error_id="error-id", + created_at=datetime.now(), + ), + ), + ], +) +def test_recovery_target_clears_when_run_ends(ending_action: actions.Action) -> None: + """There should never be an error recovery target when the run is done.""" + subject = CommandStore( + config=_make_config(), + error_recovery_policy=_placeholder_error_recovery_policy, + is_door_open=False, + ) + subject_view = CommandView(subject.state) + + # Setup: Put the run in error recovery mode. + queue = actions.QueueCommandAction( + "c1", + created_at=datetime.now(), + request=commands.CommentCreate(params=commands.CommentParams(message="")), + request_hash=None, + ) + subject.handle_action(queue) + run = actions.RunCommandAction(command_id="c1", started_at=datetime.now()) + subject.handle_action(run) + fail = actions.FailCommandAction( + command_id="c1", + error_id="c1-error", + failed_at=datetime.now(), + error=PythonException(RuntimeError()), + notes=[], + type=ErrorRecoveryType.WAIT_FOR_RECOVERY, + running_command=subject_view.get("c1"), + ) + subject.handle_action(fail) + + # Test: Assert that the ending action clears the recovery target. + assert subject_view.get_recovery_target() is not None + subject.handle_action(ending_action) + assert subject_view.get_recovery_target() is None + + def test_final_state_after_estop() -> None: """Test the final state of the run after it's E-stopped.""" subject = CommandStore( @@ -993,3 +1046,57 @@ def test_set_and_get_error_recovery_policy() -> None: assert subject_view.get_error_recovery_policy() is initial_policy subject.handle_action(SetErrorRecoveryPolicyAction(sentinel.new_policy)) assert subject_view.get_error_recovery_policy() is new_policy + + +def test_get_state_update_for_false_positive() -> None: + """Test storage of false-positive state updates.""" + subject = CommandStore( + config=_make_config(), + error_recovery_policy=_placeholder_error_recovery_policy, + is_door_open=False, + ) + subject_view = CommandView(subject.state) + + empty_state_update = StateUpdate() + + assert subject_view.get_state_update_for_false_positive() == empty_state_update + + queue = actions.QueueCommandAction( + request=commands.CommentCreate( + params=commands.CommentParams(message=""), key="command-key-1" + ), + request_hash=None, + created_at=datetime(year=2021, month=1, day=1), + command_id="command-id-1", + ) + subject.handle_action(queue) + run = actions.RunCommandAction( + command_id="command-id-1", + started_at=datetime(year=2022, month=2, day=2), + ) + subject.handle_action(run) + fail = actions.FailCommandAction( + command_id="command-id-1", + running_command=subject_view.get("command-id-1"), + error_id="error-id", + failed_at=datetime(year=2023, month=3, day=3), + error=DefinedErrorData( + public=sentinel.public, + state_update_if_false_positive=sentinel.state_update_if_false_positive, + ), + type=ErrorRecoveryType.WAIT_FOR_RECOVERY, + notes=[], + ) + subject.handle_action(fail) + + assert ( + subject_view.get_state_update_for_false_positive() + == sentinel.state_update_if_false_positive + ) + + resume_from_recovery = actions.ResumeFromRecoveryAction( + state_update=sentinel.some_other_state_update + ) + subject.handle_action(resume_from_recovery) + + assert subject_view.get_state_update_for_false_positive() == empty_state_update diff --git a/api/tests/opentrons/protocol_engine/state/test_command_store_old.py b/api/tests/opentrons/protocol_engine/state/test_command_store_old.py index 4b7cf01e87c..d5f171b7ea9 100644 --- a/api/tests/opentrons/protocol_engine/state/test_command_store_old.py +++ b/api/tests/opentrons/protocol_engine/state/test_command_store_old.py @@ -84,7 +84,6 @@ def test_command_queue_and_unqueue() -> None: started_at=datetime(year=2022, month=2, day=2), ) succeed_2 = SucceedCommandAction( - private_result=None, command=create_succeeded_command(command_id="command-id-2"), ) @@ -137,7 +136,6 @@ def test_setup_command_queue_and_unqueue() -> None: command_id="command-id-2", started_at=datetime(year=2022, month=2, day=2) ) succeed_2 = SucceedCommandAction( - private_result=None, command=create_succeeded_command(command_id="command-id-2"), ) @@ -214,7 +212,6 @@ def test_running_command_id() -> None: started_at=datetime(year=2021, month=1, day=1), ) succeed = SucceedCommandAction( - private_result=None, command=create_succeeded_command(command_id="command-id-1"), ) @@ -303,7 +300,6 @@ def test_command_store_keeps_commands_in_queue_order() -> None: command=create_succeeded_command( command_id="command-id-2", ), - private_result=None, ) ) assert subject.state.command_history.get_all_ids() == [ @@ -334,7 +330,7 @@ def test_command_store_handles_pause_action(pause_source: PauseSource) -> None: finish_error=None, failed_command=None, command_error_recovery_types={}, - recovery_target_command_id=None, + recovery_target=None, latest_protocol_command_hash=None, stopped_by_estop=False, failed_command_errors=[], @@ -363,7 +359,7 @@ def test_command_store_handles_play_action(pause_source: PauseSource) -> None: finish_error=None, failed_command=None, command_error_recovery_types={}, - recovery_target_command_id=None, + recovery_target=None, run_started_at=datetime(year=2021, month=1, day=1), latest_protocol_command_hash=None, stopped_by_estop=False, @@ -398,7 +394,7 @@ def test_command_store_handles_finish_action() -> None: finish_error=None, failed_command=None, command_error_recovery_types={}, - recovery_target_command_id=None, + recovery_target=None, run_started_at=datetime(year=2021, month=1, day=1), latest_protocol_command_hash=None, stopped_by_estop=False, @@ -453,7 +449,7 @@ def test_command_store_handles_stop_action( finish_error=None, failed_command=None, command_error_recovery_types={}, - recovery_target_command_id=None, + recovery_target=None, run_started_at=datetime(year=2021, month=1, day=1), latest_protocol_command_hash=None, stopped_by_estop=from_estop, @@ -491,7 +487,7 @@ def test_command_store_handles_stop_action_when_awaiting_recovery() -> None: finish_error=None, failed_command=None, command_error_recovery_types={}, - recovery_target_command_id=None, + recovery_target=None, run_started_at=datetime(year=2021, month=1, day=1), latest_protocol_command_hash=None, stopped_by_estop=False, @@ -525,7 +521,7 @@ def test_command_store_cannot_restart_after_should_stop() -> None: finish_error=None, failed_command=None, command_error_recovery_types={}, - recovery_target_command_id=None, + recovery_target=None, run_started_at=None, latest_protocol_command_hash=None, stopped_by_estop=False, @@ -673,7 +669,7 @@ def test_command_store_wraps_unknown_errors() -> None: run_started_at=None, failed_command=None, command_error_recovery_types={}, - recovery_target_command_id=None, + recovery_target=None, latest_protocol_command_hash=None, stopped_by_estop=False, failed_command_errors=[], @@ -742,7 +738,7 @@ def __init__(self, message: str) -> None: ), failed_command=None, command_error_recovery_types={}, - recovery_target_command_id=None, + recovery_target=None, run_started_at=None, latest_protocol_command_hash=None, stopped_by_estop=False, @@ -778,7 +774,7 @@ def test_command_store_ignores_stop_after_graceful_finish() -> None: finish_error=None, failed_command=None, command_error_recovery_types={}, - recovery_target_command_id=None, + recovery_target=None, run_started_at=datetime(year=2021, month=1, day=1), latest_protocol_command_hash=None, stopped_by_estop=False, @@ -814,7 +810,7 @@ def test_command_store_ignores_finish_after_non_graceful_stop() -> None: finish_error=None, failed_command=None, command_error_recovery_types={}, - recovery_target_command_id=None, + recovery_target=None, run_started_at=datetime(year=2021, month=1, day=1), latest_protocol_command_hash=None, stopped_by_estop=False, @@ -850,7 +846,7 @@ def test_handles_hardware_stopped() -> None: finish_error=None, failed_command=None, command_error_recovery_types={}, - recovery_target_command_id=None, + recovery_target=None, run_started_at=None, latest_protocol_command_hash=None, stopped_by_estop=False, diff --git a/api/tests/opentrons/protocol_engine/state/test_command_view_old.py b/api/tests/opentrons/protocol_engine/state/test_command_view_old.py index 06318cb8d36..f7b1d6cd31f 100644 --- a/api/tests/opentrons/protocol_engine/state/test_command_view_old.py +++ b/api/tests/opentrons/protocol_engine/state/test_command_view_old.py @@ -22,6 +22,9 @@ from opentrons.protocol_engine.error_recovery_policy import ErrorRecoveryType from opentrons.protocol_engine.state.commands import ( + # todo(mm, 2024-10-24): Avoid testing internal implementation details like + # _RecoveryTargetInfo. See note above about porting to test_command_state.py. + _RecoveryTargetInfo, CommandState, CommandView, CommandSlice, @@ -38,6 +41,7 @@ from opentrons_shared_data.errors.codes import ErrorCodes from opentrons.protocol_engine.state.command_history import CommandHistory +from opentrons.protocol_engine.state.update_types import StateUpdate from .command_fixtures import ( create_queued_command, @@ -108,7 +112,12 @@ def get_command_view( # noqa: C901 finish_error=finish_error, failed_command=failed_command, command_error_recovery_types=command_error_recovery_types or {}, - recovery_target_command_id=recovery_target_command_id, + recovery_target=_RecoveryTargetInfo( + command_id=recovery_target_command_id, + state_update_if_false_positive=StateUpdate(), + ) + if recovery_target_command_id is not None + else None, run_started_at=run_started_at, latest_protocol_command_hash=latest_command_hash, stopped_by_estop=False, @@ -592,7 +601,7 @@ class ActionAllowedSpec(NamedTuple): ), ), ), - action=ResumeFromRecoveryAction(), + action=ResumeFromRecoveryAction(StateUpdate()), expected_error=errors.ResumeFromRecoveryNotAllowedError, ), ActionAllowedSpec( diff --git a/api/tests/opentrons/protocol_engine/state/test_fluid_stack.py b/api/tests/opentrons/protocol_engine/state/test_fluid_stack.py new file mode 100644 index 00000000000..e958b92036d --- /dev/null +++ b/api/tests/opentrons/protocol_engine/state/test_fluid_stack.py @@ -0,0 +1,219 @@ +"""Test pipette internal fluid tracking.""" +import pytest + +from opentrons.protocol_engine.state.fluid_stack import FluidStack +from opentrons.protocol_engine.types import AspiratedFluid, FluidKind + + +@pytest.mark.parametrize( + "fluids,resulting_stack", + [ + ( + [ + AspiratedFluid(FluidKind.LIQUID, 10), + AspiratedFluid(FluidKind.LIQUID, 10), + ], + [AspiratedFluid(FluidKind.LIQUID, 20)], + ), + ( + [AspiratedFluid(FluidKind.AIR, 10), AspiratedFluid(FluidKind.LIQUID, 20)], + [AspiratedFluid(FluidKind.AIR, 10), AspiratedFluid(FluidKind.LIQUID, 20)], + ), + ( + [ + AspiratedFluid(FluidKind.AIR, 10), + AspiratedFluid(FluidKind.LIQUID, 20), + AspiratedFluid(FluidKind.LIQUID, 10), + AspiratedFluid(FluidKind.AIR, 20), + ], + [ + AspiratedFluid(FluidKind.AIR, 10), + AspiratedFluid(FluidKind.LIQUID, 30), + AspiratedFluid(FluidKind.AIR, 20), + ], + ), + ], +) +def test_add_fluid( + fluids: list[AspiratedFluid], resulting_stack: list[AspiratedFluid] +) -> None: + """It should add fluids.""" + stack = FluidStack() + for fluid in fluids: + stack.add_fluid(fluid) + assert stack._fluid_stack == resulting_stack + + +@pytest.mark.parametrize( + "starting_fluids,remove_volume,resulting_stack", + [ + ([], 1, []), + ([], 0, []), + ( + [AspiratedFluid(FluidKind.LIQUID, 10)], + 0, + [AspiratedFluid(FluidKind.LIQUID, 10)], + ), + ( + [AspiratedFluid(FluidKind.LIQUID, 10)], + 5, + [AspiratedFluid(FluidKind.LIQUID, 5)], + ), + ([AspiratedFluid(FluidKind.LIQUID, 10)], 11, []), + ( + [AspiratedFluid(FluidKind.LIQUID, 10), AspiratedFluid(FluidKind.AIR, 10)], + 11, + [AspiratedFluid(FluidKind.LIQUID, 9)], + ), + ( + [AspiratedFluid(FluidKind.LIQUID, 10), AspiratedFluid(FluidKind.AIR, 10)], + 20, + [], + ), + ( + [ + AspiratedFluid(FluidKind.LIQUID, 10), + AspiratedFluid(FluidKind.AIR, 10), + AspiratedFluid(FluidKind.LIQUID, 10), + ], + 28, + [AspiratedFluid(FluidKind.LIQUID, 2)], + ), + ], +) +def test_remove_fluid( + starting_fluids: list[AspiratedFluid], + remove_volume: float, + resulting_stack: list[AspiratedFluid], +) -> None: + """It should remove fluids.""" + stack = FluidStack(_fluid_stack=[f for f in starting_fluids]) + stack.remove_fluid(remove_volume) + assert stack._fluid_stack == resulting_stack + + +@pytest.mark.parametrize( + "starting_fluids,filter,result", + [ + ([], None, 0), + ([], FluidKind.LIQUID, 0), + ([], FluidKind.AIR, 0), + ([AspiratedFluid(FluidKind.LIQUID, 10)], None, 10), + ([AspiratedFluid(FluidKind.LIQUID, 10)], FluidKind.LIQUID, 10), + ([AspiratedFluid(FluidKind.LIQUID, 10)], FluidKind.AIR, 0), + ( + [AspiratedFluid(FluidKind.LIQUID, 10), AspiratedFluid(FluidKind.AIR, 10)], + None, + 20, + ), + ( + [AspiratedFluid(FluidKind.LIQUID, 10), AspiratedFluid(FluidKind.AIR, 10)], + FluidKind.LIQUID, + 10, + ), + ( + [AspiratedFluid(FluidKind.LIQUID, 10), AspiratedFluid(FluidKind.AIR, 10)], + FluidKind.AIR, + 10, + ), + ], +) +def test_aspirated_volume( + starting_fluids: list[AspiratedFluid], filter: FluidKind | None, result: float +) -> None: + """It should represent aspirated volume with filtering.""" + stack = FluidStack(_fluid_stack=starting_fluids) + assert stack.aspirated_volume(kind=filter) == result + + +@pytest.mark.parametrize( + "starting_fluids,dispense_volume,result", + [ + ([], 0, 0), + ([], 1, 0), + ([AspiratedFluid(FluidKind.AIR, 10)], 10, 0), + ([AspiratedFluid(FluidKind.AIR, 10)], 0, 0), + ([AspiratedFluid(FluidKind.LIQUID, 10)], 10, 10), + ([AspiratedFluid(FluidKind.LIQUID, 10)], 0, 0), + ( + [ + AspiratedFluid(FluidKind.AIR, 10), + AspiratedFluid(FluidKind.LIQUID, 10), + ], + 10, + 10, + ), + ( + [ + AspiratedFluid(FluidKind.AIR, 10), + AspiratedFluid(FluidKind.LIQUID, 10), + ], + 20, + 10, + ), + ( + [ + AspiratedFluid(FluidKind.AIR, 10), + AspiratedFluid(FluidKind.LIQUID, 10), + ], + 30, + 10, + ), + ( + [ + AspiratedFluid(FluidKind.AIR, 10), + AspiratedFluid(FluidKind.LIQUID, 10), + ], + 5, + 5, + ), + ( + [ + AspiratedFluid(FluidKind.LIQUID, 10), + AspiratedFluid(FluidKind.AIR, 10), + ], + 5, + 0, + ), + ( + [ + AspiratedFluid(FluidKind.LIQUID, 10), + AspiratedFluid(FluidKind.AIR, 10), + ], + 10, + 0, + ), + ( + [ + AspiratedFluid(FluidKind.LIQUID, 10), + AspiratedFluid(FluidKind.AIR, 10), + ], + 11, + 1, + ), + ( + [ + AspiratedFluid(FluidKind.LIQUID, 10), + AspiratedFluid(FluidKind.AIR, 10), + ], + 20, + 10, + ), + ( + [ + AspiratedFluid(FluidKind.LIQUID, 10), + AspiratedFluid(FluidKind.AIR, 10), + ], + 30, + 10, + ), + ], +) +def test_liquid_part_of_dispense_volume( + starting_fluids: list[AspiratedFluid], + dispense_volume: float, + result: float, +) -> None: + """It should predict resulting liquid from a dispense.""" + stack = FluidStack(_fluid_stack=starting_fluids) + assert stack.liquid_part_of_dispense_volume(dispense_volume) == result diff --git a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py index 7a94f06ca09..3f7ad59bda2 100644 --- a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py @@ -55,6 +55,9 @@ LoadedPipette, TipGeometry, ModuleDefinition, + ProbedHeightInfo, + LoadedVolumeInfo, + WellLiquidInfo, ) from opentrons.protocol_engine.commands import ( CommandStatus, @@ -1539,9 +1542,13 @@ def test_get_well_position_with_meniscus_offset( decoy.when(mock_labware_view.get_well_definition("labware-id", "B2")).then_return( well_def ) - decoy.when( - mock_well_view.get_last_measured_liquid_height("labware-id", "B2") - ).then_return(70.5) + decoy.when(mock_well_view.get_well_liquid_info("labware-id", "B2")).then_return( + WellLiquidInfo( + probed_volume=None, + probed_height=ProbedHeightInfo(height=70.5, last_probed=datetime.now()), + loaded_volume=None, + ) + ) decoy.when( mock_pipette_view.get_current_tip_lld_settings(pipette_id="pipette-id") ).then_return(0.5) @@ -1563,6 +1570,68 @@ def test_get_well_position_with_meniscus_offset( ) +def test_get_well_position_with_volume_offset_raises_error( + decoy: Decoy, + well_plate_def: LabwareDefinition, + mock_labware_view: LabwareView, + mock_well_view: WellView, + mock_addressable_area_view: AddressableAreaView, + mock_pipette_view: PipetteView, + subject: GeometryView, +) -> None: + """Calling get_well_position with any volume offset should raise an error when there's no innerLabwareGeometry.""" + labware_data = LoadedLabware( + id="labware-id", + loadName="load-name", + definitionUri="definition-uri", + location=DeckSlotLocation(slotName=DeckSlotName.SLOT_4), + offsetId="offset-id", + ) + calibration_offset = LabwareOffsetVector(x=1, y=-2, z=3) + slot_pos = Point(4, 5, 6) + well_def = well_plate_def.wells["B2"] + + decoy.when(mock_labware_view.get("labware-id")).then_return(labware_data) + decoy.when(mock_labware_view.get_definition("labware-id")).then_return( + well_plate_def + ) + decoy.when(mock_labware_view.get_labware_offset_vector("labware-id")).then_return( + calibration_offset + ) + decoy.when( + mock_addressable_area_view.get_addressable_area_position(DeckSlotName.SLOT_4.id) + ).then_return(slot_pos) + decoy.when(mock_labware_view.get_well_definition("labware-id", "B2")).then_return( + well_def + ) + decoy.when(mock_well_view.get_well_liquid_info("labware-id", "B2")).then_return( + WellLiquidInfo( + loaded_volume=None, + probed_height=ProbedHeightInfo(height=45.0, last_probed=datetime.now()), + probed_volume=None, + ) + ) + decoy.when( + mock_pipette_view.get_current_tip_lld_settings(pipette_id="pipette-id") + ).then_return(0.5) + decoy.when(mock_labware_view.get_well_geometry("labware-id", "B2")).then_raise( + errors.IncompleteLabwareDefinitionError("Woops!") + ) + + with pytest.raises(errors.IncompleteLabwareDefinitionError): + subject.get_well_position( + labware_id="labware-id", + well_name="B2", + well_location=LiquidHandlingWellLocation( + origin=WellOrigin.MENISCUS, + offset=WellOffset(x=2, y=3, z=4), + volumeOffset="operationVolume", + ), + operation_volume=-1245.833, + pipette_id="pipette-id", + ) + + def test_get_well_position_with_meniscus_and_literal_volume_offset( decoy: Decoy, well_plate_def: LabwareDefinition, @@ -1597,9 +1666,13 @@ def test_get_well_position_with_meniscus_and_literal_volume_offset( decoy.when(mock_labware_view.get_well_definition("labware-id", "B2")).then_return( well_def ) - decoy.when( - mock_well_view.get_last_measured_liquid_height("labware-id", "B2") - ).then_return(45.0) + decoy.when(mock_well_view.get_well_liquid_info("labware-id", "B2")).then_return( + WellLiquidInfo( + loaded_volume=None, + probed_height=ProbedHeightInfo(height=45.0, last_probed=datetime.now()), + probed_volume=None, + ) + ) labware_def = _load_labware_definition_data() assert labware_def.innerLabwareGeometry is not None inner_well_def = labware_def.innerLabwareGeometry["welldefinition1111"] @@ -1663,9 +1736,13 @@ def test_get_well_position_with_meniscus_and_float_volume_offset( decoy.when(mock_labware_view.get_well_definition("labware-id", "B2")).then_return( well_def ) - decoy.when( - mock_well_view.get_last_measured_liquid_height("labware-id", "B2") - ).then_return(45.0) + decoy.when(mock_well_view.get_well_liquid_info("labware-id", "B2")).then_return( + WellLiquidInfo( + loaded_volume=None, + probed_height=ProbedHeightInfo(height=45.0, last_probed=datetime.now()), + probed_volume=None, + ) + ) labware_def = _load_labware_definition_data() assert labware_def.innerLabwareGeometry is not None inner_well_def = labware_def.innerLabwareGeometry["welldefinition1111"] @@ -1728,9 +1805,13 @@ def test_get_well_position_raises_validation_error( decoy.when(mock_labware_view.get_well_definition("labware-id", "B2")).then_return( well_def ) - decoy.when( - mock_well_view.get_last_measured_liquid_height("labware-id", "B2") - ).then_return(40.0) + decoy.when(mock_well_view.get_well_liquid_info("labware-id", "B2")).then_return( + WellLiquidInfo( + loaded_volume=None, + probed_height=ProbedHeightInfo(height=40.0, last_probed=datetime.now()), + probed_volume=None, + ) + ) labware_def = _load_labware_definition_data() assert labware_def.innerLabwareGeometry is not None inner_well_def = labware_def.innerLabwareGeometry["welldefinition1111"] @@ -1755,6 +1836,76 @@ def test_get_well_position_raises_validation_error( ) +def test_get_meniscus_height( + decoy: Decoy, + well_plate_def: LabwareDefinition, + mock_labware_view: LabwareView, + mock_well_view: WellView, + mock_addressable_area_view: AddressableAreaView, + mock_pipette_view: PipetteView, + subject: GeometryView, +) -> None: + """It should be able to get the position of a well meniscus in a labware.""" + labware_data = LoadedLabware( + id="labware-id", + loadName="load-name", + definitionUri="definition-uri", + location=DeckSlotLocation(slotName=DeckSlotName.SLOT_4), + offsetId="offset-id", + ) + calibration_offset = LabwareOffsetVector(x=1, y=-2, z=3) + slot_pos = Point(4, 5, 6) + well_def = well_plate_def.wells["B2"] + + decoy.when(mock_labware_view.get("labware-id")).then_return(labware_data) + decoy.when(mock_labware_view.get_definition("labware-id")).then_return( + well_plate_def + ) + decoy.when(mock_labware_view.get_labware_offset_vector("labware-id")).then_return( + calibration_offset + ) + decoy.when( + mock_addressable_area_view.get_addressable_area_position(DeckSlotName.SLOT_4.id) + ).then_return(slot_pos) + decoy.when(mock_labware_view.get_well_definition("labware-id", "B2")).then_return( + well_def + ) + decoy.when(mock_well_view.get_well_liquid_info("labware-id", "B2")).then_return( + WellLiquidInfo( + loaded_volume=LoadedVolumeInfo( + volume=2000.0, last_loaded=datetime.now(), operations_since_load=0 + ), + probed_height=None, + probed_volume=None, + ) + ) + labware_def = _load_labware_definition_data() + assert labware_def.innerLabwareGeometry is not None + inner_well_def = labware_def.innerLabwareGeometry["welldefinition1111"] + decoy.when(mock_labware_view.get_well_geometry("labware-id", "B2")).then_return( + inner_well_def + ) + decoy.when( + mock_pipette_view.get_current_tip_lld_settings(pipette_id="pipette-id") + ).then_return(0.5) + + result = subject.get_well_position( + labware_id="labware-id", + well_name="B2", + well_location=WellLocation( + origin=WellOrigin.MENISCUS, + offset=WellOffset(x=2, y=3, z=4), + ), + pipette_id="pipette-id", + ) + + assert result == Point( + x=slot_pos[0] + 1 + well_def.x + 2, + y=slot_pos[1] - 2 + well_def.y + 3, + z=slot_pos[2] + 3 + well_def.z + 4 + 39.2423, + ) + + def test_get_relative_well_location( decoy: Decoy, well_plate_def: LabwareDefinition, @@ -2786,7 +2937,6 @@ def test_get_offset_location_deck_slot( version=nice_labware_definition.version, ), ), - private_result=None, state_update=StateUpdate( loaded_labware=LoadedLabwareUpdate( labware_id="labware-id-1", @@ -2831,7 +2981,6 @@ def test_get_offset_location_module( model=tempdeck_v2_def.model, ), ), - private_result=None, ) load_labware = SucceedCommandAction( command=LoadLabware( @@ -2851,7 +3000,6 @@ def test_get_offset_location_module( version=nice_labware_definition.version, ), ), - private_result=None, state_update=StateUpdate( loaded_labware=LoadedLabwareUpdate( labware_id="labware-id-1", @@ -2900,7 +3048,6 @@ def test_get_offset_location_module_with_adapter( model=tempdeck_v2_def.model, ), ), - private_result=None, ) load_adapter = SucceedCommandAction( command=LoadLabware( @@ -2920,7 +3067,6 @@ def test_get_offset_location_module_with_adapter( version=nice_adapter_definition.version, ), ), - private_result=None, state_update=StateUpdate( loaded_labware=LoadedLabwareUpdate( labware_id="adapter-id-1", @@ -2949,7 +3095,6 @@ def test_get_offset_location_module_with_adapter( version=nice_labware_definition.version, ), ), - private_result=None, state_update=StateUpdate( loaded_labware=LoadedLabwareUpdate( labware_id="labware-id-1", @@ -2998,7 +3143,6 @@ def test_get_offset_fails_with_off_deck_labware( version=nice_labware_definition.version, ), ), - private_result=None, state_update=StateUpdate( loaded_labware=LoadedLabwareUpdate( labware_id="labware-id-1", @@ -3133,9 +3277,13 @@ def test_validate_dispense_volume_into_well_meniscus( decoy.when(mock_labware_view.get_well_geometry("labware-id", "A1")).then_return( inner_well_def ) - decoy.when( - mock_well_view.get_last_measured_liquid_height("labware-id", "A1") - ).then_return(40.0) + decoy.when(mock_well_view.get_well_liquid_info("labware-id", "A1")).then_return( + WellLiquidInfo( + loaded_volume=None, + probed_height=ProbedHeightInfo(height=40.0, last_probed=datetime.now()), + probed_volume=None, + ) + ) with pytest.raises(errors.InvalidDispenseVolumeError): subject.validate_dispense_volume_into_well( diff --git a/api/tests/opentrons/protocol_engine/state/test_labware_store.py b/api/tests/opentrons/protocol_engine/state/test_labware_store.py index 68c7e86c5ff..47150ec425f 100644 --- a/api/tests/opentrons/protocol_engine/state/test_labware_store.py +++ b/api/tests/opentrons/protocol_engine/state/test_labware_store.py @@ -126,7 +126,6 @@ def test_handles_load_labware( ) subject.handle_action( SucceedCommandAction( - private_result=None, command=command, state_update=update_types.StateUpdate( loaded_labware=update_types.LoadedLabwareUpdate( @@ -154,7 +153,6 @@ def test_handles_reload_labware( subject.handle_action( SucceedCommandAction( - private_result=None, command=command, state_update=update_types.StateUpdate( loaded_labware=update_types.LoadedLabwareUpdate( @@ -194,7 +192,6 @@ def test_handles_reload_labware( ) subject.handle_action( SucceedCommandAction( - private_result=None, command=comment_command_2, state_update=update_types.StateUpdate( labware_location=update_types.LabwareLocationUpdate( @@ -254,7 +251,6 @@ def test_handles_move_labware( ) subject.handle_action( SucceedCommandAction( - private_result=None, command=comment_command, state_update=update_types.StateUpdate( loaded_labware=update_types.LoadedLabwareUpdate( @@ -273,7 +269,6 @@ def test_handles_move_labware( ) subject.handle_action( SucceedCommandAction( - private_result=None, command=comment_2, state_update=update_types.StateUpdate( labware_location=update_types.LabwareLocationUpdate( @@ -311,7 +306,6 @@ def test_handles_move_labware_off_deck( ) subject.handle_action( SucceedCommandAction( - private_result=None, command=comment_command, state_update=update_types.StateUpdate( loaded_labware=update_types.LoadedLabwareUpdate( @@ -330,7 +324,6 @@ def test_handles_move_labware_off_deck( ) subject.handle_action( SucceedCommandAction( - private_result=None, command=comment_2, state_update=update_types.StateUpdate( labware_location=update_types.LabwareLocationUpdate( diff --git a/api/tests/opentrons/protocol_engine/state/test_labware_view.py b/api/tests/opentrons/protocol_engine/state/test_labware_view.py index d461ddda4e6..d6b05b7b027 100644 --- a/api/tests/opentrons/protocol_engine/state/test_labware_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_labware_view.py @@ -460,6 +460,17 @@ def test_get_well_definition_get_first(well_plate_def: LabwareDefinition) -> Non assert result == expected_well_def +def test_get_well_geometry_raises_error(well_plate_def: LabwareDefinition) -> None: + """It should raise an IncompleteLabwareDefinitionError when there's no innerLabwareGeometry.""" + subject = get_labware_view( + labware_by_id={"plate-id": plate}, + definitions_by_uri={"some-plate-uri": well_plate_def}, + ) + + with pytest.raises(errors.IncompleteLabwareDefinitionError): + subject.get_well_geometry(labware_id="plate-id") + + def test_get_well_size_circular(well_plate_def: LabwareDefinition) -> None: """It should return the well dimensions of a circular well.""" subject = get_labware_view( diff --git a/api/tests/opentrons/protocol_engine/state/test_liquid_view.py b/api/tests/opentrons/protocol_engine/state/test_liquid_view.py index f3424932b0e..db1e6f274a1 100644 --- a/api/tests/opentrons/protocol_engine/state/test_liquid_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_liquid_view.py @@ -3,7 +3,7 @@ from opentrons.protocol_engine.state.liquids import LiquidState, LiquidView from opentrons.protocol_engine import Liquid -from opentrons.protocol_engine.errors import LiquidDoesNotExistError +from opentrons.protocol_engine.errors import LiquidDoesNotExistError, InvalidLiquidError @pytest.fixture @@ -33,3 +33,22 @@ def test_has_liquid(subject: LiquidView) -> None: with pytest.raises(LiquidDoesNotExistError): subject.validate_liquid_id("no-id") + + +def test_validate_liquid_prevents_empty(subject: LiquidView) -> None: + """It should not allow loading a liquid with the special id EMPTY.""" + with pytest.raises(InvalidLiquidError): + subject.validate_liquid_allowed( + Liquid(id="EMPTY", displayName="empty", description="nothing") + ) + + +def test_validate_liquid_allows_non_empty(subject: LiquidView) -> None: + """It should allow a valid liquid.""" + valid_liquid = Liquid( + id="some-id", + displayName="some-display-name", + description="some-description", + displayColor=None, + ) + assert subject.validate_liquid_allowed(valid_liquid) == valid_liquid diff --git a/api/tests/opentrons/protocol_engine/state/test_module_store.py b/api/tests/opentrons/protocol_engine/state/test_module_store.py index 4f94ed314d5..832713ed0a4 100644 --- a/api/tests/opentrons/protocol_engine/state/test_module_store.py +++ b/api/tests/opentrons/protocol_engine/state/test_module_store.py @@ -197,7 +197,6 @@ def test_load_module( ) -> None: """It should handle a successful LoadModule command.""" action = actions.SucceedCommandAction( - private_result=None, command=commands.LoadModule.construct( # type: ignore[call-arg] params=commands.LoadModuleParams( model=params_model, @@ -261,7 +260,6 @@ def test_load_thermocycler_in_thermocycler_slot( ) -> None: """It should update additional slots for thermocycler module.""" action = actions.SucceedCommandAction( - private_result=None, command=commands.LoadModule.construct( # type: ignore[call-arg] params=commands.LoadModuleParams( model=ModuleModel.THERMOCYCLER_MODULE_V2, @@ -411,12 +409,8 @@ def test_handle_hs_temperature_commands(heater_shaker_v1_def: ModuleDefinition) deck_fixed_labware=[], ) - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=load_module_cmd) - ) - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=set_temp_cmd) - ) + subject.handle_action(actions.SucceedCommandAction(command=load_module_cmd)) + subject.handle_action(actions.SucceedCommandAction(command=set_temp_cmd)) assert subject.state.substate_by_module_id == { "module-id": HeaterShakerModuleSubState( module_id=HeaterShakerModuleId("module-id"), @@ -425,9 +419,7 @@ def test_handle_hs_temperature_commands(heater_shaker_v1_def: ModuleDefinition) plate_target_temperature=42, ) } - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=deactivate_cmd) - ) + subject.handle_action(actions.SucceedCommandAction(command=deactivate_cmd)) assert subject.state.substate_by_module_id == { "module-id": HeaterShakerModuleSubState( module_id=HeaterShakerModuleId("module-id"), @@ -465,12 +457,8 @@ def test_handle_hs_shake_commands(heater_shaker_v1_def: ModuleDefinition) -> Non deck_fixed_labware=[], ) - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=load_module_cmd) - ) - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=set_shake_cmd) - ) + subject.handle_action(actions.SucceedCommandAction(command=load_module_cmd)) + subject.handle_action(actions.SucceedCommandAction(command=set_shake_cmd)) assert subject.state.substate_by_module_id == { "module-id": HeaterShakerModuleSubState( module_id=HeaterShakerModuleId("module-id"), @@ -479,9 +467,7 @@ def test_handle_hs_shake_commands(heater_shaker_v1_def: ModuleDefinition) -> Non plate_target_temperature=None, ) } - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=deactivate_cmd) - ) + subject.handle_action(actions.SucceedCommandAction(command=deactivate_cmd)) assert subject.state.substate_by_module_id == { "module-id": HeaterShakerModuleSubState( module_id=HeaterShakerModuleId("module-id"), @@ -521,9 +507,7 @@ def test_handle_hs_labware_latch_commands( deck_fixed_labware=[], ) - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=load_module_cmd) - ) + subject.handle_action(actions.SucceedCommandAction(command=load_module_cmd)) assert subject.state.substate_by_module_id == { "module-id": HeaterShakerModuleSubState( module_id=HeaterShakerModuleId("module-id"), @@ -533,9 +517,7 @@ def test_handle_hs_labware_latch_commands( ) } - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=close_latch_cmd) - ) + subject.handle_action(actions.SucceedCommandAction(command=close_latch_cmd)) assert subject.state.substate_by_module_id == { "module-id": HeaterShakerModuleSubState( module_id=HeaterShakerModuleId("module-id"), @@ -544,9 +526,7 @@ def test_handle_hs_labware_latch_commands( plate_target_temperature=None, ) } - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=open_latch_cmd) - ) + subject.handle_action(actions.SucceedCommandAction(command=open_latch_cmd)) assert subject.state.substate_by_module_id == { "module-id": HeaterShakerModuleSubState( module_id=HeaterShakerModuleId("module-id"), @@ -588,20 +568,14 @@ def test_handle_tempdeck_temperature_commands( deck_fixed_labware=[], ) - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=load_module_cmd) - ) - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=set_temp_cmd) - ) + subject.handle_action(actions.SucceedCommandAction(command=load_module_cmd)) + subject.handle_action(actions.SucceedCommandAction(command=set_temp_cmd)) assert subject.state.substate_by_module_id == { "module-id": TemperatureModuleSubState( module_id=TemperatureModuleId("module-id"), plate_target_temperature=42 ) } - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=deactivate_cmd) - ) + subject.handle_action(actions.SucceedCommandAction(command=deactivate_cmd)) assert subject.state.substate_by_module_id == { "module-id": TemperatureModuleSubState( module_id=TemperatureModuleId("module-id"), plate_target_temperature=None @@ -650,12 +624,8 @@ def test_handle_thermocycler_temperature_commands( deck_fixed_labware=[], ) - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=load_module_cmd) - ) - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=set_block_temp_cmd) - ) + subject.handle_action(actions.SucceedCommandAction(command=load_module_cmd)) + subject.handle_action(actions.SucceedCommandAction(command=set_block_temp_cmd)) assert subject.state.substate_by_module_id == { "module-id": ThermocyclerModuleSubState( module_id=ThermocyclerModuleId("module-id"), @@ -664,9 +634,7 @@ def test_handle_thermocycler_temperature_commands( target_lid_temperature=None, ) } - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=set_lid_temp_cmd) - ) + subject.handle_action(actions.SucceedCommandAction(command=set_lid_temp_cmd)) assert subject.state.substate_by_module_id == { "module-id": ThermocyclerModuleSubState( module_id=ThermocyclerModuleId("module-id"), @@ -675,9 +643,7 @@ def test_handle_thermocycler_temperature_commands( target_lid_temperature=35.3, ) } - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=deactivate_lid_cmd) - ) + subject.handle_action(actions.SucceedCommandAction(command=deactivate_lid_cmd)) assert subject.state.substate_by_module_id == { "module-id": ThermocyclerModuleSubState( module_id=ThermocyclerModuleId("module-id"), @@ -686,9 +652,7 @@ def test_handle_thermocycler_temperature_commands( target_lid_temperature=None, ) } - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=deactivate_block_cmd) - ) + subject.handle_action(actions.SucceedCommandAction(command=deactivate_block_cmd)) assert subject.state.substate_by_module_id == { "module-id": ThermocyclerModuleSubState( module_id=ThermocyclerModuleId("module-id"), @@ -734,12 +698,8 @@ def test_handle_thermocycler_lid_commands( deck_fixed_labware=[], ) - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=load_module_cmd) - ) - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=open_lid_cmd) - ) + subject.handle_action(actions.SucceedCommandAction(command=load_module_cmd)) + subject.handle_action(actions.SucceedCommandAction(command=open_lid_cmd)) assert subject.state.substate_by_module_id == { "module-id": ThermocyclerModuleSubState( module_id=ThermocyclerModuleId("module-id"), @@ -749,9 +709,7 @@ def test_handle_thermocycler_lid_commands( ) } - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=close_lid_cmd) - ) + subject.handle_action(actions.SucceedCommandAction(command=close_lid_cmd)) assert subject.state.substate_by_module_id == { "module-id": ThermocyclerModuleSubState( module_id=ThermocyclerModuleId("module-id"), diff --git a/api/tests/opentrons/protocol_engine/state/test_pipette_store.py b/api/tests/opentrons/protocol_engine/state/test_pipette_store.py index caab429e26b..31b1a7f3a2c 100644 --- a/api/tests/opentrons/protocol_engine/state/test_pipette_store.py +++ b/api/tests/opentrons/protocol_engine/state/test_pipette_store.py @@ -14,6 +14,8 @@ FlowRates, CurrentWell, TipGeometry, + AspiratedFluid, + FluidKind, ) from opentrons.protocol_engine.actions import ( SetPipetteMovementSpeedAction, @@ -30,6 +32,7 @@ from opentrons.protocol_engine.resources.pipette_data_provider import ( LoadedStaticPipetteData, ) +from opentrons.protocol_engine.state.fluid_stack import FluidStack from .command_fixtures import ( create_load_pipette_command, @@ -62,7 +65,7 @@ def test_sets_initial_state(subject: PipetteStore) -> None: assert result == PipetteState( pipettes_by_id={}, - aspirated_volume_by_id={}, + pipette_contents_by_id={}, current_location=None, current_deck_point=CurrentDeckPoint(mount=None, deck_point=None), attached_tip_by_id={}, @@ -81,16 +84,13 @@ def test_location_state_update(subject: PipetteStore) -> None: pipette_name=PipetteNameType.P300_SINGLE, mount=MountType.RIGHT, ) - subject.handle_action( - SucceedCommandAction(command=load_command, private_result=None) - ) + subject.handle_action(SucceedCommandAction(command=load_command)) # Update the location to a well: dummy_command = create_succeeded_command() subject.handle_action( SucceedCommandAction( command=dummy_command, - private_result=None, state_update=update_types.StateUpdate( pipette_location=update_types.PipetteLocationUpdate( pipette_id="pipette-id", @@ -120,7 +120,6 @@ def test_location_state_update(subject: PipetteStore) -> None: subject.handle_action( SucceedCommandAction( command=dummy_command, - private_result=None, state_update=update_types.StateUpdate( pipette_location=update_types.PipetteLocationUpdate( pipette_id="pipette-id", @@ -143,7 +142,6 @@ def test_location_state_update(subject: PipetteStore) -> None: subject.handle_action( SucceedCommandAction( command=dummy_command, - private_result=None, state_update=update_types.StateUpdate( pipette_location=update_types.PipetteLocationUpdate( pipette_id="pipette-id", @@ -162,7 +160,6 @@ def test_location_state_update(subject: PipetteStore) -> None: subject.handle_action( SucceedCommandAction( command=dummy_command, - private_result=None, state_update=update_types.StateUpdate( pipette_location=update_types.PipetteLocationUpdate( pipette_id="pipette-id", @@ -181,7 +178,6 @@ def test_location_state_update(subject: PipetteStore) -> None: subject.handle_action( SucceedCommandAction( command=dummy_command, - private_result=None, state_update=update_types.StateUpdate(pipette_location=update_types.CLEAR), ) ) @@ -230,13 +226,15 @@ def test_handles_load_pipette( config=config, serial_number="pipette-serial", ) + contents_update = update_types.PipetteUnknownFluidUpdate(pipette_id="pipette-id") subject.handle_action( SucceedCommandAction( - private_result=None, command=dummy_command, state_update=update_types.StateUpdate( - loaded_pipette=load_pipette_update, pipette_config=config_update + loaded_pipette=load_pipette_update, + pipette_config=config_update, + pipette_aspirated_fluid=contents_update, ), ) ) @@ -248,7 +246,7 @@ def test_handles_load_pipette( pipetteName=PipetteNameType.P300_SINGLE, mount=MountType.LEFT, ) - assert result.aspirated_volume_by_id["pipette-id"] is None + assert result.pipette_contents_by_id["pipette-id"] is None assert result.movement_speed_by_id["pipette-id"] is None assert result.attached_tip_by_id["pipette-id"] is None @@ -271,7 +269,6 @@ def test_handles_pick_up_and_drop_tip(subject: PipetteStore) -> None: subject.handle_action( SucceedCommandAction( - private_result=None, command=load_pipette_command, state_update=update_types.StateUpdate( loaded_pipette=update_types.LoadPipetteUpdate( @@ -279,41 +276,48 @@ def test_handles_pick_up_and_drop_tip(subject: PipetteStore) -> None: pipette_name=PipetteNameType.P300_SINGLE, mount=MountType.LEFT, liquid_presence_detection=None, - ) + ), + pipette_aspirated_fluid=update_types.PipetteUnknownFluidUpdate( + pipette_id="abc" + ), ), ) ) subject.handle_action( SucceedCommandAction( - private_result=None, command=pick_up_tip_command, state_update=update_types.StateUpdate( pipette_tip_state=update_types.PipetteTipStateUpdate( pipette_id="abc", tip_geometry=TipGeometry(volume=42, length=101, diameter=8.0), - ) + ), + pipette_aspirated_fluid=update_types.PipetteEmptyFluidUpdate( + pipette_id="abc" + ), ), ) ) assert subject.state.attached_tip_by_id["abc"] == TipGeometry( volume=42, length=101, diameter=8.0 ) - assert subject.state.aspirated_volume_by_id["abc"] == 0 + assert subject.state.pipette_contents_by_id["abc"] == FluidStack() subject.handle_action( SucceedCommandAction( - private_result=None, command=drop_tip_command, state_update=update_types.StateUpdate( pipette_tip_state=update_types.PipetteTipStateUpdate( pipette_id="abc", tip_geometry=None - ) + ), + pipette_aspirated_fluid=update_types.PipetteUnknownFluidUpdate( + pipette_id="abc" + ), ), ) ) assert subject.state.attached_tip_by_id["abc"] is None - assert subject.state.aspirated_volume_by_id["abc"] is None + assert subject.state.pipette_contents_by_id["abc"] is None def test_handles_drop_tip_in_place(subject: PipetteStore) -> None: @@ -334,7 +338,6 @@ def test_handles_drop_tip_in_place(subject: PipetteStore) -> None: subject.handle_action( SucceedCommandAction( - private_result=None, command=load_pipette_command, state_update=update_types.StateUpdate( loaded_pipette=update_types.LoadPipetteUpdate( @@ -342,40 +345,47 @@ def test_handles_drop_tip_in_place(subject: PipetteStore) -> None: pipette_name=PipetteNameType.P300_SINGLE, mount=MountType.LEFT, liquid_presence_detection=None, - ) + ), + pipette_aspirated_fluid=update_types.PipetteUnknownFluidUpdate( + pipette_id="xyz" + ), ), ) ) subject.handle_action( SucceedCommandAction( - private_result=None, command=pick_up_tip_command, state_update=update_types.StateUpdate( pipette_tip_state=update_types.PipetteTipStateUpdate( pipette_id="xyz", tip_geometry=TipGeometry(volume=42, length=101, diameter=8.0), - ) + ), + pipette_aspirated_fluid=update_types.PipetteEmptyFluidUpdate( + pipette_id="xyz" + ), ), ) ) assert subject.state.attached_tip_by_id["xyz"] == TipGeometry( volume=42, length=101, diameter=8.0 ) - assert subject.state.aspirated_volume_by_id["xyz"] == 0 + assert subject.state.pipette_contents_by_id["xyz"] == FluidStack() subject.handle_action( SucceedCommandAction( - private_result=None, command=drop_tip_in_place_command, state_update=update_types.StateUpdate( pipette_tip_state=update_types.PipetteTipStateUpdate( pipette_id="xyz", tip_geometry=None - ) + ), + pipette_aspirated_fluid=update_types.PipetteUnknownFluidUpdate( + pipette_id="xyz" + ), ), ) ) assert subject.state.attached_tip_by_id["xyz"] is None - assert subject.state.aspirated_volume_by_id["xyz"] is None + assert subject.state.pipette_contents_by_id["xyz"] is None def test_handles_unsafe_drop_tip_in_place(subject: PipetteStore) -> None: @@ -396,7 +406,6 @@ def test_handles_unsafe_drop_tip_in_place(subject: PipetteStore) -> None: subject.handle_action( SucceedCommandAction( - private_result=None, command=load_pipette_command, state_update=update_types.StateUpdate( loaded_pipette=update_types.LoadPipetteUpdate( @@ -404,53 +413,78 @@ def test_handles_unsafe_drop_tip_in_place(subject: PipetteStore) -> None: pipette_name=PipetteNameType.P300_SINGLE, mount=MountType.LEFT, liquid_presence_detection=None, - ) + ), + pipette_aspirated_fluid=update_types.PipetteUnknownFluidUpdate( + pipette_id="xyz" + ), ), ) ) subject.handle_action( SucceedCommandAction( - private_result=None, command=pick_up_tip_command, state_update=update_types.StateUpdate( pipette_tip_state=update_types.PipetteTipStateUpdate( pipette_id="xyz", tip_geometry=TipGeometry(volume=42, length=101, diameter=8.0), - ) + ), + pipette_aspirated_fluid=update_types.PipetteEmptyFluidUpdate( + pipette_id="xyz" + ), ), ) ) assert subject.state.attached_tip_by_id["xyz"] == TipGeometry( volume=42, length=101, diameter=8.0 ) - assert subject.state.aspirated_volume_by_id["xyz"] == 0 + assert subject.state.pipette_contents_by_id["xyz"] == FluidStack() subject.handle_action( SucceedCommandAction( - private_result=None, command=unsafe_drop_tip_in_place_command, state_update=update_types.StateUpdate( pipette_tip_state=update_types.PipetteTipStateUpdate( pipette_id="xyz", tip_geometry=None - ) + ), + pipette_aspirated_fluid=update_types.PipetteUnknownFluidUpdate( + pipette_id="xyz" + ), ), ) ) assert subject.state.attached_tip_by_id["xyz"] is None - assert subject.state.aspirated_volume_by_id["xyz"] is None + assert subject.state.pipette_contents_by_id["xyz"] is None @pytest.mark.parametrize( - "aspirate_command", + "aspirate_command,aspirate_update", [ - create_aspirate_command(pipette_id="pipette-id", volume=42, flow_rate=1.23), - create_aspirate_in_place_command( - pipette_id="pipette-id", volume=42, flow_rate=1.23 + ( + create_aspirate_command(pipette_id="pipette-id", volume=42, flow_rate=1.23), + update_types.StateUpdate( + pipette_aspirated_fluid=update_types.PipetteAspiratedFluidUpdate( + pipette_id="pipette-id", + fluid=AspiratedFluid(kind=FluidKind.LIQUID, volume=42), + ) + ), + ), + ( + create_aspirate_in_place_command( + pipette_id="pipette-id", volume=42, flow_rate=1.23 + ), + update_types.StateUpdate( + pipette_aspirated_fluid=update_types.PipetteAspiratedFluidUpdate( + pipette_id="pipette-id", + fluid=AspiratedFluid(kind=FluidKind.LIQUID, volume=42), + ) + ), ), ], ) def test_aspirate_adds_volume( - subject: PipetteStore, aspirate_command: cmd.Command + subject: PipetteStore, + aspirate_command: cmd.Command, + aspirate_update: update_types.StateUpdate, ) -> None: """It should add volume to pipette after an aspirate.""" load_command = create_load_pipette_command( @@ -458,10 +492,12 @@ def test_aspirate_adds_volume( pipette_name=PipetteNameType.P300_SINGLE, mount=MountType.LEFT, ) + pick_up_tip_command = create_pick_up_tip_command( + pipette_id="pipette-id", tip_volume=42, tip_length=101, tip_diameter=8.0 + ) subject.handle_action( SucceedCommandAction( - private_result=None, command=load_command, state_update=update_types.StateUpdate( loaded_pipette=update_types.LoadPipetteUpdate( @@ -469,36 +505,76 @@ def test_aspirate_adds_volume( pipette_name=PipetteNameType.P300_SINGLE, mount=MountType.LEFT, liquid_presence_detection=None, - ) + ), + pipette_aspirated_fluid=update_types.PipetteUnknownFluidUpdate( + pipette_id="pipette-id" + ), ), ) ) subject.handle_action( - SucceedCommandAction(private_result=None, command=aspirate_command) + SucceedCommandAction( + command=pick_up_tip_command, + state_update=update_types.StateUpdate( + pipette_tip_state=update_types.PipetteTipStateUpdate( + pipette_id="pipette-id", + tip_geometry=TipGeometry(volume=42, length=101, diameter=8.0), + ), + pipette_aspirated_fluid=update_types.PipetteEmptyFluidUpdate( + pipette_id="pipette-id" + ), + ), + ) + ) + subject.handle_action( + SucceedCommandAction( + command=aspirate_command, + state_update=aspirate_update, + ) ) - assert subject.state.aspirated_volume_by_id["pipette-id"] == 42 + assert subject.state.pipette_contents_by_id["pipette-id"] == FluidStack( + _fluid_stack=[AspiratedFluid(kind=FluidKind.LIQUID, volume=42)] + ) subject.handle_action( - SucceedCommandAction(private_result=None, command=aspirate_command) + SucceedCommandAction(command=aspirate_command, state_update=aspirate_update) ) - assert subject.state.aspirated_volume_by_id["pipette-id"] == 84 + assert subject.state.pipette_contents_by_id["pipette-id"] == FluidStack( + _fluid_stack=[AspiratedFluid(kind=FluidKind.LIQUID, volume=84)] + ) @pytest.mark.parametrize( - "dispense_command", + "dispense_command,dispense_update", [ - create_dispense_command(pipette_id="pipette-id", volume=21, flow_rate=1.23), - create_dispense_in_place_command( - pipette_id="pipette-id", - volume=21, - flow_rate=1.23, + ( + create_dispense_command(pipette_id="pipette-id", volume=21, flow_rate=1.23), + update_types.StateUpdate( + pipette_aspirated_fluid=update_types.PipetteEjectedFluidUpdate( + pipette_id="pipette-id", volume=21 + ) + ), + ), + ( + create_dispense_in_place_command( + pipette_id="pipette-id", + volume=21, + flow_rate=1.23, + ), + update_types.StateUpdate( + pipette_aspirated_fluid=update_types.PipetteEjectedFluidUpdate( + pipette_id="pipette-id", volume=21 + ) + ), ), ], ) def test_dispense_subtracts_volume( - subject: PipetteStore, dispense_command: cmd.Command + subject: PipetteStore, + dispense_command: cmd.Command, + dispense_update: update_types.StateUpdate, ) -> None: """It should subtract volume from pipette after a dispense.""" load_command = create_load_pipette_command( @@ -506,6 +582,10 @@ def test_dispense_subtracts_volume( pipette_name=PipetteNameType.P300_SINGLE, mount=MountType.LEFT, ) + pick_up_tip_command = create_pick_up_tip_command( + pipette_id="pipette-id", tip_volume=47, tip_length=101, tip_diameter=8.0 + ) + aspirate_command = create_aspirate_command( pipette_id="pipette-id", volume=42, @@ -514,7 +594,6 @@ def test_dispense_subtracts_volume( subject.handle_action( SucceedCommandAction( - private_result=None, command=load_command, state_update=update_types.StateUpdate( loaded_pipette=update_types.LoadPipetteUpdate( @@ -522,24 +601,51 @@ def test_dispense_subtracts_volume( pipette_name=PipetteNameType.P300_SINGLE, mount=MountType.LEFT, liquid_presence_detection=None, - ) + ), + pipette_aspirated_fluid=update_types.PipetteUnknownFluidUpdate( + pipette_id="pipette-id" + ), ), ) ) subject.handle_action( - SucceedCommandAction(private_result=None, command=aspirate_command) + SucceedCommandAction( + command=pick_up_tip_command, + state_update=update_types.StateUpdate( + pipette_tip_state=update_types.PipetteTipStateUpdate( + pipette_id="pipette-id", + tip_geometry=TipGeometry(volume=47, length=101, diameter=8.0), + ), + pipette_aspirated_fluid=update_types.PipetteEmptyFluidUpdate( + pipette_id="pipette-id" + ), + ), + ) ) subject.handle_action( - SucceedCommandAction(private_result=None, command=dispense_command) + SucceedCommandAction( + command=aspirate_command, + state_update=update_types.StateUpdate( + pipette_aspirated_fluid=update_types.PipetteAspiratedFluidUpdate( + pipette_id="pipette-id", + fluid=AspiratedFluid(kind=FluidKind.LIQUID, volume=42), + ) + ), + ) + ) + subject.handle_action( + SucceedCommandAction(command=dispense_command, state_update=dispense_update) ) - assert subject.state.aspirated_volume_by_id["pipette-id"] == 21 + assert subject.state.pipette_contents_by_id["pipette-id"] == FluidStack( + _fluid_stack=[AspiratedFluid(kind=FluidKind.LIQUID, volume=21)] + ) subject.handle_action( - SucceedCommandAction(private_result=None, command=dispense_command) + SucceedCommandAction(command=dispense_command, state_update=dispense_update) ) - assert subject.state.aspirated_volume_by_id["pipette-id"] == 0 + assert subject.state.pipette_contents_by_id["pipette-id"] == FluidStack() @pytest.mark.parametrize( @@ -559,6 +665,10 @@ def test_blow_out_clears_volume( pipette_name=PipetteNameType.P300_SINGLE, mount=MountType.LEFT, ) + pick_up_tip_command = create_pick_up_tip_command( + pipette_id="pipette-id", tip_volume=47, tip_length=101, tip_diameter=8.0 + ) + aspirate_command = create_aspirate_command( pipette_id="pipette-id", volume=42, @@ -567,7 +677,6 @@ def test_blow_out_clears_volume( subject.handle_action( SucceedCommandAction( - private_result=None, command=load_command, state_update=update_types.StateUpdate( loaded_pipette=update_types.LoadPipetteUpdate( @@ -580,13 +689,42 @@ def test_blow_out_clears_volume( ) ) subject.handle_action( - SucceedCommandAction(private_result=None, command=aspirate_command) + SucceedCommandAction( + command=pick_up_tip_command, + state_update=update_types.StateUpdate( + pipette_tip_state=update_types.PipetteTipStateUpdate( + pipette_id="pipette-id", + tip_geometry=TipGeometry(volume=47, length=101, diameter=8.0), + ), + pipette_aspirated_fluid=update_types.PipetteEmptyFluidUpdate( + pipette_id="pipette-id" + ), + ), + ) ) subject.handle_action( - SucceedCommandAction(private_result=None, command=blow_out_command) + SucceedCommandAction( + command=aspirate_command, + state_update=update_types.StateUpdate( + pipette_aspirated_fluid=update_types.PipetteAspiratedFluidUpdate( + pipette_id="pipette-id", + fluid=AspiratedFluid(kind=FluidKind.LIQUID, volume=42), + ) + ), + ) + ) + subject.handle_action( + SucceedCommandAction( + command=blow_out_command, + state_update=update_types.StateUpdate( + pipette_aspirated_fluid=update_types.PipetteUnknownFluidUpdate( + pipette_id="pipette-id" + ) + ), + ) ) - assert subject.state.aspirated_volume_by_id["pipette-id"] is None + assert subject.state.pipette_contents_by_id["pipette-id"] is None def test_set_movement_speed(subject: PipetteStore) -> None: @@ -597,9 +735,7 @@ def test_set_movement_speed(subject: PipetteStore) -> None: pipette_name=PipetteNameType.P300_SINGLE, mount=MountType.LEFT, ) - subject.handle_action( - SucceedCommandAction(private_result=None, command=load_pipette_command) - ) + subject.handle_action(SucceedCommandAction(command=load_pipette_command)) subject.handle_action( SetPipetteMovementSpeedAction(pipette_id=pipette_id, speed=123.456) ) @@ -638,13 +774,9 @@ def test_add_pipette_config( pipette_lld_settings={}, ) - private_result = cmd.LoadPipettePrivateResult( - pipette_id="pipette-id", serial_number="pipette-serial", config=config - ) subject.handle_action( SucceedCommandAction( command=command, - private_result=private_result, state_update=update_types.StateUpdate( pipette_config=update_types.PipetteConfigUpdate( pipette_id="pipette-id", @@ -685,14 +817,30 @@ def test_add_pipette_config( @pytest.mark.parametrize( - "previous", + "previous_cmd,previous_state", [ - create_blow_out_command(pipette_id="pipette-id", flow_rate=1.0), - create_dispense_command(pipette_id="pipette-id", volume=10, flow_rate=1.0), + ( + create_blow_out_command(pipette_id="pipette-id", flow_rate=1.0), + update_types.StateUpdate( + pipette_aspirated_fluid=update_types.PipetteUnknownFluidUpdate( + pipette_id="pipette-id" + ) + ), + ), + ( + create_dispense_command(pipette_id="pipette-id", volume=10, flow_rate=1.0), + update_types.StateUpdate( + pipette_aspirated_fluid=update_types.PipetteEjectedFluidUpdate( + pipette_id="pipette-id", volume=10 + ) + ), + ), ], ) def test_prepare_to_aspirate_marks_pipette_ready( - subject: PipetteStore, previous: cmd.Command + subject: PipetteStore, + previous_cmd: cmd.Command, + previous_state: update_types.StateUpdate, ) -> None: """It should mark a pipette as ready to aspirate.""" load_pipette_command = create_load_pipette_command( @@ -705,7 +853,6 @@ def test_prepare_to_aspirate_marks_pipette_ready( ) subject.handle_action( SucceedCommandAction( - private_result=None, command=load_pipette_command, state_update=update_types.StateUpdate( loaded_pipette=update_types.LoadPipetteUpdate( @@ -713,34 +860,43 @@ def test_prepare_to_aspirate_marks_pipette_ready( pipette_name=PipetteNameType.P50_MULTI_FLEX, mount=MountType.LEFT, liquid_presence_detection=None, - ) + ), + pipette_aspirated_fluid=update_types.PipetteUnknownFluidUpdate( + pipette_id="pipette-id" + ), ), ) ) subject.handle_action( SucceedCommandAction( - private_result=None, command=pick_up_tip_command, state_update=update_types.StateUpdate( pipette_tip_state=update_types.PipetteTipStateUpdate( pipette_id="pipette-id", tip_geometry=TipGeometry(volume=42, length=101, diameter=8.0), - ) + ), + pipette_aspirated_fluid=update_types.PipetteEmptyFluidUpdate( + pipette_id="xyz" + ), ), ) ) subject.handle_action( - SucceedCommandAction( - private_result=None, - command=previous, - ) + SucceedCommandAction(command=previous_cmd, state_update=previous_state) ) prepare_to_aspirate_command = create_prepare_to_aspirate_command( pipette_id="pipette-id" ) subject.handle_action( - SucceedCommandAction(private_result=None, command=prepare_to_aspirate_command) + SucceedCommandAction( + command=prepare_to_aspirate_command, + state_update=update_types.StateUpdate( + pipette_aspirated_fluid=update_types.PipetteEmptyFluidUpdate( + pipette_id="pipette-id" + ) + ), + ) ) - assert subject.state.aspirated_volume_by_id["pipette-id"] == 0.0 + assert subject.state.pipette_contents_by_id["pipette-id"] == FluidStack() diff --git a/api/tests/opentrons/protocol_engine/state/test_pipette_view.py b/api/tests/opentrons/protocol_engine/state/test_pipette_view.py index 3b4d04bd967..60bb528ba85 100644 --- a/api/tests/opentrons/protocol_engine/state/test_pipette_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_pipette_view.py @@ -1,8 +1,10 @@ """Tests for pipette state accessors in the protocol_engine state store.""" from collections import OrderedDict +from typing import cast, Dict, List, Optional, Tuple, NamedTuple import pytest -from typing import cast, Dict, List, Optional, Tuple, NamedTuple +from decoy import Decoy + from opentrons_shared_data.pipette.types import PipetteNameType from opentrons_shared_data.pipette import pipette_definition @@ -30,6 +32,7 @@ BoundingNozzlesOffsets, PipetteBoundingBoxOffsets, ) +from opentrons.protocol_engine.state import fluid_stack from opentrons.hardware_control.nozzle_manager import NozzleMap, NozzleConfigurationType from opentrons.protocol_engine.errors import TipNotAttachedError, PipetteNotLoadedError @@ -56,7 +59,6 @@ def get_pipette_view( pipettes_by_id: Optional[Dict[str, LoadedPipette]] = None, - aspirated_volume_by_id: Optional[Dict[str, Optional[float]]] = None, current_well: Optional[CurrentPipetteLocation] = None, current_deck_point: CurrentDeckPoint = CurrentDeckPoint( mount=None, deck_point=None @@ -67,11 +69,14 @@ def get_pipette_view( flow_rates_by_id: Optional[Dict[str, FlowRates]] = None, nozzle_layout_by_id: Optional[Dict[str, NozzleMap]] = None, liquid_presence_detection_by_id: Optional[Dict[str, bool]] = None, + pipette_contents_by_id: Optional[ + Dict[str, Optional[fluid_stack.FluidStack]] + ] = None, ) -> PipetteView: """Get a pipette view test subject with the specified state.""" state = PipetteState( pipettes_by_id=pipettes_by_id or {}, - aspirated_volume_by_id=aspirated_volume_by_id or {}, + pipette_contents_by_id=pipette_contents_by_id or {}, current_location=current_well, current_deck_point=current_deck_point, attached_tip_by_id=attached_tip_by_id or {}, @@ -234,11 +239,12 @@ def test_get_hardware_pipette_raises_with_name_mismatch() -> None: ) -def test_get_aspirated_volume() -> None: +def test_get_aspirated_volume(decoy: Decoy) -> None: """It should get the aspirate volume for a pipette.""" + stack = decoy.mock(cls=fluid_stack.FluidStack) subject = get_pipette_view( - aspirated_volume_by_id={ - "pipette-id": 42, + pipette_contents_by_id={ + "pipette-id": stack, "pipette-id-none": None, "pipette-id-no-tip": None, }, @@ -248,6 +254,7 @@ def test_get_aspirated_volume() -> None: "pipette-id-no-tip": None, }, ) + decoy.when(stack.aspirated_volume()).then_return(42) assert subject.get_aspirated_volume("pipette-id") == 42 assert subject.get_aspirated_volume("pipette-id-none") is None @@ -326,9 +333,11 @@ def test_get_pipette_working_volume_raises_if_tip_volume_is_none( def test_get_pipette_available_volume( - supported_tip_fixture: pipette_definition.SupportedTipsDefinition, + supported_tip_fixture: pipette_definition.SupportedTipsDefinition, decoy: Decoy ) -> None: """It should get the available volume for a pipette.""" + stack = decoy.mock(cls=fluid_stack.FluidStack) + decoy.when(stack.aspirated_volume()).then_return(58) subject = get_pipette_view( attached_tip_by_id={ "pipette-id": TipGeometry( @@ -337,7 +346,7 @@ def test_get_pipette_available_volume( volume=100, ), }, - aspirated_volume_by_id={"pipette-id": 58}, + pipette_contents_by_id={"pipette-id": stack}, static_config_by_id={ "pipette-id": StaticPipetteConfig( min_volume=1, diff --git a/api/tests/opentrons/protocol_engine/state/test_tip_state.py b/api/tests/opentrons/protocol_engine/state/test_tip_state.py index e0f0fd15669..abb408d7418 100644 --- a/api/tests/opentrons/protocol_engine/state/test_tip_state.py +++ b/api/tests/opentrons/protocol_engine/state/test_tip_state.py @@ -16,11 +16,11 @@ from opentrons.protocol_engine import actions, commands from opentrons.protocol_engine.state import update_types from opentrons.protocol_engine.state.tips import TipStore, TipView -from opentrons.protocol_engine.types import FlowRates +from opentrons.protocol_engine.types import DeckSlotLocation, FlowRates from opentrons.protocol_engine.resources.pipette_data_provider import ( LoadedStaticPipetteData, ) -from opentrons.types import Point +from opentrons.types import DeckSlotName, Point from opentrons_shared_data.pipette.types import PipetteNameType from ..pipette_fixtures import ( NINETY_SIX_MAP, @@ -61,13 +61,21 @@ def labware_definition() -> LabwareDefinition: @pytest.fixture -def load_labware_command(labware_definition: LabwareDefinition) -> commands.LoadLabware: +def load_labware_action( + labware_definition: LabwareDefinition, +) -> actions.SucceedCommandAction: """Get a load labware command value object.""" - return commands.LoadLabware.construct( # type: ignore[call-arg] - result=commands.LoadLabwareResult.construct( - labwareId="cool-labware", - definition=labware_definition, - ) + return actions.SucceedCommandAction( + command=_dummy_command(), + state_update=update_types.StateUpdate( + loaded_labware=update_types.LoadedLabwareUpdate( + labware_id="cool-labware", + definition=labware_definition, + new_location=DeckSlotLocation(slotName=DeckSlotName.SLOT_A1), + display_name=None, + offset_id=None, + ) + ), ) @@ -83,18 +91,13 @@ def _dummy_command() -> commands.Command: ], ) def test_get_next_tip_returns_none( - load_labware_command: commands.LoadLabware, + load_labware_action: actions.SucceedCommandAction, subject: TipStore, supported_tip_fixture: pipette_definition.SupportedTipsDefinition, ) -> None: """It should start at the first tip in the labware.""" - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=load_labware_command) - ) - load_pipette_command = commands.LoadPipette.construct( # type: ignore[call-arg] - result=commands.LoadPipetteResult(pipetteId="pipette-id") - ) - load_pipette_private_result = commands.LoadPipettePrivateResult( + subject.handle_action(load_labware_action) + config_update = update_types.PipetteConfigUpdate( pipette_id="pipette-id", serial_number="pipette-serial", config=LoadedStaticPipetteData( @@ -120,7 +123,8 @@ def test_get_next_tip_returns_none( ) subject.handle_action( actions.SucceedCommandAction( - private_result=load_pipette_private_result, command=load_pipette_command + state_update=update_types.StateUpdate(pipette_config=config_update), + command=_dummy_command(), ) ) @@ -136,18 +140,14 @@ def test_get_next_tip_returns_none( @pytest.mark.parametrize("input_tip_amount", [1, 8, 96]) def test_get_next_tip_returns_first_tip( - load_labware_command: commands.LoadLabware, + load_labware_action: actions.SucceedCommandAction, subject: TipStore, input_tip_amount: int, supported_tip_fixture: pipette_definition.SupportedTipsDefinition, ) -> None: """It should start at the first tip in the labware.""" - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=load_labware_command) - ) - load_pipette_command = commands.LoadPipette.construct( # type: ignore[call-arg] - result=commands.LoadPipetteResult(pipetteId="pipette-id") - ) + subject.handle_action(load_labware_action) + pipette_name_type = PipetteNameType.P1000_96 if input_tip_amount == 1: pipette_name_type = PipetteNameType.P300_SINGLE_GEN2 @@ -155,7 +155,7 @@ def test_get_next_tip_returns_first_tip( pipette_name_type = PipetteNameType.P300_MULTI_GEN2 else: pipette_name_type = PipetteNameType.P1000_96 - load_pipette_private_result = commands.LoadPipettePrivateResult( + config_update = update_types.PipetteConfigUpdate( pipette_id="pipette-id", serial_number="pipette-serial", config=LoadedStaticPipetteData( @@ -181,7 +181,8 @@ def test_get_next_tip_returns_first_tip( ) subject.handle_action( actions.SucceedCommandAction( - private_result=load_pipette_private_result, command=load_pipette_command + state_update=update_types.StateUpdate(pipette_config=config_update), + command=_dummy_command(), ) ) @@ -197,20 +198,16 @@ def test_get_next_tip_returns_first_tip( @pytest.mark.parametrize("input_tip_amount, result_well_name", [(1, "B1"), (8, "A2")]) def test_get_next_tip_used_starting_tip( - load_labware_command: commands.LoadLabware, + load_labware_action: actions.SucceedCommandAction, subject: TipStore, input_tip_amount: int, result_well_name: str, supported_tip_fixture: pipette_definition.SupportedTipsDefinition, ) -> None: """It should start searching at the given starting tip.""" - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=load_labware_command) - ) - load_pipette_command = commands.LoadPipette.construct( # type: ignore[call-arg] - result=commands.LoadPipetteResult(pipetteId="pipette-id") - ) - load_pipette_private_result = commands.LoadPipettePrivateResult( + subject.handle_action(load_labware_action) + + config_update = update_types.PipetteConfigUpdate( pipette_id="pipette-id", serial_number="pipette-serial", config=LoadedStaticPipetteData( @@ -236,7 +233,8 @@ def test_get_next_tip_used_starting_tip( ) subject.handle_action( actions.SucceedCommandAction( - private_result=load_pipette_private_result, command=load_pipette_command + state_update=update_types.StateUpdate(pipette_config=config_update), + command=_dummy_command(), ) ) @@ -265,7 +263,7 @@ def test_get_next_tip_used_starting_tip( ], ) def test_get_next_tip_skips_picked_up_tip( - load_labware_command: commands.LoadLabware, + load_labware_action: actions.SucceedCommandAction, subject: TipStore, input_tip_amount: int, get_next_tip_tips: int, @@ -274,13 +272,8 @@ def test_get_next_tip_skips_picked_up_tip( supported_tip_fixture: pipette_definition.SupportedTipsDefinition, ) -> None: """It should get the next tip in the column if one has been picked up.""" - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=load_labware_command) - ) + subject.handle_action(load_labware_action) - load_pipette_command = commands.LoadPipette.construct( # type: ignore[call-arg] - result=commands.LoadPipetteResult(pipetteId="pipette-id") - ) channels_num = input_tip_amount if input_starting_tip is not None: pipette_name_type = PipetteNameType.P1000_96 @@ -299,7 +292,7 @@ def test_get_next_tip_skips_picked_up_tip( pipette_name_type = PipetteNameType.P300_MULTI_GEN2 else: pipette_name_type = PipetteNameType.P1000_96 - load_pipette_private_result = commands.LoadPipettePrivateResult( + config_update = update_types.PipetteConfigUpdate( pipette_id="pipette-id", serial_number="pipette-serial", config=LoadedStaticPipetteData( @@ -325,7 +318,8 @@ def test_get_next_tip_skips_picked_up_tip( ) subject.handle_action( actions.SucceedCommandAction( - private_result=load_pipette_private_result, command=load_pipette_command + state_update=update_types.StateUpdate(pipette_config=config_update), + command=_dummy_command(), ) ) @@ -339,7 +333,6 @@ def test_get_next_tip_skips_picked_up_tip( subject.handle_action( actions.SucceedCommandAction( command=_dummy_command(), - private_result=None, state_update=pick_up_tip_state_update, ) ) @@ -348,7 +341,7 @@ def test_get_next_tip_skips_picked_up_tip( labware_id="cool-labware", num_tips=get_next_tip_tips, starting_tip_name=input_starting_tip, - nozzle_map=load_pipette_private_result.config.nozzle_map, + nozzle_map=config_update.config.nozzle_map, ) assert result == result_well_name @@ -356,17 +349,13 @@ def test_get_next_tip_skips_picked_up_tip( def test_get_next_tip_with_starting_tip( subject: TipStore, - load_labware_command: commands.LoadLabware, + load_labware_action: actions.SucceedCommandAction, supported_tip_fixture: pipette_definition.SupportedTipsDefinition, ) -> None: """It should return the starting tip, and then the following tip after that.""" - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=load_labware_command) - ) - load_pipette_command = commands.LoadPipette.construct( # type: ignore[call-arg] - result=commands.LoadPipetteResult(pipetteId="pipette-id") - ) - load_pipette_private_result = commands.LoadPipettePrivateResult( + subject.handle_action(load_labware_action) + + config_update = update_types.PipetteConfigUpdate( pipette_id="pipette-id", serial_number="pipette-serial", config=LoadedStaticPipetteData( @@ -392,14 +381,15 @@ def test_get_next_tip_with_starting_tip( ) subject.handle_action( actions.SucceedCommandAction( - private_result=load_pipette_private_result, command=load_pipette_command + state_update=update_types.StateUpdate(pipette_config=config_update), + command=_dummy_command(), ) ) result = TipView(subject.state).get_next_tip( labware_id="cool-labware", num_tips=1, starting_tip_name="B2", - nozzle_map=load_pipette_private_result.config.nozzle_map, + nozzle_map=config_update.config.nozzle_map, ) assert result == "B2" @@ -410,7 +400,6 @@ def test_get_next_tip_with_starting_tip( subject.handle_action( actions.SucceedCommandAction( command=_dummy_command(), - private_result=None, state_update=pick_up_tip_state_update, ) ) @@ -419,7 +408,7 @@ def test_get_next_tip_with_starting_tip( labware_id="cool-labware", num_tips=1, starting_tip_name="B2", - nozzle_map=load_pipette_private_result.config.nozzle_map, + nozzle_map=config_update.config.nozzle_map, ) assert result == "C2" @@ -427,17 +416,13 @@ def test_get_next_tip_with_starting_tip( def test_get_next_tip_with_starting_tip_8_channel( subject: TipStore, - load_labware_command: commands.LoadLabware, + load_labware_action: actions.SucceedCommandAction, supported_tip_fixture: pipette_definition.SupportedTipsDefinition, ) -> None: """It should return the starting tip, and then the following tip after that.""" - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=load_labware_command) - ) - load_pipette_command = commands.LoadPipette.construct( # type: ignore[call-arg] - result=commands.LoadPipetteResult(pipetteId="pipette-id") - ) - load_pipette_private_result = commands.LoadPipettePrivateResult( + subject.handle_action(load_labware_action) + + config_update = update_types.PipetteConfigUpdate( pipette_id="pipette-id", serial_number="pipette-serial", config=LoadedStaticPipetteData( @@ -463,7 +448,8 @@ def test_get_next_tip_with_starting_tip_8_channel( ) subject.handle_action( actions.SucceedCommandAction( - private_result=load_pipette_private_result, command=load_pipette_command + state_update=update_types.StateUpdate(pipette_config=config_update), + command=_dummy_command(), ) ) @@ -484,7 +470,6 @@ def test_get_next_tip_with_starting_tip_8_channel( subject.handle_action( actions.SucceedCommandAction( command=_dummy_command(), - private_result=None, state_update=pick_up_tip_state_update, ) ) @@ -501,17 +486,13 @@ def test_get_next_tip_with_starting_tip_8_channel( def test_get_next_tip_with_1_channel_followed_by_8_channel( subject: TipStore, - load_labware_command: commands.LoadLabware, + load_labware_action: actions.SucceedCommandAction, supported_tip_fixture: pipette_definition.SupportedTipsDefinition, ) -> None: """It should return the first tip of column 2 for the 8 channel after performing a single tip pickup on column 1.""" - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=load_labware_command) - ) - load_pipette_command = commands.LoadPipette.construct( # type: ignore[call-arg] - result=commands.LoadPipetteResult(pipetteId="pipette-id") - ) - load_pipette_private_result = commands.LoadPipettePrivateResult( + subject.handle_action(load_labware_action) + + config_update = update_types.PipetteConfigUpdate( pipette_id="pipette-id", serial_number="pipette-serial", config=LoadedStaticPipetteData( @@ -537,13 +518,12 @@ def test_get_next_tip_with_1_channel_followed_by_8_channel( ) subject.handle_action( actions.SucceedCommandAction( - private_result=load_pipette_private_result, command=load_pipette_command + state_update=update_types.StateUpdate(pipette_config=config_update), + command=_dummy_command(), ) ) - load_pipette_command_2 = commands.LoadPipette.construct( # type: ignore[call-arg] - result=commands.LoadPipetteResult(pipetteId="pipette-id2") - ) - load_pipette_private_result_2 = commands.LoadPipettePrivateResult( + + config_update_2 = update_types.PipetteConfigUpdate( pipette_id="pipette-id2", serial_number="pipette-serial2", config=LoadedStaticPipetteData( @@ -569,7 +549,8 @@ def test_get_next_tip_with_1_channel_followed_by_8_channel( ) subject.handle_action( actions.SucceedCommandAction( - private_result=load_pipette_private_result_2, command=load_pipette_command_2 + state_update=update_types.StateUpdate(pipette_config=config_update_2), + command=_dummy_command(), ) ) @@ -590,7 +571,6 @@ def test_get_next_tip_with_1_channel_followed_by_8_channel( subject.handle_action( actions.SucceedCommandAction( command=_dummy_command(), - private_result=None, state_update=pick_up_tip_2_state_update, ) ) @@ -607,17 +587,13 @@ def test_get_next_tip_with_1_channel_followed_by_8_channel( def test_get_next_tip_with_starting_tip_out_of_tips( subject: TipStore, - load_labware_command: commands.LoadLabware, + load_labware_action: actions.SucceedCommandAction, supported_tip_fixture: pipette_definition.SupportedTipsDefinition, ) -> None: """It should return the starting tip of H12 and then None after that.""" - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=load_labware_command) - ) - load_pipette_command = commands.LoadPipette.construct( # type: ignore[call-arg] - result=commands.LoadPipetteResult(pipetteId="pipette-id") - ) - load_pipette_private_result = commands.LoadPipettePrivateResult( + subject.handle_action(load_labware_action) + + config_update = update_types.PipetteConfigUpdate( pipette_id="pipette-id", serial_number="pipette-serial", config=LoadedStaticPipetteData( @@ -643,7 +619,8 @@ def test_get_next_tip_with_starting_tip_out_of_tips( ) subject.handle_action( actions.SucceedCommandAction( - private_result=load_pipette_private_result, command=load_pipette_command + state_update=update_types.StateUpdate(pipette_config=config_update), + command=_dummy_command(), ) ) @@ -664,7 +641,6 @@ def test_get_next_tip_with_starting_tip_out_of_tips( subject.handle_action( actions.SucceedCommandAction( command=_dummy_command(), - private_result=None, state_update=pick_up_tip_state_update, ) ) @@ -681,17 +657,13 @@ def test_get_next_tip_with_starting_tip_out_of_tips( def test_get_next_tip_with_column_and_starting_tip( subject: TipStore, - load_labware_command: commands.LoadLabware, + load_labware_action: actions.SucceedCommandAction, supported_tip_fixture: pipette_definition.SupportedTipsDefinition, ) -> None: """It should return the first tip in a column, taking starting tip into account.""" - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=load_labware_command) - ) - load_pipette_command = commands.LoadPipette.construct( # type: ignore[call-arg] - result=commands.LoadPipetteResult(pipetteId="pipette-id") - ) - load_pipette_private_result = commands.LoadPipettePrivateResult( + subject.handle_action(load_labware_action) + + config_update = update_types.PipetteConfigUpdate( pipette_id="pipette-id", serial_number="pipette-serial", config=LoadedStaticPipetteData( @@ -717,7 +689,8 @@ def test_get_next_tip_with_column_and_starting_tip( ) subject.handle_action( actions.SucceedCommandAction( - private_result=load_pipette_private_result, command=load_pipette_command + state_update=update_types.StateUpdate(pipette_config=config_update), + command=_dummy_command(), ) ) @@ -733,17 +706,13 @@ def test_get_next_tip_with_column_and_starting_tip( def test_reset_tips( subject: TipStore, - load_labware_command: commands.LoadLabware, + load_labware_action: actions.SucceedCommandAction, supported_tip_fixture: pipette_definition.SupportedTipsDefinition, ) -> None: """It should be able to reset tip tracking state.""" - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=load_labware_command) - ) - load_pipette_command = commands.LoadPipette.construct( # type: ignore[call-arg] - result=commands.LoadPipetteResult(pipetteId="pipette-id") - ) - load_pipette_private_result = commands.LoadPipettePrivateResult( + subject.handle_action(load_labware_action) + + config_update = update_types.PipetteConfigUpdate( pipette_id="pipette-id", serial_number="pipette-serial", config=LoadedStaticPipetteData( @@ -770,14 +739,14 @@ def test_reset_tips( subject.handle_action( actions.SucceedCommandAction( - private_result=load_pipette_private_result, command=load_pipette_command + state_update=update_types.StateUpdate(pipette_config=config_update), + command=_dummy_command(), ) ) subject.handle_action( actions.SucceedCommandAction( command=_dummy_command(), - private_result=None, state_update=update_types.StateUpdate( tips_used=update_types.TipsUsedUpdate( pipette_id="pipette-id", @@ -805,10 +774,7 @@ def test_handle_pipette_config_action( subject: TipStore, supported_tip_fixture: pipette_definition.SupportedTipsDefinition ) -> None: """Should add pipette channel to state.""" - load_pipette_command = commands.LoadPipette.construct( # type: ignore[call-arg] - result=commands.LoadPipetteResult(pipetteId="pipette-id") - ) - load_pipette_private_result = commands.LoadPipettePrivateResult( + config_update = update_types.PipetteConfigUpdate( pipette_id="pipette-id", serial_number="pipette-serial", config=LoadedStaticPipetteData( @@ -834,7 +800,8 @@ def test_handle_pipette_config_action( ) subject.handle_action( actions.SucceedCommandAction( - private_result=load_pipette_private_result, command=load_pipette_command + state_update=update_types.StateUpdate(pipette_config=config_update), + command=_dummy_command(), ) ) @@ -852,12 +819,10 @@ def test_handle_pipette_config_action( ], ) def test_has_tip_not_tip_rack( - load_labware_command: commands.LoadLabware, subject: TipStore + load_labware_action: actions.SucceedCommandAction, subject: TipStore ) -> None: """It should return False if labware isn't a tip rack.""" - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=load_labware_command) - ) + subject.handle_action(load_labware_action) result = TipView(state=subject.state).has_clean_tip("cool-labware", "A1") @@ -865,12 +830,10 @@ def test_has_tip_not_tip_rack( def test_has_tip_tip_rack( - load_labware_command: commands.LoadLabware, subject: TipStore + load_labware_action: actions.SucceedCommandAction, subject: TipStore ) -> None: """It should return False if labware isn't a tip rack.""" - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=load_labware_command) - ) + subject.handle_action(load_labware_action) result = TipView(state=subject.state).has_clean_tip("cool-labware", "A1") @@ -944,10 +907,7 @@ def test_active_channels( ) -> None: """Should update active channels after pipette configuration change.""" # Load pipette to update state - load_pipette_command = commands.LoadPipette.construct( # type: ignore[call-arg] - result=commands.LoadPipetteResult(pipetteId="pipette-id") - ) - load_pipette_private_result = commands.LoadPipettePrivateResult( + config_update = update_types.PipetteConfigUpdate( pipette_id="pipette-id", serial_number="pipette-serial", config=LoadedStaticPipetteData( @@ -973,7 +933,8 @@ def test_active_channels( ) subject.handle_action( actions.SucceedCommandAction( - private_result=load_pipette_private_result, command=load_pipette_command + state_update=update_types.StateUpdate(pipette_config=config_update), + command=_dummy_command(), ) ) @@ -987,7 +948,6 @@ def test_active_channels( subject.handle_action( actions.SucceedCommandAction( command=_dummy_command(), - private_result=None, state_update=state_update, ) ) @@ -1000,19 +960,14 @@ def test_active_channels( def test_next_tip_uses_active_channels( subject: TipStore, supported_tip_fixture: pipette_definition.SupportedTipsDefinition, - load_labware_command: commands.LoadLabware, + load_labware_action: actions.SucceedCommandAction, ) -> None: """Test that tip tracking logic uses pipette's active channels.""" # Load labware - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=load_labware_command) - ) + subject.handle_action(load_labware_action) # Load pipette - load_pipette_command = commands.LoadPipette.construct( # type: ignore[call-arg] - result=commands.LoadPipetteResult(pipetteId="pipette-id") - ) - load_pipette_private_result = commands.LoadPipettePrivateResult( + config_update = update_types.PipetteConfigUpdate( pipette_id="pipette-id", serial_number="pipette-serial", config=LoadedStaticPipetteData( @@ -1038,7 +993,8 @@ def test_next_tip_uses_active_channels( ) subject.handle_action( actions.SucceedCommandAction( - private_result=load_pipette_private_result, command=load_pipette_command + state_update=update_types.StateUpdate(pipette_config=config_update), + command=_dummy_command(), ) ) @@ -1073,7 +1029,6 @@ def test_next_tip_uses_active_channels( subject.handle_action( actions.SucceedCommandAction( command=_dummy_command(), - private_result=None, state_update=state_update, ) ) @@ -1081,7 +1036,6 @@ def test_next_tip_uses_active_channels( subject.handle_action( actions.SucceedCommandAction( command=_dummy_command(), - private_result=None, state_update=update_types.StateUpdate( tips_used=update_types.TipsUsedUpdate( pipette_id="pipette-id", @@ -1104,19 +1058,14 @@ def test_next_tip_uses_active_channels( def test_next_tip_automatic_tip_tracking_with_partial_configurations( subject: TipStore, supported_tip_fixture: pipette_definition.SupportedTipsDefinition, - load_labware_command: commands.LoadLabware, + load_labware_action: actions.SucceedCommandAction, ) -> None: """Test tip tracking logic using multiple pipette configurations.""" # Load labware - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=load_labware_command) - ) + subject.handle_action(load_labware_action) # Load pipette - load_pipette_command = commands.LoadPipette.construct( # type: ignore[call-arg] - result=commands.LoadPipetteResult(pipetteId="pipette-id") - ) - load_pipette_private_result = commands.LoadPipettePrivateResult( + config_update = update_types.PipetteConfigUpdate( pipette_id="pipette-id", serial_number="pipette-serial", config=LoadedStaticPipetteData( @@ -1142,7 +1091,8 @@ def test_next_tip_automatic_tip_tracking_with_partial_configurations( ) subject.handle_action( actions.SucceedCommandAction( - private_result=load_pipette_private_result, command=load_pipette_command + state_update=update_types.StateUpdate(pipette_config=config_update), + command=_dummy_command(), ) ) @@ -1166,7 +1116,6 @@ def _assert_and_pickup(well: str, nozzle_map: NozzleMap) -> None: subject.handle_action( actions.SucceedCommandAction( command=_dummy_command(), - private_result=None, state_update=pick_up_tip_state_update, ) ) @@ -1237,7 +1186,6 @@ def _reconfigure_nozzle_layout(start: str, back_l: str, front_r: str) -> NozzleM subject.handle_action( actions.SucceedCommandAction( command=_dummy_command(), - private_result=None, state_update=state_update, ) ) @@ -1262,19 +1210,14 @@ def _reconfigure_nozzle_layout(start: str, back_l: str, front_r: str) -> NozzleM def test_next_tip_automatic_tip_tracking_tiprack_limits( subject: TipStore, supported_tip_fixture: pipette_definition.SupportedTipsDefinition, - load_labware_command: commands.LoadLabware, + load_labware_action: actions.SucceedCommandAction, ) -> None: """Test tip tracking logic to ensure once a tiprack is consumed it returns None when consuming tips using multiple pipette configurations.""" # Load labware - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=load_labware_command) - ) + subject.handle_action(load_labware_action) # Load pipette - load_pipette_command = commands.LoadPipette.construct( # type: ignore[call-arg] - result=commands.LoadPipetteResult(pipetteId="pipette-id") - ) - load_pipette_private_result = commands.LoadPipettePrivateResult( + config_update = update_types.PipetteConfigUpdate( pipette_id="pipette-id", serial_number="pipette-serial", config=LoadedStaticPipetteData( @@ -1300,7 +1243,8 @@ def test_next_tip_automatic_tip_tracking_tiprack_limits( ) subject.handle_action( actions.SucceedCommandAction( - private_result=load_pipette_private_result, command=load_pipette_command + state_update=update_types.StateUpdate(pipette_config=config_update), + command=_dummy_command(), ) ) @@ -1321,7 +1265,6 @@ def _get_next_and_pickup(nozzle_map: NozzleMap) -> str | None: subject.handle_action( actions.SucceedCommandAction( command=_dummy_command(), - private_result=None, state_update=pick_up_tip_state_update, ) ) @@ -1365,7 +1308,7 @@ def _reconfigure_nozzle_layout(start: str, back_l: str, front_r: str) -> NozzleM ) subject.handle_action( actions.SucceedCommandAction( - command=_dummy_command(), private_result=None, state_update=state_update + command=_dummy_command(), state_update=state_update ) ) return nozzle_map diff --git a/api/tests/opentrons/protocol_engine/state/test_well_store.py b/api/tests/opentrons/protocol_engine/state/test_well_store.py index 325021a9942..ec59a643db0 100644 --- a/api/tests/opentrons/protocol_engine/state/test_well_store.py +++ b/api/tests/opentrons/protocol_engine/state/test_well_store.py @@ -1,9 +1,15 @@ """Well state store tests.""" import pytest +from datetime import datetime from opentrons.protocol_engine.state.wells import WellStore from opentrons.protocol_engine.actions.actions import SucceedCommandAction +from opentrons.protocol_engine.state import update_types -from .command_fixtures import create_liquid_probe_command +from .command_fixtures import ( + create_liquid_probe_command, + create_load_liquid_command, + create_aspirate_command, +) @pytest.fixture @@ -16,13 +22,208 @@ def test_handles_liquid_probe_success(subject: WellStore) -> None: """It should add the well to the state after a successful liquid probe.""" labware_id = "labware-id" well_name = "well-name" + liquid_probe = create_liquid_probe_command() + timestamp = datetime(year=2020, month=1, day=2) + + subject.handle_action( + SucceedCommandAction( + command=liquid_probe, + state_update=update_types.StateUpdate( + liquid_probed=update_types.LiquidProbedUpdate( + labware_id="labware-id", + well_name="well-name", + height=15.0, + volume=30.0, + last_probed=timestamp, + ) + ), + ) + ) + + assert len(subject.state.probed_heights) == 1 + assert len(subject.state.probed_volumes) == 1 + + assert subject.state.probed_heights[labware_id][well_name].height == 15.0 + assert subject.state.probed_heights[labware_id][well_name].last_probed == timestamp + assert subject.state.probed_volumes[labware_id][well_name].volume == 30.0 + assert subject.state.probed_volumes[labware_id][well_name].last_probed == timestamp + assert ( + subject.state.probed_volumes[labware_id][well_name].operations_since_probe == 0 + ) + + +def test_handles_load_liquid_success(subject: WellStore) -> None: + """It should add the well to the state after a successful load liquid.""" + labware_id = "labware-id" + well_name_1 = "well-name-1" + well_name_2 = "well-name-2" + load_liquid = create_load_liquid_command( + labware_id=labware_id, volume_by_well={well_name_1: 30, well_name_2: 100} + ) + timestamp = datetime(year=2020, month=1, day=2) + + subject.handle_action( + SucceedCommandAction( + command=load_liquid, + state_update=update_types.StateUpdate( + liquid_loaded=update_types.LiquidLoadedUpdate( + labware_id=labware_id, + volumes={well_name_1: 30, well_name_2: 100}, + last_loaded=timestamp, + ) + ), + ) + ) + + assert len(subject.state.loaded_volumes) == 1 + assert len(subject.state.loaded_volumes[labware_id]) == 2 + + assert subject.state.loaded_volumes[labware_id][well_name_1].volume == 30.0 + assert ( + subject.state.loaded_volumes[labware_id][well_name_1].last_loaded == timestamp + ) + assert ( + subject.state.loaded_volumes[labware_id][well_name_1].operations_since_load == 0 + ) + assert subject.state.loaded_volumes[labware_id][well_name_2].volume == 100.0 + assert ( + subject.state.loaded_volumes[labware_id][well_name_2].last_loaded == timestamp + ) + assert ( + subject.state.loaded_volumes[labware_id][well_name_2].operations_since_load == 0 + ) + + +def test_handles_load_liquid_and_aspirate(subject: WellStore) -> None: + """It should populate the well state after load liquid and update the well state after aspirate.""" + pipette_id = "pipette-id" + labware_id = "labware-id" + well_name_1 = "well-name-1" + well_name_2 = "well-name-2" + aspirated_volume = 10.0 + load_liquid = create_load_liquid_command( + labware_id=labware_id, volume_by_well={well_name_1: 30, well_name_2: 100} + ) + aspirate_1 = create_aspirate_command( + pipette_id=pipette_id, + volume=aspirated_volume, + flow_rate=1.0, + labware_id=labware_id, + well_name=well_name_1, + ) + aspirate_2 = create_aspirate_command( + pipette_id=pipette_id, + volume=aspirated_volume, + flow_rate=1.0, + labware_id=labware_id, + well_name=well_name_2, + ) + timestamp = datetime(year=2020, month=1, day=2) + + subject.handle_action( + SucceedCommandAction( + command=load_liquid, + state_update=update_types.StateUpdate( + liquid_loaded=update_types.LiquidLoadedUpdate( + labware_id=labware_id, + volumes={well_name_1: 30, well_name_2: 100}, + last_loaded=timestamp, + ) + ), + ) + ) + subject.handle_action( + SucceedCommandAction( + command=aspirate_1, + state_update=update_types.StateUpdate( + liquid_operated=update_types.LiquidOperatedUpdate( + labware_id=labware_id, + well_name=well_name_1, + volume_added=-aspirated_volume, + ) + ), + ) + ) + subject.handle_action( + SucceedCommandAction( + command=aspirate_2, + state_update=update_types.StateUpdate( + liquid_operated=update_types.LiquidOperatedUpdate( + labware_id=labware_id, + well_name=well_name_2, + volume_added=-aspirated_volume, + ) + ), + ) + ) + + assert len(subject.state.loaded_volumes) == 1 + assert len(subject.state.loaded_volumes[labware_id]) == 2 + assert subject.state.loaded_volumes[labware_id][well_name_1].volume == 20.0 + assert ( + subject.state.loaded_volumes[labware_id][well_name_1].last_loaded == timestamp + ) + assert ( + subject.state.loaded_volumes[labware_id][well_name_1].operations_since_load == 1 + ) + assert subject.state.loaded_volumes[labware_id][well_name_2].volume == 90.0 + assert ( + subject.state.loaded_volumes[labware_id][well_name_2].last_loaded == timestamp + ) + assert ( + subject.state.loaded_volumes[labware_id][well_name_2].operations_since_load == 1 + ) + + +def test_handles_liquid_probe_and_aspirate(subject: WellStore) -> None: + """It should populate the well state after liquid probe and update the well state after aspirate.""" + pipette_id = "pipette-id" + labware_id = "labware-id" + well_name = "well-name" + aspirated_volume = 10.0 liquid_probe = create_liquid_probe_command() + aspirate = create_aspirate_command( + pipette_id=pipette_id, + volume=aspirated_volume, + flow_rate=1.0, + labware_id=labware_id, + well_name=well_name, + ) + timestamp = datetime(year=2020, month=1, day=2) subject.handle_action( - SucceedCommandAction(private_result=None, command=liquid_probe) + SucceedCommandAction( + command=liquid_probe, + state_update=update_types.StateUpdate( + liquid_probed=update_types.LiquidProbedUpdate( + labware_id="labware-id", + well_name="well-name", + height=15.0, + volume=30.0, + last_probed=timestamp, + ) + ), + ) + ) + subject.handle_action( + SucceedCommandAction( + command=aspirate, + state_update=update_types.StateUpdate( + liquid_operated=update_types.LiquidOperatedUpdate( + labware_id="labware-id", + well_name="well-name", + volume_added=-aspirated_volume, + ) + ), + ) ) - assert len(subject.state.measured_liquid_heights) == 1 + assert len(subject.state.probed_heights[labware_id]) == 0 + assert len(subject.state.probed_volumes) == 1 - assert subject.state.measured_liquid_heights[labware_id][well_name].height == 0.5 + assert subject.state.probed_volumes[labware_id][well_name].volume == 20.0 + assert subject.state.probed_volumes[labware_id][well_name].last_probed == timestamp + assert ( + subject.state.probed_volumes[labware_id][well_name].operations_since_probe == 1 + ) diff --git a/api/tests/opentrons/protocol_engine/state/test_well_view.py b/api/tests/opentrons/protocol_engine/state/test_well_view.py index 3bd86e9dcb9..5025e4ee93e 100644 --- a/api/tests/opentrons/protocol_engine/state/test_well_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_well_view.py @@ -1,6 +1,10 @@ """Well view tests.""" from datetime import datetime -from opentrons.protocol_engine.types import LiquidHeightInfo +from opentrons.protocol_engine.types import ( + LoadedVolumeInfo, + ProbedHeightInfo, + ProbedVolumeInfo, +) import pytest from opentrons.protocol_engine.state.wells import WellState, WellView @@ -8,44 +12,47 @@ @pytest.fixture def subject() -> WellView: """Get a well view test subject.""" - labware_id = "labware-id" - well_name = "well-name" - height_info = LiquidHeightInfo(height=0.5, last_measured=datetime.now()) - state = WellState(measured_liquid_heights={labware_id: {well_name: height_info}}) + loaded_volume_info = LoadedVolumeInfo( + volume=30.0, last_loaded=datetime.now(), operations_since_load=0 + ) + probed_height_info = ProbedHeightInfo(height=5.5, last_probed=datetime.now()) + probed_volume_info = ProbedVolumeInfo( + volume=25.0, last_probed=datetime.now(), operations_since_probe=0 + ) + state = WellState( + loaded_volumes={"labware_id_1": {"well_name": loaded_volume_info}}, + probed_heights={"labware_id_2": {"well_name": probed_height_info}}, + probed_volumes={"labware_id_2": {"well_name": probed_volume_info}}, + ) return WellView(state) -def test_get_all(subject: WellView) -> None: - """Should return a list of well heights.""" - assert subject.get_all()[0].height == 0.5 - - -def test_get_last_measured_liquid_height(subject: WellView) -> None: - """Should return the height of a single well correctly for valid wells.""" - labware_id = "labware-id" - well_name = "well-name" - - invalid_labware_id = "invalid-labware-id" - invalid_well_name = "invalid-well-name" - - assert ( - subject.get_last_measured_liquid_height(invalid_labware_id, invalid_well_name) - is None +def test_get_well_liquid_info(subject: WellView) -> None: + """Should return a tuple of well infos.""" + volume_info = subject.get_well_liquid_info( + labware_id="labware_id_1", well_name="well_name" ) - assert subject.get_last_measured_liquid_height(labware_id, well_name) == 0.5 + assert volume_info.loaded_volume is not None + assert volume_info.probed_height is None + assert volume_info.probed_volume is None + assert volume_info.loaded_volume.volume == 30.0 + volume_info = subject.get_well_liquid_info( + labware_id="labware_id_2", well_name="well_name" + ) + assert volume_info.loaded_volume is None + assert volume_info.probed_height is not None + assert volume_info.probed_volume is not None + assert volume_info.probed_height.height == 5.5 + assert volume_info.probed_volume.volume == 25.0 -def test_has_measured_liquid_height(subject: WellView) -> None: - """Should return True for measured wells and False for ones that have no measurements.""" - labware_id = "labware-id" - well_name = "well-name" - invalid_labware_id = "invalid-labware-id" - invalid_well_name = "invalid-well-name" +def test_get_all(subject: WellView) -> None: + """Should return a list of well summaries.""" + summaries = subject.get_all() - assert ( - subject.has_measured_liquid_height(invalid_labware_id, invalid_well_name) - is False - ) - assert subject.has_measured_liquid_height(labware_id, well_name) is True + assert len(summaries) == 2, f"{summaries}" + assert summaries[0].loaded_volume == 30.0 + assert summaries[1].probed_height == 5.5 + assert summaries[1].probed_volume == 25.0 diff --git a/api/tests/opentrons/protocol_engine/test_protocol_engine.py b/api/tests/opentrons/protocol_engine/test_protocol_engine.py index 71e23cfe715..bc581114ab2 100644 --- a/api/tests/opentrons/protocol_engine/test_protocol_engine.py +++ b/api/tests/opentrons/protocol_engine/test_protocol_engine.py @@ -10,6 +10,7 @@ from opentrons_shared_data.robot.types import RobotType from opentrons.protocol_engine.actions.actions import SetErrorRecoveryPolicyAction +from opentrons.protocol_engine.state.update_types import StateUpdate from opentrons.types import DeckSlotName from opentrons.hardware_control import HardwareControlAPI, OT2HardwareControlAPI from opentrons.hardware_control.modules import MagDeck, TempDeck @@ -38,7 +39,11 @@ HardwareStopper, DoorWatcher, ) -from opentrons.protocol_engine.resources import ModelUtils, ModuleDataProvider +from opentrons.protocol_engine.resources import ( + FileProvider, + ModelUtils, + ModuleDataProvider, +) from opentrons.protocol_engine.state.config import Config from opentrons.protocol_engine.state.state import StateStore from opentrons.protocol_engine.plugins import AbstractPlugin, PluginStarter @@ -118,6 +123,12 @@ def module_data_provider(decoy: Decoy) -> ModuleDataProvider: return decoy.mock(cls=ModuleDataProvider) +@pytest.fixture +def file_provider(decoy: Decoy) -> FileProvider: + """Get a mock FileProvider.""" + return decoy.mock(cls=FileProvider) + + @pytest.fixture(autouse=True) def _mock_slot_standardization_module( decoy: Decoy, monkeypatch: pytest.MonkeyPatch @@ -148,6 +159,7 @@ def subject( hardware_stopper: HardwareStopper, door_watcher: DoorWatcher, module_data_provider: ModuleDataProvider, + file_provider: FileProvider, ) -> ProtocolEngine: """Get a ProtocolEngine test subject with its dependencies stubbed out.""" return ProtocolEngine( @@ -160,6 +172,7 @@ def subject( hardware_stopper=hardware_stopper, door_watcher=door_watcher, module_data_provider=module_data_provider, + file_provider=file_provider, ) @@ -613,20 +626,31 @@ def test_pause( ) +@pytest.mark.parametrize("reconcile_false_positive", [True, False]) def test_resume_from_recovery( decoy: Decoy, state_store: StateStore, action_dispatcher: ActionDispatcher, subject: ProtocolEngine, + reconcile_false_positive: bool, ) -> None: """It should dispatch a ResumeFromRecoveryAction.""" - expected_action = ResumeFromRecoveryAction() + decoy.when(state_store.commands.get_state_update_for_false_positive()).then_return( + sentinel.state_update_for_false_positive + ) + empty_state_update = StateUpdate() + + expected_action = ResumeFromRecoveryAction( + sentinel.state_update_for_false_positive + if reconcile_false_positive + else empty_state_update + ) decoy.when( state_store.commands.validate_action_allowed(expected_action) ).then_return(expected_action) - subject.resume_from_recovery() + subject.resume_from_recovery(reconcile_false_positive) decoy.verify(action_dispatcher.dispatch(expected_action)) @@ -1109,21 +1133,18 @@ def test_add_liquid( decoy: Decoy, action_dispatcher: ActionDispatcher, subject: ProtocolEngine, + state_store: StateStore, ) -> None: """It should dispatch an AddLiquidAction action.""" + liquid_obj = Liquid(id="water-id", displayName="water", description="water desc") + decoy.when( + state_store.liquid.validate_liquid_allowed(liquid=liquid_obj) + ).then_return(liquid_obj) subject.add_liquid( id="water-id", name="water", description="water desc", color=None ) - decoy.verify( - action_dispatcher.dispatch( - AddLiquidAction( - liquid=Liquid( - id="water-id", displayName="water", description="water desc" - ) - ) - ) - ) + decoy.verify(action_dispatcher.dispatch(AddLiquidAction(liquid=liquid_obj))) async def test_use_attached_temp_and_mag_modules( diff --git a/api/tests/opentrons/protocol_runner/test_legacy_command_mapper.py b/api/tests/opentrons/protocol_runner/test_legacy_command_mapper.py index 8663c3e0a8d..42c589ba7d3 100644 --- a/api/tests/opentrons/protocol_runner/test_legacy_command_mapper.py +++ b/api/tests/opentrons/protocol_runner/test_legacy_command_mapper.py @@ -6,6 +6,7 @@ from opentrons.protocol_engine.state.update_types import ( LoadPipetteUpdate, LoadedLabwareUpdate, + PipetteConfigUpdate, StateUpdate, ) import pytest @@ -116,7 +117,6 @@ def test_map_after_command() -> None: assert result == [ pe_actions.SucceedCommandAction( - private_result=None, command=pe_commands.Comment.construct( id="command.COMMENT-0", key="command.COMMENT-0", @@ -240,7 +240,6 @@ def test_command_stack() -> None: command_id="command.COMMENT-1", started_at=matchers.IsA(datetime) ), pe_actions.SucceedCommandAction( - private_result=None, command=pe_commands.Comment.construct( id="command.COMMENT-0", key="command.COMMENT-0", @@ -320,7 +319,6 @@ def test_map_labware_load(minimal_labware_def: LabwareDefinition) -> None: ), notes=[], ), - private_result=None, state_update=StateUpdate( loaded_labware=LoadedLabwareUpdate( labware_id="labware-0", @@ -380,16 +378,18 @@ def test_map_instrument_load(decoy: Decoy) -> None: result=pe_commands.LoadPipetteResult(pipetteId="pipette-0"), notes=[], ), - private_result=pe_commands.LoadPipettePrivateResult( - pipette_id="pipette-0", serial_number="fizzbuzz", config=pipette_config - ), state_update=StateUpdate( loaded_pipette=LoadPipetteUpdate( pipette_id="pipette-0", mount=expected_params.mount, pipette_name=expected_params.pipetteName, liquid_presence_detection=expected_params.liquidPresenceDetection, - ) + ), + pipette_config=PipetteConfigUpdate( + pipette_id="pipette-0", + serial_number="fizzbuzz", + config=pipette_config, + ), ), ) @@ -456,7 +456,6 @@ def test_map_module_load( ), notes=[], ), - private_result=None, ) [result_queue, result_run, result_succeed] = LegacyCommandMapper( @@ -521,7 +520,6 @@ def test_map_module_labware_load(minimal_labware_def: LabwareDefinition) -> None ), notes=[], ), - private_result=None, state_update=StateUpdate( loaded_labware=LoadedLabwareUpdate( labware_id="labware-0", @@ -580,7 +578,6 @@ def test_map_pause() -> None: started_at=matchers.IsA(datetime), ), pe_actions.SucceedCommandAction( - private_result=None, command=pe_commands.WaitForResume.construct( id="command.PAUSE-0", key="command.PAUSE-0", diff --git a/api/tests/opentrons/protocol_runner/test_legacy_context_plugin.py b/api/tests/opentrons/protocol_runner/test_legacy_context_plugin.py index 1714064bfa5..0ccc616012a 100644 --- a/api/tests/opentrons/protocol_runner/test_legacy_context_plugin.py +++ b/api/tests/opentrons/protocol_runner/test_legacy_context_plugin.py @@ -163,18 +163,14 @@ async def test_command_broker_messages( decoy.when( mock_legacy_command_mapper.map_command(command=legacy_command) - ).then_return( - [pe_actions.SucceedCommandAction(engine_command, private_result=None)] - ) + ).then_return([pe_actions.SucceedCommandAction(engine_command)]) await to_thread.run_sync(handler, legacy_command) await subject.teardown() decoy.verify( - mock_action_dispatcher.dispatch( - pe_actions.SucceedCommandAction(engine_command, private_result=None) - ) + mock_action_dispatcher.dispatch(pe_actions.SucceedCommandAction(engine_command)) ) @@ -222,9 +218,7 @@ async def test_equipment_broker_messages( decoy.when( mock_legacy_command_mapper.map_equipment_load(load_info=load_info) - ).then_return( - [pe_actions.SucceedCommandAction(command=engine_command, private_result=None)] - ) + ).then_return([pe_actions.SucceedCommandAction(command=engine_command)]) await to_thread.run_sync(handler, load_info) @@ -232,6 +226,6 @@ async def test_equipment_broker_messages( decoy.verify( mock_action_dispatcher.dispatch( - pe_actions.SucceedCommandAction(command=engine_command, private_result=None) + pe_actions.SucceedCommandAction(command=engine_command) ), ) diff --git a/api/tests/opentrons/protocol_runner/test_protocol_runner.py b/api/tests/opentrons/protocol_runner/test_protocol_runner.py index cd945c33e64..2f06e27c2c2 100644 --- a/api/tests/opentrons/protocol_runner/test_protocol_runner.py +++ b/api/tests/opentrons/protocol_runner/test_protocol_runner.py @@ -313,9 +313,16 @@ def test_resume_from_recovery( subject: AnyRunner, ) -> None: """It should call `resume_from_recovery()` on the underlying engine.""" - subject.resume_from_recovery() + subject.resume_from_recovery( + reconcile_false_positive=sentinel.reconcile_false_positive + ) - decoy.verify(protocol_engine.resume_from_recovery(), times=1) + decoy.verify( + protocol_engine.resume_from_recovery( + reconcile_false_positive=sentinel.reconcile_false_positive + ), + times=1, + ) async def test_run_json_runner( diff --git a/app-shell-odd/src/__tests__/http.test.ts b/app-shell-odd/src/__tests__/http.test.ts index 7b2c72578c0..c7ea4443a96 100644 --- a/app-shell-odd/src/__tests__/http.test.ts +++ b/app-shell-odd/src/__tests__/http.test.ts @@ -9,6 +9,7 @@ import type { Request, Response } from 'node-fetch' vi.mock('../config') vi.mock('node-fetch') +vi.mock('../log') describe('app-shell main http module', () => { beforeEach(() => { diff --git a/app-shell-odd/src/__tests__/update.test.ts b/app-shell-odd/src/__tests__/update.test.ts deleted file mode 100644 index 26adb67684b..00000000000 --- a/app-shell-odd/src/__tests__/update.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -// app-shell self-update tests -import { when } from 'vitest-when' -import { describe, it, vi, beforeEach, afterEach, expect } from 'vitest' -import * as http from '../http' -import { registerUpdate, FLEX_MANIFEST_URL } from '../update' -import * as Cfg from '../config' - -import type { Dispatch } from '../types' - -vi.unmock('electron-updater') -vi.mock('electron-updater') -vi.mock('../log') -vi.mock('../config') -vi.mock('../http') -vi.mock('fs-extra') - -describe('update', () => { - let dispatch: Dispatch - let handleAction: Dispatch - - beforeEach(() => { - dispatch = vi.fn() - handleAction = registerUpdate(dispatch) - }) - - afterEach(() => { - vi.resetAllMocks() - }) - - it('handles shell:CHECK_UPDATE with available update', () => { - when(vi.mocked(Cfg.getConfig)) - // @ts-expect-error getConfig mock not recognizing correct type overload - .calledWith('update') - .thenReturn({ - channel: 'latest', - } as any) - - when(vi.mocked(http.fetchJson)) - .calledWith(FLEX_MANIFEST_URL) - .thenResolve({ production: { '5.0.0': {}, '6.0.0': {} } }) - handleAction({ type: 'shell:CHECK_UPDATE', meta: { shell: true } }) - - expect(vi.mocked(Cfg.getConfig)).toHaveBeenCalledWith('update') - - expect(vi.mocked(http.fetchJson)).toHaveBeenCalledWith(FLEX_MANIFEST_URL) - }) -}) diff --git a/app-shell-odd/src/actions.ts b/app-shell-odd/src/actions.ts index 588dc88b3e4..bb7c0450210 100644 --- a/app-shell-odd/src/actions.ts +++ b/app-shell-odd/src/actions.ts @@ -119,6 +119,7 @@ import type { export const configInitialized = (config: Config): ConfigInitializedAction => ({ type: CONFIG_INITIALIZED, payload: { config }, + meta: { shell: true }, }) // config value has been updated @@ -128,6 +129,7 @@ export const configValueUpdated = ( ): ConfigValueUpdatedAction => ({ type: VALUE_UPDATED, payload: { path, value }, + meta: { shell: true }, }) export const customLabwareList = ( diff --git a/app-shell-odd/src/config/index.ts b/app-shell-odd/src/config/index.ts index df8e0cf317d..a67655976d9 100644 --- a/app-shell-odd/src/config/index.ts +++ b/app-shell-odd/src/config/index.ts @@ -5,7 +5,6 @@ import get from 'lodash/get' import forEach from 'lodash/forEach' import mergeOptions from 'merge-options' import yargsParser from 'yargs-parser' - import { UI_INITIALIZED } from '../constants' import * as Cfg from '../constants' import { configInitialized, configValueUpdated } from '../actions' @@ -13,6 +12,7 @@ import systemd from '../systemd' import { createLogger } from '../log' import { DEFAULTS_V12, migrate } from './migrate' import { shouldUpdate, getNextValue } from './update' +import { setUserDataPath } from '../early' import type { ConfigV12, @@ -24,8 +24,6 @@ import type { Config, Overrides } from './types' export * from './types' -export const ODD_DIR = '/data/ODD' - // make sure all arguments are included in production const argv = process.argv0.endsWith('defaultApp') ? process.argv.slice(2) @@ -48,8 +46,7 @@ const store = (): Store => { // perform store migration if loading for the first time _store = (new Store({ defaults: DEFAULTS_V12, - // dont overwrite config dir if in dev mode because it causes issues - ...(process.env.NODE_ENV === 'production' && { cwd: ODD_DIR }), + cwd: setUserDataPath(), }) as unknown) as Store _store.store = migrate((_store.store as unknown) as ConfigV12) } @@ -66,7 +63,14 @@ const log = (): Logger => _log ?? (_log = createLogger('config')) export function registerConfig(dispatch: Dispatch): (action: Action) => void { return function handleIncomingAction(action: Action) { if (action.type === UI_INITIALIZED) { + log().info('initializing configuration') dispatch(configInitialized(getFullConfig())) + log().info( + `flow route: ${ + getConfig('onDeviceDisplaySettings').unfinishedUnboxingFlowRoute + }` + ) + log().info('configuration initialized') } else if ( action.type === Cfg.UPDATE_VALUE || action.type === Cfg.RESET_VALUE || @@ -120,8 +124,8 @@ export function getOverrides(path?: string): unknown { return path != null ? get(overrides(), path) : overrides() } -export function getConfig

(path: P): Config[P] export function getConfig(): Config +export function getConfig

(path: P): Config[P] export function getConfig(path?: any): any { const result = store().get(path) const over = getOverrides(path as string | undefined) diff --git a/app-shell-odd/src/constants.ts b/app-shell-odd/src/constants.ts index a78e9274ae0..8b92e639cf6 100644 --- a/app-shell-odd/src/constants.ts +++ b/app-shell-odd/src/constants.ts @@ -257,3 +257,5 @@ export const FAILURE_STATUSES = { } as const export const SEND_FILE_PATHS: 'shell:SEND_FILE_PATHS' = 'shell:SEND_FILE_PATHS' + +export const ODD_DATA_DIR = '/data/ODD' diff --git a/app-shell-odd/src/early.ts b/app-shell-odd/src/early.ts new file mode 100644 index 00000000000..134c8957804 --- /dev/null +++ b/app-shell-odd/src/early.ts @@ -0,0 +1,22 @@ +// things intended to execute early in app-shell initialization +// do as little as possible in this file and do none of it at import time + +import { app } from 'electron' +import { ODD_DATA_DIR } from './constants' + +let path: string + +export const setUserDataPath = (): string => { + if (path == null) { + console.log( + `node env is ${process.env.NODE_ENV}, path is ${app.getPath('userData')}` + ) + if (process.env.NODE_ENV === 'production') { + console.log(`setting app path to ${ODD_DATA_DIR}`) + app.setPath('userData', ODD_DATA_DIR) + } + path = app.getPath('userData') + console.log(`app path becomes ${app.getPath('userData')}`) + } + return app.getPath('userData') +} diff --git a/app-shell-odd/src/http.ts b/app-shell-odd/src/http.ts index 6392340fbe7..90d01530da8 100644 --- a/app-shell-odd/src/http.ts +++ b/app-shell-odd/src/http.ts @@ -7,10 +7,13 @@ import FormData from 'form-data' import { Transform } from 'stream' import { HTTP_API_VERSION } from './constants' +import { createLogger } from './log' import type { Readable } from 'stream' import type { Request, RequestInit, Response } from 'node-fetch' +const log = createLogger('http') + type RequestInput = Request | string export interface DownloadProgress { @@ -18,6 +21,16 @@ export interface DownloadProgress { size: number | null } +export class LocalAbortError extends Error { + declare readonly name: 'LocalAbortError' + declare readonly type: 'aborted' + constructor(message: string) { + super(message) + this.name = 'LocalAbortError' + this.type = 'aborted' + } +} + export function fetch( input: RequestInput, init?: RequestInit @@ -35,21 +48,29 @@ export function fetch( }) } -export function fetchJson(input: RequestInput): Promise { - return fetch(input).then(response => response.json()) +export function fetchJson( + input: RequestInput, + init?: RequestInit +): Promise { + return fetch(input, init).then(response => response.json()) +} + +export function fetchText(input: Request, init?: RequestInit): Promise { + return fetch(input, init).then(response => response.text()) } -export function fetchText(input: Request): Promise { - return fetch(input).then(response => response.text()) +export interface FetchToFileOptions { + onProgress: (progress: DownloadProgress) => unknown + signal: AbortSignal } // TODO(mc, 2019-07-02): break this function up and test its components export function fetchToFile( input: RequestInput, destination: string, - options?: Partial<{ onProgress: (progress: DownloadProgress) => unknown }> + options?: Partial ): Promise { - return fetch(input).then(response => { + return fetch(input, { signal: options?.signal }).then(response => { let downloaded = 0 const size = Number(response.headers.get('Content-Length')) || null @@ -75,13 +96,26 @@ export function fetchToFile( // pump calls stream.pipe, handles teardown if streams error, and calls // its callbacks when the streams are done pump(inputStream, progressReader, outputStream, error => { - // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions - if (error) { + const handleError = (problem: Error): void => { // if we error out, delete the temp dir to clean up - return remove(destination).then(() => { + log.error(`Aborting fetchToFile: ${problem.name}: ${problem.message}`) + remove(destination).then(() => { reject(error) }) } + const listener = (): void => { + handleError( + new LocalAbortError( + (options?.signal?.reason as string | null) ?? 'aborted' + ) + ) + } + options?.signal?.addEventListener('abort', listener, { once: true }) + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions + if (error) { + handleError(error) + } + options?.signal?.removeEventListener('abort', listener, {}) resolve(destination) }) }) diff --git a/app-shell-odd/src/log.ts b/app-shell-odd/src/log.ts index 0c6a087be3f..100c7f275fb 100644 --- a/app-shell-odd/src/log.ts +++ b/app-shell-odd/src/log.ts @@ -4,13 +4,13 @@ import path from 'path' import dateFormat from 'dateformat' import winston from 'winston' +import { setUserDataPath } from './early' import { getConfig } from './config' import type Transport from 'winston-transport' import type { Config } from './config' -const ODD_DIR = '/data/ODD' -const LOG_DIR = path.join(ODD_DIR, 'logs') +const LOG_DIR = path.join(setUserDataPath(), 'logs') const ERROR_LOG = path.join(LOG_DIR, 'error.log') const COMBINED_LOG = path.join(LOG_DIR, 'combined.log') diff --git a/app-shell-odd/src/main.ts b/app-shell-odd/src/main.ts index d271bb1dc87..ccb9ff61aa2 100644 --- a/app-shell-odd/src/main.ts +++ b/app-shell-odd/src/main.ts @@ -6,11 +6,7 @@ import path from 'path' import { createUi, waitForRobotServerAndShowMainWindow } from './ui' import { createLogger } from './log' import { registerDiscovery } from './discovery' -import { - registerUpdate, - updateLatestVersion, - registerUpdateBrightness, -} from './update' +import { registerUpdateBrightness } from './system' import { registerRobotSystemUpdate } from './system-update' import { registerAppRestart } from './restart' import { @@ -19,7 +15,6 @@ import { getOverrides, registerConfig, resetStore, - ODD_DIR, } from './config' import systemd from './systemd' import { registerDataFiles, watchForMassStorage } from './usb' @@ -28,7 +23,10 @@ import { establishBrokerConnection, closeBrokerConnection, } from './notifications' +import { setUserDataPath } from './early' +import { registerResourceMonitor } from './monitor' +import type { OTLogger } from './log' import type { BrowserWindow } from 'electron' import type { Action, Dispatch, Logger } from './types' import type { LogEntry } from 'winston' @@ -39,6 +37,7 @@ import type { LogEntry } from 'winston' * https://github.com/node-fetch/node-fetch/issues/1624 */ dns.setDefaultResultOrder('ipv4first') +setUserDataPath() systemd.sendStatus('starting app') const config = getConfig() @@ -87,12 +86,14 @@ function startUp(): void { log.info('Starting App') console.log('Starting App') const storeNeedsReset = fse.existsSync( - path.join(ODD_DIR, `_CONFIG_TO_BE_DELETED_ON_REBOOT`) + path.join(setUserDataPath(), `_CONFIG_TO_BE_DELETED_ON_REBOOT`) ) if (storeNeedsReset) { log.debug('store marked to be reset, resetting store') resetStore() - fse.removeSync(path.join(ODD_DIR, `_CONFIG_TO_BE_DELETED_ON_REBOOT`)) + fse.removeSync( + path.join(app.getPath('userData'), `_CONFIG_TO_BE_DELETED_ON_REBOOT`) + ) } systemd.sendStatus('loading app') process.on('uncaughtException', error => log.error('Uncaught: ', { error })) @@ -102,11 +103,28 @@ function startUp(): void { // wire modules to UI dispatches const dispatch: Dispatch = action => { - // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions - if (mainWindow) { - log.silly('Sending action via IPC to renderer', { action }) - mainWindow.webContents.send('dispatch', action) - } + // This function now dispatches actions to all the handlers in the app shell. That would make it + // vulnerable to infinite recursion: + // - handler handles action A + // - handler dispatches action A as a response (calls this function) + // - this function calls handler with action A + // By deferring to nextTick(), we would still be executing the code over and over but we should have + // broken the stack. + process.nextTick(() => { + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions + if (mainWindow) { + log.silly('Sending action via IPC to renderer', { action }) + mainWindow.webContents.send('dispatch', action) + } + log.debug( + `bouncing action ${action.type} to ${actionHandlers.length} handlers` + ) + // Make actions that are sourced from the shell also go to the app shell without needing + // round tripping. This call is the reason for the nextTick() above. + actionHandlers.forEach(handler => { + handler(action) + }) + }) } mainWindow = createUi(dispatch) @@ -114,16 +132,11 @@ function startUp(): void { void establishBrokerConnection() mainWindow.once('closed', () => (mainWindow = null)) - log.info('Fetching latest software version') - updateLatestVersion().catch((error: Error) => { - log.error('Error fetching latest software version: ', { error }) - }) - const actionHandlers: Dispatch[] = [ registerConfig(dispatch), registerDiscovery(dispatch), - registerUpdate(dispatch), registerRobotSystemUpdate(dispatch), + registerResourceMonitor(dispatch), registerAppRestart(), registerUpdateBrightness(), registerNotify(dispatch, mainWindow), @@ -143,8 +156,19 @@ function startUp(): void { log.info('First dispatch, showing') systemd.sendStatus('started') systemd.ready() - const stopWatching = watchForMassStorage(dispatch) - ipcMain.once('quit', stopWatching) + try { + const stopWatching = watchForMassStorage(dispatch) + ipcMain.once('quit', stopWatching) + } catch (err: any) { + if (err instanceof Error) { + console.log( + `Failed to watch for mass storage: ${err.name}: ${err.message}`, + err + ) + } else { + console.log(`Failed to watch for mass storage: ${err}`) + } + } // TODO: This is where we render the main window for the first time. See ui.ts // in the createUI function for more. if (!!!mainWindow) { @@ -155,7 +179,7 @@ function startUp(): void { }) } -function createRendererLogger(): Logger { +function createRendererLogger(): OTLogger { log.info('Creating renderer logger') const logger = createLogger('renderer') diff --git a/app-shell-odd/src/monitor/ResourceMonitor.ts b/app-shell-odd/src/monitor/ResourceMonitor.ts new file mode 100644 index 00000000000..e093ccbb716 --- /dev/null +++ b/app-shell-odd/src/monitor/ResourceMonitor.ts @@ -0,0 +1,305 @@ +import { exec } from 'child_process' +import { promises as fs } from 'fs' +import path from 'path' + +import { createLogger } from '../log' +import { UI_INITIALIZED } from '../constants' + +import type { Action, Dispatch } from '../types' + +export const PARENT_PROCESSES = [ + 'opentrons-robot-server.service', + 'opentrons-robot-app.service', +] as const +const REPORTING_INTERVAL_MS = 3600000 // 1 hour +const MAX_CMD_STR_LENGTH = 100 +const MAX_REPORTED_PROCESSES = 15 + +interface ProcessTreeNode { + pid: number + cmd: string + children: ProcessTreeNode[] +} + +interface ProcessDetails { + name: string + memRssMb: string +} + +interface ResourceMonitorDetails { + systemAvailMemMb: string + systemUptimeHrs: string + processesDetails: ProcessDetails[] +} + +interface ResourceMonitorOptions { + procPath?: string +} + +// Scrapes system and select process resource metrics, reporting those metrics to the browser layer. +// Note that only MAX_REPORTED_PROCESSES are actually dispatched. +export class ResourceMonitor { + private readonly monitoredProcesses: Set + private readonly log: ReturnType + private readonly procPath: string + private intervalId: NodeJS.Timeout | null + + constructor(options: ResourceMonitorOptions = {}) { + this.monitoredProcesses = new Set(PARENT_PROCESSES) + this.log = createLogger('monitor') + this.intervalId = null + this.procPath = options.procPath ?? '/proc' // Override used for testing purposes. + } + + start(dispatch: Dispatch): Dispatch { + // Scrape and report metrics on an interval. + const beginMonitor = (): void => { + if (this.intervalId == null) { + this.intervalId = setInterval(() => { + this.getResourceDetails() + .then(resourceDetails => { + this.log.debug('resource monitor report', { + resourceDetails, + }) + this.dispatchResourceDetails(resourceDetails, dispatch) + }) + .catch(error => { + this.log.error('Error monitoring process: ', error) + }) + }, REPORTING_INTERVAL_MS) + } else { + this.log.warn( + 'Attempted to start an already started instance of ResourceMonitor.' + ) + } + } + + return function handleAction(action: Action) { + switch (action.type) { + case UI_INITIALIZED: + beginMonitor() + } + } + } + + // Manually stop reporting, clearing internal state. + stop(): void { + if (this.intervalId != null) { + clearInterval(this.intervalId) + this.intervalId = null + this.monitoredProcesses.clear() + } + } + + private dispatchResourceDetails( + details: ResourceMonitorDetails, + dispatch: Dispatch + ): void { + const { processesDetails, systemUptimeHrs, systemAvailMemMb } = details + dispatch({ + type: 'analytics:RESOURCE_MONITOR_REPORT', + payload: { + systemUptimeHrs, + systemAvailMemMb, + processesDetails: processesDetails.slice(0, MAX_REPORTED_PROCESSES), // don't accidentally send too many items to mixpanel. + }, + }) + } + + private getResourceDetails(): Promise { + return Promise.all([ + this.getSystemAvailableMemory(), + this.getSystemUptimeHrs(), + this.getProcessDetails(), + ]).then(([systemAvailMemMb, systemUptimeHrs, processesDetails]) => ({ + systemAvailMemMb, + systemUptimeHrs, + processesDetails, + })) + } + + // Scrape system uptime from /proc/uptime. + private getSystemUptimeHrs(): Promise { + return fs + .readFile(path.join(this.procPath, 'uptime'), 'utf8') + .then(uptime => { + // First value is uptime in seconds, second is idle time + const uptimeSeconds = Math.floor(parseFloat(uptime.split(' ')[0])) + return (uptimeSeconds / 3600).toFixed(2) + }) + .catch(error => { + throw new Error( + `Failed to read system uptime: ${ + error instanceof Error ? error.message : String(error) + }` + ) + }) + } + + // Scrape system available memory from /proc/meminfo. + private getSystemAvailableMemory(): Promise { + return fs + .readFile(path.join(this.procPath, 'meminfo'), 'utf8') + .then(meminfo => { + const match = meminfo.match(/MemAvailable:\s+(\d+)\s+kB/) + if (match == null) { + throw new Error('Could not find MemAvailable in meminfo file') + } else { + const memInKb = parseInt(match[1], 10) + return (memInKb / 1024).toFixed(2) + } + }) + .catch(error => { + throw new Error( + `Failed to read available memory info: ${ + error instanceof Error ? error.message : String(error) + }` + ) + }) + } + + // Given parent process names, get metrics for parent and all spawned processes. + private getProcessDetails(): Promise { + return Promise.all( + Array.from(this.monitoredProcesses).map(parentProcess => + this.getProcessTree(parentProcess) + .then(processTree => { + if (processTree == null) { + return [] + } else { + return this.getProcessDetailsFlattened(processTree) + } + }) + .catch(error => { + this.log.error('Failed to get process tree', { + parentProcess, + error, + }) + return [] + }) + ) + ).then(detailsArrays => detailsArrays.flat()) + } + + private getProcessTree( + parentProcess: string + ): Promise { + return this.getProcessPid(parentProcess).then(parentPid => { + if (parentPid == null) { + return null + } else { + return this.buildProcessTree(parentPid) + } + }) + } + + private getProcessPid(serviceName: string): Promise { + return new Promise((resolve, reject) => { + exec(`systemctl show ${serviceName} -p MainPID`, (error, stdout) => { + if (error != null) { + reject( + new Error(`Failed to get PID for ${serviceName}: ${error.message}`) + ) + } else { + const match = stdout.match(/MainPID=(\d+)/) + + if (match == null) { + resolve(null) + } else { + const pid = parseInt(match[1], 10) + resolve(pid > 1 ? pid : null) + } + } + }) + }) + } + + // Recursively build the process tree, scraping the cmdline string for each pid. + private buildProcessTree(pid: number): Promise { + return Promise.all([ + this.getProcessCmdline(pid), + this.getChildProcessesFrom(pid), + ]).then(([cmd, childPids]) => { + return Promise.all( + childPids.map(childPid => this.buildProcessTree(childPid)) + ).then(children => ({ + pid, + cmd, + children, + })) + }) + } + + // Get the exact cmdline string for the given pid, truncating if necessary. + private getProcessCmdline(pid: number): Promise { + return fs + .readFile(path.join(this.procPath, String(pid), 'cmdline'), 'utf8') + .then(cmdline => { + const cmd = cmdline.replace(/\0/g, ' ').trim() + return cmd.length > MAX_CMD_STR_LENGTH + ? `${cmd.substring(0, MAX_CMD_STR_LENGTH)}...` + : cmd + }) + .catch(error => { + this.log.error(`Failed to read cmdline for PID ${pid}`, error) + return `PID ${pid}` + }) + } + + private getChildProcessesFrom(parentPid: number): Promise { + return new Promise((resolve, reject) => { + exec(`pgrep -P ${parentPid}`, (error, stdout) => { + // code 1 means no children found + if (error != null && error.code !== 1) { + reject(error) + } else { + const children = stdout + .trim() + .split('\n') + .filter(line => line.length > 0) + .map(pid => parseInt(pid, 10)) + + resolve(children) + } + }) + }) + } + + // Get the actual metric(s) for a given node and recursively get metric(s) for all child nodes. + private getProcessDetailsFlattened( + node: ProcessTreeNode + ): Promise { + return this.getProcessMemory(node.pid).then(memRssMb => { + const currentNodeDetails: ProcessDetails = { + name: node.cmd, + memRssMb, + } + + return Promise.all( + node.children.map(child => this.getProcessDetailsFlattened(child)) + ).then(childDetailsArrays => { + return [currentNodeDetails, ...childDetailsArrays.flat()] + }) + }) + } + + // Scrape VmRSS from /proc/pid/status for a given pid. + private getProcessMemory(pid: number): Promise { + return fs + .readFile(path.join(this.procPath, String(pid), 'status'), 'utf8') + .then(status => { + const match = status.match(/VmRSS:\s+(\d+)\s+kB/) + if (match == null) { + throw new Error('Could not find VmRSS in status file') + } else { + const memInKb = parseInt(match[1], 10) + return (memInKb / 1024).toFixed(2) + } + }) + .catch(error => { + throw new Error( + `Failed to read memory info for PID ${pid}: ${error.message}` + ) + }) + } +} diff --git a/app-shell-odd/src/monitor/__tests__/ResourceMonitor.test.ts b/app-shell-odd/src/monitor/__tests__/ResourceMonitor.test.ts new file mode 100644 index 00000000000..416ddd15204 --- /dev/null +++ b/app-shell-odd/src/monitor/__tests__/ResourceMonitor.test.ts @@ -0,0 +1,162 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck -- Get around private method access warnings. + +import path from 'path' +import fs from 'fs-extra' +import tempy from 'tempy' +import { + vi, + describe, + beforeEach, + afterEach, + afterAll, + it, + expect, +} from 'vitest' +import { exec } from 'child_process' + +import { ResourceMonitor, PARENT_PROCESSES } from '../ResourceMonitor' +import { UI_INITIALIZED } from '../../constants' + +vi.mock('child_process') +vi.mock('../../log', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + createLogger: () => ({ + debug: vi.fn(), + error: vi.fn(), + }), + } +}) + +describe('ResourceMonitor', () => { + let procDir: string + let monitor: ResourceMonitor + const tempDirs: string[] = [] + + beforeEach(async () => { + procDir = tempy.directory() + tempDirs.push(procDir) + + vi.mocked(exec).mockImplementation((cmd, callback) => { + if (cmd.startsWith('systemctl')) { + callback(null, 'MainPID=1234\n') + } else if (cmd.startsWith('pgrep')) { + callback({ code: 1 } as any, '') + } + return {} as any + }) + + // Populate mock files with some mock data. + await fs.writeFile(path.join(procDir, 'uptime'), '3600.00 7200.00\n') + await fs.writeFile( + path.join(procDir, 'meminfo'), + 'MemTotal: 8192000 kB\nMemAvailable: 4096000 kB\n' + ) + + const parentPidDir = path.join(procDir, '1234') + await fs.ensureDir(parentPidDir) + await fs.writeFile( + path.join(parentPidDir, 'cmdline'), + 'process1234\0arg1\0arg2' + ) + await fs.writeFile( + path.join(parentPidDir, 'status'), + 'Name:\tprocess\nVmRSS:\t2048 kB\n' + ) + + monitor = new ResourceMonitor({ procPath: procDir }) + }) + + afterEach(() => { + monitor.stop() + }) + + afterAll(() => { + vi.resetAllMocks() + return Promise.all(tempDirs.map(d => fs.remove(d))) + }) + + describe('getSystemUptimeHrs', () => { + it('reads and parses system uptime', () => { + return monitor.getResourceDetails().then(details => { + expect(details.systemUptimeHrs).toBe('1.00') + }) + }) + + it('handles error reading uptime file', async () => { + await fs.remove(path.join(procDir, 'uptime')) + await expect(monitor.getResourceDetails()).rejects.toThrow( + 'Failed to read system uptime' + ) + }) + }) + + describe('getSystemAvailableMemory', () => { + it('reads and parses available memory', () => { + return monitor.getResourceDetails().then(details => { + expect(details.systemAvailMemMb).toBe('4000.00') + }) + }) + + it('handles missing MemAvailable in meminfo', async () => { + await fs.writeFile( + path.join(procDir, 'meminfo'), + 'MemTotal: 8192000 kB\n' + ) + + await expect(monitor.getResourceDetails()).rejects.toThrow( + 'Could not find MemAvailable in meminfo file' + ) + }) + }) + + describe('getProcessDetails', () => { + it('collects process details for parent process', () => { + return monitor.getResourceDetails().then(details => { + expect(details.processesDetails).toHaveLength(PARENT_PROCESSES.length) + expect(details.processesDetails[0]).toEqual({ + name: 'process1234 arg1 arg2', + memRssMb: '2.00', + }) + }) + }) + + it('handles missing process', () => { + // Mock exec to return non-existent PID + vi.mocked(exec).mockImplementation((cmd, callback) => { + if (cmd.startsWith('systemctl')) { + callback(null, 'MainPID=9999\n') + } else { + callback({ code: 1 } as any, '') + } + return {} as any + }) + + return monitor.getResourceDetails().then(details => { + expect(details.processesDetails).toHaveLength(0) + }) + }) + + it('handles errors reading process details', async () => { + await fs.remove(path.join(procDir, '1234', 'status')) + await monitor.getResourceDetails().then(details => { + expect(details.processesDetails).toHaveLength(0) + }) + }) + }) + + describe('start', () => { + it(`handler correctly updates internal state when ${UI_INITIALIZED} is dispatched`, () => { + const dispatch = vi.fn() + const handler = monitor.start(dispatch) + + expect(typeof handler).toBe('function') + + handler({ type: UI_INITIALIZED }) + + expect(monitor.intervalId).not.toBeNull() + }) + }) +}) diff --git a/app-shell-odd/src/monitor/index.ts b/app-shell-odd/src/monitor/index.ts new file mode 100644 index 00000000000..8f257b19aa1 --- /dev/null +++ b/app-shell-odd/src/monitor/index.ts @@ -0,0 +1,8 @@ +import { ResourceMonitor } from './ResourceMonitor' + +import type { Dispatch } from '../types' + +export function registerResourceMonitor(dispatch: Dispatch): Dispatch { + const resourceMonitor = new ResourceMonitor() + return resourceMonitor.start(dispatch) +} diff --git a/app-shell-odd/src/system-update/__tests__/handler.test.ts b/app-shell-odd/src/system-update/__tests__/handler.test.ts new file mode 100644 index 00000000000..65769c93729 --- /dev/null +++ b/app-shell-odd/src/system-update/__tests__/handler.test.ts @@ -0,0 +1,777 @@ +// app-shell self-update tests +import { when } from 'vitest-when' +import { rm } from 'fs-extra' +import { describe, it, vi, beforeEach, afterEach, expect } from 'vitest' +import tempy from 'tempy' + +import * as Cfg from '../../config' +import { CONFIG_INITIALIZED, VALUE_UPDATED } from '../../constants' +import { + manageDriver, + createUpdateDriver, + CURRENT_SYSTEM_VERSION, +} from '../handler' +import { FLEX_MANIFEST_URL } from '../constants' +import { getSystemUpdateDir as _getSystemUpdateDir } from '../directories' +import { getProvider as _getWebProvider } from '../from-web' +import { getProvider as _getUsbProvider } from '../from-usb' + +import type { UpdateProvider } from '../types' +import type { UpdateDriver } from '../handler' +import type { WebUpdateSource } from '../from-web' +import type { USBUpdateSource } from '../from-usb' +import type { Dispatch } from '../../types' + +import type { + ConfigInitializedAction, + ConfigValueUpdatedAction, +} from '@opentrons/app/src/redux/config' + +vi.unmock('electron-updater') // ? +vi.mock('electron-updater') +vi.mock('../../log') +vi.mock('../../config') +vi.mock('../../http') +vi.mock('../directories') +vi.mock('../from-web') +vi.mock('../from-usb') + +const getSystemUpdateDir = vi.mocked(_getSystemUpdateDir) +const getConfig = vi.mocked(Cfg.getConfig) +const getWebProvider = vi.mocked(_getWebProvider) +const getUsbProvider = vi.mocked(_getUsbProvider) + +describe('update driver manager', () => { + let dispatch: Dispatch + let testDir: string = '' + beforeEach(() => { + const thisTd = tempy.directory() + testDir = thisTd + dispatch = vi.fn() + when(getSystemUpdateDir).calledWith().thenReturn(thisTd) + }) + + afterEach(() => { + vi.resetAllMocks() + const oldTd = testDir + testDir = '' + return oldTd === '' + ? new Promise(resolve => resolve()) + : rm(oldTd, { recursive: true, force: true }) + }) + + it('creates a driver once config is loaded', () => { + when(getConfig) + .calledWith('update') + .thenReturn(({ channel: 'alpha' } as any) as Cfg.Config['update']) + const driver = manageDriver(dispatch) + expect(driver.getUpdateDriver()).toBeNull() + expect(getConfig).not.toHaveBeenCalled() + return driver + .handleAction({ + type: CONFIG_INITIALIZED, + } as ConfigInitializedAction) + .then(() => { + expect(driver.getUpdateDriver()).not.toBeNull() + expect(getConfig).toHaveBeenCalledOnce() + expect(getWebProvider).toHaveBeenCalledWith({ + manifestUrl: FLEX_MANIFEST_URL, + channel: 'alpha', + updateCacheDirectory: testDir, + currentVersion: CURRENT_SYSTEM_VERSION, + }) + }) + }) + + it('reloads the web driver when appropriate', () => { + when(getConfig) + .calledWith('update') + .thenReturn(({ channel: 'alpha' } as any) as Cfg.Config['update']) + const fakeProvider = { + teardown: vi.fn(), + refreshUpdateCache: vi.fn(), + getUpdateDetails: vi.fn(), + lockUpdateCache: vi.fn(), + unlockUpdateCache: vi.fn(), + name: vi.fn(), + source: () => (({ channel: 'alpha' } as any) as WebUpdateSource), + } + const fakeProvider2 = { + ...fakeProvider, + source: () => (({ channel: 'beta' } as any) as WebUpdateSource), + } + when(getWebProvider) + .calledWith({ + manifestUrl: FLEX_MANIFEST_URL, + channel: 'alpha', + updateCacheDirectory: testDir, + currentVersion: CURRENT_SYSTEM_VERSION, + }) + .thenReturn(fakeProvider) + when(getWebProvider) + .calledWith({ + manifestUrl: FLEX_MANIFEST_URL, + channel: 'beta', + updateCacheDirectory: testDir, + currentVersion: CURRENT_SYSTEM_VERSION, + }) + .thenReturn(fakeProvider2) + const driverManager = manageDriver(dispatch) + return driverManager + .handleAction({ + type: CONFIG_INITIALIZED, + } as ConfigInitializedAction) + .then(() => { + expect(getWebProvider).toHaveBeenCalledWith({ + manifestUrl: FLEX_MANIFEST_URL, + channel: 'alpha', + updateCacheDirectory: testDir, + currentVersion: CURRENT_SYSTEM_VERSION, + }) + expect(driverManager.getUpdateDriver()).not.toBeNull() + when(fakeProvider.teardown).calledWith().thenResolve() + return driverManager.handleAction({ + type: VALUE_UPDATED, + } as ConfigValueUpdatedAction) + }) + .then(() => { + expect(getWebProvider).toHaveBeenCalledOnce() + when(getConfig) + .calledWith('update') + .thenReturn(({ + channel: 'beta', + } as any) as Cfg.Config['update']) + return driverManager.handleAction({ + type: VALUE_UPDATED, + } as ConfigValueUpdatedAction) + }) + .then(() => { + expect(getWebProvider).toHaveBeenCalledWith({ + manifestUrl: FLEX_MANIFEST_URL, + channel: 'alpha', + updateCacheDirectory: testDir, + currentVersion: CURRENT_SYSTEM_VERSION, + }) + }) + }) +}) + +describe('update driver', () => { + let dispatch: Dispatch + let testDir: string = '' + let subject: UpdateDriver | null = null + const fakeProvider: UpdateProvider = { + teardown: vi.fn(), + refreshUpdateCache: vi.fn(), + getUpdateDetails: vi.fn(), + lockUpdateCache: vi.fn(), + unlockUpdateCache: vi.fn(), + name: vi.fn(), + source: () => (({ channel: 'alpha' } as any) as WebUpdateSource), + } + const fakeUsbProviders: Record> = { + first: { + teardown: vi.fn(), + refreshUpdateCache: vi.fn(), + getUpdateDetails: vi.fn(), + lockUpdateCache: vi.fn(), + unlockUpdateCache: vi.fn(), + name: () => '/some/usb/path', + source: () => + (({ + massStorageRootPath: '/some/usb/path', + } as any) as USBUpdateSource), + }, + } + + beforeEach(() => { + const thisTd = tempy.directory() + testDir = thisTd + dispatch = vi.fn() + when(getSystemUpdateDir).calledWith().thenReturn(thisTd) + when(getConfig) + .calledWith('update') + .thenReturn(({ channel: 'alpha' } as any) as Cfg.Config['update']) + when(getWebProvider) + .calledWith({ + manifestUrl: FLEX_MANIFEST_URL, + channel: 'alpha', + updateCacheDirectory: testDir, + currentVersion: CURRENT_SYSTEM_VERSION, + }) + .thenReturn(fakeProvider) + fakeUsbProviders.first = { + teardown: vi.fn(), + refreshUpdateCache: vi.fn(), + getUpdateDetails: vi.fn(), + lockUpdateCache: vi.fn(), + unlockUpdateCache: vi.fn(), + name: () => '/some/usb/path', + source: () => + (({ + massStorageRootPath: '/some/usb/path', + } as any) as USBUpdateSource), + } + fakeUsbProviders.second = { + teardown: vi.fn(), + refreshUpdateCache: vi.fn(), + getUpdateDetails: vi.fn(), + lockUpdateCache: vi.fn(), + unlockUpdateCache: vi.fn(), + name: () => '/some/other/usb/path', + source: () => + (({ + massStorageRootPath: '/some/other/usb/path', + } as any) as USBUpdateSource), + } + subject = createUpdateDriver(dispatch) + }) + + afterEach(() => { + vi.resetAllMocks() + const oldTd = testDir + testDir = '' + return ( + subject?.teardown() || new Promise(resolve => resolve()) + ).then(() => + oldTd === '' + ? new Promise(resolve => resolve()) + : rm(oldTd, { recursive: true, force: true }) + ) + }) + + it('checks updates when told to check updates', () => { + const thisSubject = subject as UpdateDriver + when(fakeProvider.refreshUpdateCache) + .calledWith(expect.any(Function)) + .thenDo( + progress => + new Promise(resolve => { + progress({ + version: null, + files: null, + downloadProgress: 0, + releaseNotes: null, + }) + resolve({ + version: null, + files: null, + downloadProgress: 0, + releaseNotes: null, + }) + }) + ) + return thisSubject + .handleAction({ type: 'shell:CHECK_UPDATE', meta: { shell: true } }) + .then(() => { + expect(dispatch).toHaveBeenCalledWith({ + type: 'robotUpdate:UPDATE_INFO', + payload: { + version: null, + releaseNotes: null, + force: false, + target: 'flex', + }, + }) + expect(dispatch).toHaveBeenCalledWith({ + type: 'robotUpdate:UPDATE_VERSION', + payload: { version: null, force: false, target: 'flex' }, + }) + }) + }) + it('forwards in-progress downloads when no USB updates are present', () => { + const thisSubject = subject as UpdateDriver + when(fakeProvider.refreshUpdateCache) + .calledWith(expect.any(Function)) + .thenDo( + progress => + new Promise(resolve => { + progress({ + version: null, + files: null, + downloadProgress: 0, + releaseNotes: null, + }) + progress({ + version: '1.2.3', + files: null, + downloadProgress: 0, + releaseNotes: null, + }) + progress({ + version: '1.2.3', + files: null, + downloadProgress: 50, + releaseNotes: null, + }) + progress({ + version: '1.2.3', + files: { + system: '/some/path', + releaseNotes: '/some/other/path', + }, + downloadProgress: 100, + releaseNotes: 'some release notes', + }) + resolve({ + version: '1.2.3', + files: { + system: '/some/path', + releaseNotes: '/some/other/path', + }, + downloadProgress: 100, + releaseNotes: 'some release notes', + }) + }) + ) + return thisSubject + .handleAction({ type: 'shell:CHECK_UPDATE', meta: { shell: true } }) + .then(() => { + expect(dispatch).toHaveBeenNthCalledWith(1, { + type: 'robotUpdate:UPDATE_VERSION', + payload: { version: '1.2.3', force: false, target: 'flex' }, + }) + expect(dispatch).toHaveBeenNthCalledWith(2, { + type: 'robotUpdate:DOWNLOAD_PROGRESS', + payload: { progress: 50, target: 'flex' }, + }) + expect(dispatch).toHaveBeenNthCalledWith(3, { + type: 'robotUpdate:UPDATE_INFO', + payload: { + version: '1.2.3', + releaseNotes: 'some release notes', + force: false, + target: 'flex', + }, + }) + expect(dispatch).toHaveBeenNthCalledWith(4, { + type: 'robotUpdate:UPDATE_VERSION', + payload: { version: '1.2.3', force: false, target: 'flex' }, + }) + expect(dispatch).toHaveBeenNthCalledWith(5, { + type: 'robotUpdate:UPDATE_INFO', + payload: { + version: '1.2.3', + releaseNotes: 'some release notes', + force: false, + target: 'flex', + }, + }) + expect(dispatch).toHaveBeenNthCalledWith(6, { + type: 'robotUpdate:UPDATE_VERSION', + payload: { version: '1.2.3', force: false, target: 'flex' }, + }) + }) + }) + it('creates a usb provider when it gets a message that a usb device was added', () => { + const thisSubject = subject as UpdateDriver + when(getUsbProvider) + .calledWith({ + currentVersion: CURRENT_SYSTEM_VERSION, + massStorageDeviceRoot: '/some/usb/path', + massStorageDeviceFiles: ['/some/file', '/some/other/file'], + }) + .thenReturn(fakeUsbProviders.first) + when(fakeUsbProviders.first.refreshUpdateCache) + .calledWith(expect.any(Function)) + .thenResolve({ + version: '1.2.3', + files: { system: '/some/file', releaseNotes: null }, + releaseNotes: 'some fake notes', + downloadProgress: 100, + }) + return thisSubject + .handleAction({ + type: 'shell:ROBOT_MASS_STORAGE_DEVICE_ENUMERATED', + payload: { + rootPath: '/some/usb/path', + filePaths: ['/some/file', '/some/other/file'], + }, + meta: { shell: true }, + }) + .then(() => { + expect(getUsbProvider).toHaveBeenCalledWith({ + currentVersion: CURRENT_SYSTEM_VERSION, + massStorageDeviceRoot: '/some/usb/path', + massStorageDeviceFiles: ['/some/file', '/some/other/file'], + }) + }) + }) + it('does not create a usb provider if it already has one for a path', () => { + const thisSubject = subject as UpdateDriver + when(getUsbProvider) + .calledWith({ + currentVersion: CURRENT_SYSTEM_VERSION, + massStorageDeviceRoot: '/some/usb/path', + massStorageDeviceFiles: ['/some/file', '/some/other/file'], + }) + .thenReturn(fakeUsbProviders.first) + when(fakeUsbProviders.first.refreshUpdateCache) + .calledWith(expect.any(Function)) + .thenResolve({ + version: '0.1.2', + files: { system: '/some/file', releaseNotes: null }, + releaseNotes: 'some fake notes', + downloadProgress: 100, + }) + when(fakeUsbProviders.first.getUpdateDetails) + .calledWith() + .thenReturn({ + version: '0.1.2', + files: { system: '/some/file', releaseNotes: null }, + releaseNotes: 'some fake notes', + downloadProgress: 100, + }) + return thisSubject + .handleAction({ + type: 'shell:ROBOT_MASS_STORAGE_DEVICE_ENUMERATED', + payload: { + rootPath: '/some/usb/path', + filePaths: ['/some/file', '/some/other/file'], + }, + meta: { shell: true }, + }) + .then(() => { + expect(getUsbProvider).toHaveBeenCalledWith({ + currentVersion: CURRENT_SYSTEM_VERSION, + massStorageDeviceRoot: '/some/usb/path', + massStorageDeviceFiles: ['/some/file', '/some/other/file'], + }) + return thisSubject.handleAction({ + type: 'shell:ROBOT_MASS_STORAGE_DEVICE_ENUMERATED', + payload: { + rootPath: '/some/usb/path', + filePaths: ['/some/file', '/some/other/file'], + }, + meta: { shell: true }, + }) + }) + .then(() => { + expect(getUsbProvider).toHaveBeenCalledOnce() + expect(dispatch).toHaveBeenCalledWith({ + type: 'robotUpdate:UPDATE_INFO', + payload: { + releaseNotes: 'some fake notes', + version: '0.1.2', + force: true, + target: 'flex', + }, + }) + expect(dispatch).toHaveBeenCalledWith({ + type: 'robotUpdate:UPDATE_VERSION', + payload: { + version: '0.1.2', + force: true, + target: 'flex', + }, + }) + }) + .then(() => { + vi.mocked(dispatch).mockReset() + return thisSubject.handleAction({ + type: 'robotUpdate:READ_SYSTEM_FILE', + payload: { target: 'flex' }, + meta: { shell: true }, + }) + }) + .then(() => { + expect(dispatch).toHaveBeenCalledWith({ + type: 'robotUpdate:FILE_INFO', + payload: { + systemFile: '/some/file', + version: '0.1.2', + isManualFile: false, + }, + }) + }) + }) + it('tears down a usb provider when it is removed', () => { + const thisSubject = subject as UpdateDriver + when(getUsbProvider) + .calledWith({ + currentVersion: CURRENT_SYSTEM_VERSION, + massStorageDeviceRoot: '/some/usb/path', + massStorageDeviceFiles: ['/some/file', '/some/other/file'], + }) + .thenReturn(fakeUsbProviders.first) + when(fakeUsbProviders.first.refreshUpdateCache) + .calledWith(expect.any(Function)) + .thenResolve({ + version: '1.2.3', + files: { system: '/some/file', releaseNotes: null }, + releaseNotes: 'some fake notes', + downloadProgress: 100, + }) + return thisSubject + .handleAction({ + type: 'shell:ROBOT_MASS_STORAGE_DEVICE_ENUMERATED', + payload: { + rootPath: '/some/usb/path', + filePaths: ['/some/file', '/some/other/file'], + }, + meta: { shell: true }, + }) + .then(() => { + expect(getUsbProvider).toHaveBeenCalledWith({ + currentVersion: CURRENT_SYSTEM_VERSION, + massStorageDeviceRoot: '/some/usb/path', + massStorageDeviceFiles: ['/some/file', '/some/other/file'], + }) + when(fakeUsbProviders.first.teardown).calledWith().thenResolve() + return thisSubject.handleAction({ + type: 'shell:ROBOT_MASS_STORAGE_DEVICE_REMOVED', + payload: { rootPath: '/some/usb/path' }, + meta: { shell: true }, + }) + }) + .then(() => { + expect(fakeUsbProviders.first.teardown).toHaveBeenCalledOnce() + }) + }) + it('re-adds a usb provider if it is inserted after being removed', () => { + const thisSubject = subject as UpdateDriver + when(getUsbProvider) + .calledWith({ + currentVersion: CURRENT_SYSTEM_VERSION, + massStorageDeviceRoot: '/some/usb/path', + massStorageDeviceFiles: ['/some/file', '/some/other/file'], + }) + .thenReturn(fakeUsbProviders.first) + when(fakeUsbProviders.first.refreshUpdateCache) + .calledWith(expect.any(Function)) + .thenResolve({ + version: '1.2.3', + files: { system: '/some/file', releaseNotes: null }, + releaseNotes: 'some fake notes', + downloadProgress: 100, + }) + return thisSubject + .handleAction({ + type: 'shell:ROBOT_MASS_STORAGE_DEVICE_ENUMERATED', + payload: { + rootPath: '/some/usb/path', + filePaths: ['/some/file', '/some/other/file'], + }, + meta: { shell: true }, + }) + .then(() => { + expect(getUsbProvider).toHaveBeenCalledWith({ + currentVersion: CURRENT_SYSTEM_VERSION, + massStorageDeviceRoot: '/some/usb/path', + massStorageDeviceFiles: ['/some/file', '/some/other/file'], + }) + when(fakeUsbProviders.first.teardown).calledWith().thenResolve() + return thisSubject.handleAction({ + type: 'shell:ROBOT_MASS_STORAGE_DEVICE_REMOVED', + payload: { rootPath: '/some/usb/path' }, + meta: { shell: true }, + }) + }) + .then(() => { + expect(fakeUsbProviders.first.teardown).toHaveBeenCalledOnce() + return thisSubject.handleAction({ + type: 'shell:ROBOT_MASS_STORAGE_DEVICE_ENUMERATED', + payload: { + rootPath: '/some/usb/path', + filePaths: ['/some/file', '/some/other/file'], + }, + meta: { shell: true }, + }) + }) + .then(() => { + expect(getUsbProvider).toHaveBeenCalledTimes(2) + }) + }) + it('prefers usb updates to web updates', () => { + const thisSubject = subject as UpdateDriver + when(getUsbProvider) + .calledWith({ + currentVersion: CURRENT_SYSTEM_VERSION, + massStorageDeviceRoot: '/some/usb/path', + massStorageDeviceFiles: ['/some/file', '/some/other/file'], + }) + .thenReturn(fakeUsbProviders.first) + when(fakeUsbProviders.first.getUpdateDetails) + .calledWith() + .thenReturn({ + version: '0.1.2', + files: { system: '/some/file', releaseNotes: null }, + releaseNotes: 'some fake notes', + downloadProgress: 100, + }) + when(fakeUsbProviders.first.refreshUpdateCache) + .calledWith(expect.any(Function)) + .thenResolve({ + version: '0.1.2', + files: { system: '/some/file', releaseNotes: null }, + releaseNotes: 'some fake notes', + downloadProgress: 100, + }) + when(fakeProvider.refreshUpdateCache) + .calledWith(expect.any(Function)) + .thenResolve({ + version: '1.2.3', + files: { + system: '/some/file/from/the/web', + releaseNotes: null, + }, + releaseNotes: 'some other notes', + downloadProgress: 100, + }) + return thisSubject + .handleAction({ + type: 'shell:ROBOT_MASS_STORAGE_DEVICE_ENUMERATED', + payload: { + rootPath: '/some/usb/path', + filePaths: ['/some/file', '/some/other/file'], + }, + meta: { shell: true }, + }) + .then(() => + thisSubject.handleAction({ + type: 'shell:CHECK_UPDATE', + meta: { shell: true }, + }) + ) + .then(() => { + expect(dispatch).toHaveBeenLastCalledWith({ + type: 'robotUpdate:UPDATE_VERSION', + payload: { version: '0.1.2', force: true, target: 'flex' }, + }) + }) + .then(() => { + vi.mocked(dispatch).mockReset() + return thisSubject.handleAction({ + type: 'robotUpdate:READ_SYSTEM_FILE', + payload: { target: 'flex' }, + meta: { shell: true }, + }) + }) + .then(() => { + expect(dispatch).toHaveBeenCalledWith({ + type: 'robotUpdate:FILE_INFO', + payload: { + systemFile: '/some/file', + version: '0.1.2', + isManualFile: false, + }, + }) + }) + }) + it('selects the highest version usb update', () => { + const thisSubject = subject as UpdateDriver + when(getUsbProvider) + .calledWith({ + currentVersion: CURRENT_SYSTEM_VERSION, + massStorageDeviceRoot: '/some/usb/path', + massStorageDeviceFiles: ['/some/file', '/some/other/file'], + }) + .thenReturn(fakeUsbProviders.first) + when(getUsbProvider) + .calledWith({ + currentVersion: CURRENT_SYSTEM_VERSION, + massStorageDeviceRoot: '/some/other/usb/path', + massStorageDeviceFiles: ['/some/third/file', '/some/fourth/file'], + }) + .thenReturn(fakeUsbProviders.second) + when(fakeUsbProviders.first.refreshUpdateCache) + .calledWith(expect.any(Function)) + .thenResolve({ + version: '1.2.3', + files: { system: '/some/file', releaseNotes: null }, + releaseNotes: 'some fake notes', + downloadProgress: 100, + }) + when(fakeUsbProviders.second.refreshUpdateCache) + .calledWith(expect.any(Function)) + .thenResolve({ + version: '0.1.2', + files: { system: '/some/other/file', releaseNotes: null }, + releaseNotes: 'some other fake notes', + downloadProgress: 100, + }) + when(fakeUsbProviders.first.getUpdateDetails) + .calledWith() + .thenReturn({ + version: '1.2.3', + files: { system: '/some/file', releaseNotes: null }, + releaseNotes: 'some fake notes', + downloadProgress: 100, + }) + when(fakeUsbProviders.second.getUpdateDetails) + .calledWith() + .thenReturn({ + version: '0.1.2', + files: { system: '/some/other/filefile', releaseNotes: null }, + releaseNotes: 'some other fake notes', + downloadProgress: 100, + }) + return thisSubject + .handleAction({ + type: 'shell:ROBOT_MASS_STORAGE_DEVICE_ENUMERATED', + payload: { + rootPath: '/some/usb/path', + filePaths: ['/some/file', '/some/other/file'], + }, + meta: { shell: true }, + }) + .then(() => { + expect(getUsbProvider).toHaveBeenCalledWith({ + currentVersion: CURRENT_SYSTEM_VERSION, + massStorageDeviceRoot: '/some/usb/path', + massStorageDeviceFiles: ['/some/file', '/some/other/file'], + }) + vi.mocked(dispatch).mockReset() + return thisSubject.handleAction({ + type: 'shell:ROBOT_MASS_STORAGE_DEVICE_ENUMERATED', + payload: { + rootPath: '/some/other/usb/path', + filePaths: ['/some/third/file', '/some/fourth/file'], + }, + meta: { shell: true }, + }) + }) + .then(() => { + expect(getUsbProvider).toHaveBeenCalledWith({ + currentVersion: CURRENT_SYSTEM_VERSION, + massStorageDeviceRoot: '/some/usb/path', + massStorageDeviceFiles: ['/some/file', '/some/other/file'], + }) + expect(dispatch).toHaveBeenNthCalledWith(1, { + type: 'robotUpdate:UPDATE_INFO', + payload: { + releaseNotes: 'some fake notes', + version: '1.2.3', + force: true, + target: 'flex', + }, + }) + expect(dispatch).toHaveBeenNthCalledWith(2, { + type: 'robotUpdate:UPDATE_VERSION', + payload: { + version: '1.2.3', + force: true, + target: 'flex', + }, + }) + }) + .then(() => { + vi.mocked(dispatch).mockReset() + return thisSubject.handleAction({ + type: 'robotUpdate:READ_SYSTEM_FILE', + payload: { target: 'flex' }, + meta: { shell: true }, + }) + }) + .then(() => { + expect(dispatch).toHaveBeenCalledWith({ + type: 'robotUpdate:FILE_INFO', + payload: { + systemFile: '/some/file', + version: '1.2.3', + isManualFile: false, + }, + }) + }) + }) +}) diff --git a/app-shell-odd/src/system-update/__tests__/release-files.test.ts b/app-shell-odd/src/system-update/__tests__/release-files.test.ts deleted file mode 100644 index bd2a421b910..00000000000 --- a/app-shell-odd/src/system-update/__tests__/release-files.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -// TODO(mc, 2020-06-11): test all release-files functions -import { vi, describe, it, expect, afterAll } from 'vitest' -import path from 'path' -import { promises as fs } from 'fs' -import fse from 'fs-extra' -import tempy from 'tempy' - -import { cleanupReleaseFiles } from '../release-files' -vi.mock('electron-store') -vi.mock('../../log') - -describe('system release files utilities', () => { - const tempDirs: string[] = [] - const makeEmptyDir = (): string => { - const dir: string = tempy.directory() - tempDirs.push(dir) - return dir - } - - afterAll(async () => { - await Promise.all(tempDirs.map(d => fse.remove(d))) - }) - - describe('cleanupReleaseFiles', () => { - it('should leave current version files alone', () => { - const dir = makeEmptyDir() - const releaseDir = path.join(dir, '4.0.0') - - return fs - .mkdir(releaseDir) - .then(() => cleanupReleaseFiles(dir, '4.0.0')) - .then(() => fs.readdir(dir)) - .then(files => { - expect(files).toEqual(['4.0.0']) - }) - }) - - it('should leave support files alone', () => { - const dir = makeEmptyDir() - const releaseDir = path.join(dir, '4.0.0') - const releaseManifest = path.join(dir, 'releases.json') - - return Promise.all([ - fs.mkdir(releaseDir), - fse.writeJson(releaseManifest, { hello: 'world' }), - ]) - .then(() => cleanupReleaseFiles(dir, '4.0.0')) - .then(() => fs.readdir(dir)) - .then(files => { - expect(files).toEqual(['4.0.0', 'releases.json']) - }) - }) - - it('should delete other directories', () => { - const dir = makeEmptyDir() - const releaseDir = path.join(dir, '4.0.0') - const oldReleaseDir = path.join(dir, '3.9.0') - const olderReleaseDir = path.join(dir, '3.8.0') - - return Promise.all([ - fs.mkdir(releaseDir), - fs.mkdir(oldReleaseDir), - fs.mkdir(olderReleaseDir), - ]) - .then(() => cleanupReleaseFiles(dir, '4.0.0')) - .then(() => fs.readdir(dir)) - .then(files => { - expect(files).toEqual(['4.0.0']) - }) - }) - }) -}) diff --git a/app-shell-odd/src/system-update/__tests__/release-manifest.test.ts b/app-shell-odd/src/system-update/__tests__/release-manifest.test.ts deleted file mode 100644 index 89091d2731c..00000000000 --- a/app-shell-odd/src/system-update/__tests__/release-manifest.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, it, vi, beforeEach, afterEach, expect } from 'vitest' -import * as Http from '../../http' -import * as Dirs from '../directories' -import { downloadAndCacheReleaseManifest } from '../release-manifest' - -vi.mock('../../http') -vi.mock('../directories') -vi.mock('../../log') -vi.mock('electron-store') -const fetchJson = Http.fetchJson -const getManifestCacheDir = Dirs.getManifestCacheDir - -const MOCK_DIR = 'mock_dir' -const MANIFEST_URL = 'http://example.com/releases.json' -const MOCK_MANIFEST = {} as any - -describe('release manifest utilities', () => { - beforeEach(() => { - vi.mocked(getManifestCacheDir).mockReturnValue(MOCK_DIR) - vi.mocked(fetchJson).mockResolvedValue(MOCK_MANIFEST) - }) - - afterEach(() => { - vi.resetAllMocks() - }) - - it('should download and save the manifest from a url', async () => { - await expect( - downloadAndCacheReleaseManifest(MANIFEST_URL) - ).resolves.toEqual(MOCK_MANIFEST) - expect(fetchJson).toHaveBeenCalledWith(MANIFEST_URL) - }) - - it('should pull the manifest from the file if the manifest download fails', async () => { - const error = new Error('Failed to download') - vi.mocked(fetchJson).mockRejectedValue(error) - await expect( - downloadAndCacheReleaseManifest(MANIFEST_URL) - ).resolves.toEqual(MOCK_MANIFEST) - expect(fetchJson).toHaveBeenCalledWith(MANIFEST_URL) - }) -}) diff --git a/app-shell-odd/src/system-update/constants.ts b/app-shell-odd/src/system-update/constants.ts new file mode 100644 index 00000000000..575b64230b5 --- /dev/null +++ b/app-shell-odd/src/system-update/constants.ts @@ -0,0 +1,11 @@ +const OPENTRONS_PROJECT: string = _OPENTRONS_PROJECT_ + +export const FLEX_MANIFEST_URL = + OPENTRONS_PROJECT && OPENTRONS_PROJECT.includes('robot-stack') + ? 'https://builds.opentrons.com/ot3-oe/releases.json' + : 'https://ot3-development.builds.opentrons.com/ot3-oe/releases.json' + +export const SYSTEM_UPDATE_DIRECTORY = '__ot_system_update__' +export const VERSION_FILENAME = 'VERSION.json' +export const REASONABLE_VERSION_FILE_SIZE_B = 4096 +export const SYSTEM_FILENAME = 'system-update.zip' diff --git a/app-shell-odd/src/system-update/directories.ts b/app-shell-odd/src/system-update/directories.ts index c2723153505..757f47bc44a 100644 --- a/app-shell-odd/src/system-update/directories.ts +++ b/app-shell-odd/src/system-update/directories.ts @@ -1,15 +1,6 @@ import { app } from 'electron' import path from 'path' +import { SYSTEM_UPDATE_DIRECTORY } from './constants' -const SYSTEM_UPDATE_DIRECTORY = path.join( - app.getPath('sessionData'), - '__ot_system_update__' -) - -export const getSystemUpdateDir = (): string => SYSTEM_UPDATE_DIRECTORY - -export const getFileDownloadDir = (version: string): string => - path.join(SYSTEM_UPDATE_DIRECTORY, version) - -export const getManifestCacheDir = (): string => - path.join(SYSTEM_UPDATE_DIRECTORY, 'releases.json') +export const getSystemUpdateDir = (): string => + path.join(app.getPath('userData'), SYSTEM_UPDATE_DIRECTORY) diff --git a/app-shell-odd/src/system-update/from-usb/__tests__/provider.test.ts b/app-shell-odd/src/system-update/from-usb/__tests__/provider.test.ts new file mode 100644 index 00000000000..cbdf79435dc --- /dev/null +++ b/app-shell-odd/src/system-update/from-usb/__tests__/provider.test.ts @@ -0,0 +1,205 @@ +import { it, describe, vi, afterEach, expect } from 'vitest' +import { when } from 'vitest-when' +import { getProvider } from '../provider' +import { getLatestMassStorageUpdateFile as _getLatestMassStorageUpdateFile } from '../scan-device' + +vi.mock('../scan-device') +vi.mock('../../../log') + +const getLatestMassStorageUpdateFile = vi.mocked( + _getLatestMassStorageUpdateFile +) + +describe('system-update/from-usb/provider', () => { + afterEach(() => { + vi.resetAllMocks() + }) + it('signals available updates when given available updates', () => { + when(getLatestMassStorageUpdateFile) + .calledWith(['/storage/valid-release.zip']) + .thenResolve({ path: '/storage/valid-release.zip', version: '1.2.3' }) + const progress = vi.fn() + const provider = getProvider({ + currentVersion: '1.0.0', + massStorageDeviceRoot: '/storage', + massStorageDeviceFiles: ['/storage/valid-release.zip'], + }) + const expectedUpdate = { + version: '1.2.3', + files: { + system: '/storage/valid-release.zip', + releaseNotes: expect.any(String), + }, + releaseNotes: expect.any(String), + downloadProgress: 100, + } + return expect(provider.refreshUpdateCache(progress)) + .resolves.toEqual(expectedUpdate) + .then(() => { + expect(progress).toHaveBeenLastCalledWith(expectedUpdate) + }) + }) + it('signals no available update when given no available updates', () => { + when(getLatestMassStorageUpdateFile) + .calledWith(['/storage/blahblah']) + .thenResolve(null) + const progress = vi.fn() + const provider = getProvider({ + currentVersion: '1.0.0', + massStorageDeviceRoot: '/storage', + massStorageDeviceFiles: ['/storage/blahblah'], + }) + const expectedUpdate = { + version: null, + files: null, + releaseNotes: null, + downloadProgress: 0, + } + return expect(provider.refreshUpdateCache(progress)) + .resolves.toEqual(expectedUpdate) + .then(() => { + expect(progress).toHaveBeenLastCalledWith(expectedUpdate) + }) + }) + it('signals no available update when the scan throws', () => { + when(getLatestMassStorageUpdateFile) + .calledWith(['/storage/blahblah']) + .thenReject(new Error('oh no')) + const progress = vi.fn() + const provider = getProvider({ + currentVersion: '1.0.0', + massStorageDeviceRoot: '/storage', + massStorageDeviceFiles: ['/storage/blahblah'], + }) + const expectedUpdate = { + version: null, + files: null, + releaseNotes: null, + downloadProgress: 0, + } + return expect(provider.refreshUpdateCache(progress)) + .resolves.toEqual(expectedUpdate) + .then(() => { + expect(progress).toHaveBeenLastCalledWith(expectedUpdate) + }) + }) + it('signals no available update when the highest version update is the same version as current', () => { + when(getLatestMassStorageUpdateFile) + .calledWith(['/storage/valid-release.zip']) + .thenResolve({ path: '/storage/valid-release.zip', version: '1.0.0' }) + const progress = vi.fn() + const provider = getProvider({ + currentVersion: '1.0.0', + massStorageDeviceRoot: '/storage', + massStorageDeviceFiles: ['/storage/valid-release.zip'], + }) + const expectedUpdate = { + version: null, + files: null, + releaseNotes: null, + downloadProgress: 0, + } + return expect(provider.refreshUpdateCache(progress)) + .resolves.toEqual(expectedUpdate) + .then(() => { + expect(progress).toHaveBeenLastCalledWith(expectedUpdate) + }) + }) + it('throws when torn down before scanning', () => { + const provider = getProvider({ + currentVersion: '1.0.0', + massStorageDeviceRoot: '/', + massStorageDeviceFiles: [], + }) + const progress = vi.fn() + return provider + .teardown() + .then(() => + expect(provider.refreshUpdateCache(progress)).rejects.toThrow() + ) + .then(() => + expect(progress).toHaveBeenLastCalledWith({ + version: null, + files: null, + releaseNotes: null, + downloadProgress: 0, + }) + ) + }) + it('throws when torn down right after scanning', () => { + const provider = getProvider({ + currentVersion: '1.0.0', + massStorageDeviceRoot: '/', + massStorageDeviceFiles: [], + }) + const progress = vi.fn() + when(getLatestMassStorageUpdateFile) + .calledWith(['/storage/valid-release.zip']) + .thenDo(() => + provider.teardown().then(() => ({ + path: '/storage/valid-release.zip', + version: '1.0.0', + })) + ) + return provider + .teardown() + .then(() => + expect(provider.refreshUpdateCache(progress)).rejects.toThrow() + ) + .then(() => + expect(progress).toHaveBeenLastCalledWith({ + version: null, + files: null, + releaseNotes: null, + downloadProgress: 0, + }) + ) + }) + it('will not run two checks at once', () => { + when(getLatestMassStorageUpdateFile) + .calledWith(['/storage/valid-release.zip']) + .thenResolve({ path: '/storage/valid-release.zip', version: '1.0.0' }) + const progress = vi.fn() + const provider = getProvider({ + currentVersion: '1.0.0', + massStorageDeviceRoot: '/storage', + massStorageDeviceFiles: ['/storage/valid-release.zip'], + }) + const expectedUpdate = { + version: null, + files: null, + releaseNotes: null, + downloadProgress: 0, + } + const first = provider.refreshUpdateCache(progress) + const second = provider.refreshUpdateCache(progress) + return Promise.all([ + expect(first).resolves.toEqual(expectedUpdate), + expect(second).rejects.toThrow(), + ]).then(() => expect(getLatestMassStorageUpdateFile).toHaveBeenCalledOnce()) + }) + it('will run a second check after the first ends', () => { + when(getLatestMassStorageUpdateFile) + .calledWith(['/storage/valid-release.zip']) + .thenResolve({ path: '/storage/valid-release.zip', version: '1.0.0' }) + const progress = vi.fn() + const provider = getProvider({ + currentVersion: '1.0.0', + massStorageDeviceRoot: '/storage', + massStorageDeviceFiles: ['/storage/valid-release.zip'], + }) + const expectedUpdate = { + version: null, + files: null, + releaseNotes: null, + downloadProgress: 0, + } + return expect(provider.refreshUpdateCache(progress)) + .resolves.toEqual(expectedUpdate) + .then(() => + expect(provider.refreshUpdateCache(progress)).resolves.toEqual( + expectedUpdate + ) + ) + }) +}) diff --git a/app-shell-odd/src/system-update/from-usb/__tests__/scan-device.test.ts b/app-shell-odd/src/system-update/from-usb/__tests__/scan-device.test.ts new file mode 100644 index 00000000000..ff51e89abf3 --- /dev/null +++ b/app-shell-odd/src/system-update/from-usb/__tests__/scan-device.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { when } from 'vitest-when' + +import { getVersionFromZipIfValid as _getVersionFromZipIfValid } from '../scan-zip' +import { getLatestMassStorageUpdateFile } from '../scan-device' +vi.mock('../../../log') +vi.mock('../scan-zip') +const getVersionFromZipIfValid = vi.mocked(_getVersionFromZipIfValid) + +describe('system-update/from-usb/scan-device', () => { + afterEach(() => { + vi.resetAllMocks() + }) + it('returns the single file passed in', () => { + when(getVersionFromZipIfValid) + .calledWith('/some/random/zip/file.zip') + .thenResolve({ path: '/some/random/zip/file.zip', version: '0.0.1' }) + return expect( + getLatestMassStorageUpdateFile(['/some/random/zip/file.zip']) + ).resolves.toEqual({ path: '/some/random/zip/file.zip', version: '0.0.1' }) + }) + it('returns null if no files are passed in', () => + expect(getLatestMassStorageUpdateFile([])).resolves.toBeNull()) + it('returns null if no suitable zips are found', () => { + when(getVersionFromZipIfValid) + .calledWith('/some/random/zip/file.zip') + .thenReject(new Error('no version found')) + return expect( + getLatestMassStorageUpdateFile(['/some/random/zip/file.zip']) + ).resolves.toBeNull() + }) + it('checks only the zip file', () => { + when(getVersionFromZipIfValid) + .calledWith('/some/random/zip/file.zip') + .thenResolve({ path: '/some/random/zip/file.zip', version: '0.0.1' }) + return expect( + getLatestMassStorageUpdateFile([ + '/some/random/zip/file.zip', + '/some/other/random/file', + ]) + ) + .resolves.toEqual({ path: '/some/random/zip/file.zip', version: '0.0.1' }) + .then(() => expect(getVersionFromZipIfValid).toHaveBeenCalledOnce()) + }) + it('returns the highest version', () => { + when(getVersionFromZipIfValid) + .calledWith('higher-version.zip') + .thenResolve({ path: 'higher-version.zip', version: '1.0.0' }) + when(getVersionFromZipIfValid) + .calledWith('lower-version.zip') + .thenResolve({ path: 'higher-version.zip', version: '1.0.0-alpha.0' }) + return expect( + getLatestMassStorageUpdateFile([ + 'higher-version.zip', + 'lower-version.zip', + ]) + ).resolves.toEqual({ path: 'higher-version.zip', version: '1.0.0' }) + }) +}) diff --git a/app-shell-odd/src/system-update/from-usb/__tests__/scan-zip.test.ts b/app-shell-odd/src/system-update/from-usb/__tests__/scan-zip.test.ts new file mode 100644 index 00000000000..226267a5a11 --- /dev/null +++ b/app-shell-odd/src/system-update/from-usb/__tests__/scan-zip.test.ts @@ -0,0 +1,151 @@ +import { it, describe, expect, vi } from 'vitest' +import path from 'path' +import { exec as _exec } from 'child_process' +import { promisify } from 'util' +import { writeFile, mkdir } from 'fs/promises' +import { REASONABLE_VERSION_FILE_SIZE_B } from '../../constants' +import { directoryWithCleanup } from '../../utils' +import { getVersionFromZipIfValid } from '../scan-zip' + +vi.mock('../../../log') +const exec = promisify(_exec) + +const zipCommand = ( + tempDir: string, + zipName?: string, + zipContentSubDirectory?: string +): string => + `zip -j ${path.join(tempDir, zipName ?? 'test.zip')} ${path.join( + tempDir, + zipContentSubDirectory ?? 'test', + '*' + )}` + +describe('system-update/from-usb/scan-zip', () => { + it('should read version data from a valid zip file', () => + directoryWithCleanup(directory => + mkdir(path.join(directory, 'test')) + .then(() => + writeFile( + path.join(directory, 'test', 'VERSION.json'), + JSON.stringify({ + robot_type: 'OT-3 Standard', + opentrons_api_version: '1.2.3', + }) + ) + ) + .then(() => exec(zipCommand(directory))) + .then(() => + expect( + getVersionFromZipIfValid(path.join(directory, 'test.zip')) + ).resolves.toEqual({ + path: path.join(directory, 'test.zip'), + version: '1.2.3', + }) + ) + )) + + it('should throw if there is no version file', () => + directoryWithCleanup(directory => + mkdir(path.join(directory, 'test')) + .then(() => writeFile(path.join(directory, 'test', 'dummy'), 'lalala')) + .then(() => exec(zipCommand(directory))) + .then(() => + expect( + getVersionFromZipIfValid(path.join(directory, 'test.zip')) + ).rejects.toThrow() + ) + )) + it('should throw if the version file is too big', () => + directoryWithCleanup(directory => + mkdir(path.join(directory, 'test')) + .then(() => + writeFile( + path.join(directory, 'test', 'VERSION.json'), + `{data: "${'a'.repeat(REASONABLE_VERSION_FILE_SIZE_B + 1)}"}` + ) + ) + .then(() => + exec( + `head -c ${ + REASONABLE_VERSION_FILE_SIZE_B + 1 + } /dev/zero > ${path.join(directory, 'test', 'VERSION.json')} ` + ) + ) + .then(() => exec(zipCommand(directory))) + .then(() => + expect( + getVersionFromZipIfValid(path.join(directory, 'test.zip')) + ).rejects.toThrow() + ) + )) + it('should throw if the version file is not valid json', () => + directoryWithCleanup(directory => + mkdir(path.join(directory, 'test')) + .then(() => + writeFile(path.join(directory, 'test', 'VERSION.json'), 'asdaasdas') + ) + .then(() => exec(zipCommand(directory))) + .then(() => + expect( + getVersionFromZipIfValid(path.join(directory, 'test.zip')) + ).rejects.toThrow() + ) + )) + it('should throw if the version file is for OT-2', () => + directoryWithCleanup(directory => + mkdir(path.join(directory, 'test')) + .then(() => + writeFile( + path.join(directory, 'test', 'VERSION.json'), + JSON.stringify({ + robot_type: 'OT-2 Standard', + opentrons_api_version: '1.2.3', + }) + ) + ) + .then(() => exec(zipCommand(directory))) + .then(() => + expect( + getVersionFromZipIfValid(path.join(directory, 'test.zip')) + ).rejects.toThrow() + ) + )) + it('should throw if not given a zip file', () => + directoryWithCleanup(directory => + mkdir(path.join(directory, 'test')) + .then(() => writeFile(path.join(directory, 'test.zip'), 'aosidasdasd')) + .then(() => + expect( + getVersionFromZipIfValid(path.join(directory, 'test.zip')) + ).rejects.toThrow() + ) + )) + it('should throw if given a zip file with internal directories', () => + directoryWithCleanup(directory => + mkdir(path.join(directory, 'test')) + .then(() => + writeFile( + path.join(directory, 'test', 'VERSION.json'), + JSON.stringify({ + robot_type: 'OT-3 Standard', + opentrons_api_version: '1.2.3', + }) + ) + ) + .then(() => + exec( + `zip ${path.join(directory, 'test.zip')} ${path.join( + directory, + 'test', + '*' + )}` + ) + ) + .then(() => + expect( + getVersionFromZipIfValid(path.join(directory, 'test.zip')) + ).rejects.toThrow() + ) + )) +}) diff --git a/app-shell-odd/src/system-update/from-usb/index.ts b/app-shell-odd/src/system-update/from-usb/index.ts new file mode 100644 index 00000000000..9ae1d7e4751 --- /dev/null +++ b/app-shell-odd/src/system-update/from-usb/index.ts @@ -0,0 +1,2 @@ +export { getProvider } from './provider' +export type { USBUpdateSource } from './provider' diff --git a/app-shell-odd/src/system-update/from-usb/provider.ts b/app-shell-odd/src/system-update/from-usb/provider.ts new file mode 100644 index 00000000000..53913fab790 --- /dev/null +++ b/app-shell-odd/src/system-update/from-usb/provider.ts @@ -0,0 +1,111 @@ +import tempy from 'tempy' +import path from 'path' +import { rm, writeFile } from 'fs/promises' +import type { UpdateProvider, ResolvedUpdate, ProgressCallback } from '../types' +import { getLatestMassStorageUpdateFile } from './scan-device' +import { createLogger } from '../../log' + +export interface USBUpdateSource { + currentVersion: string + massStorageDeviceRoot: string + massStorageDeviceFiles: string[] +} + +const fakeReleaseNotesForMassStorage = (version: string): string => ` +# Opentrons Robot Software Version ${version} + +This update is from a USB mass storage device connected to your Flex, and release notes cannot be shown. + +Don't remove the USB mass storage device while the update is in progress. +` +const log = createLogger('system-updates/from-usb') + +export function getProvider( + from: USBUpdateSource +): UpdateProvider { + const noUpdate = { + version: null, + files: null, + releaseNotes: null, + downloadProgress: 0, + } as const + let currentUpdate: ResolvedUpdate = noUpdate + let canceller = new AbortController() + let currentCheck: Promise | null = null + const tempdir = tempy.directory() + let tornDown = false + + const checkUpdates = async ( + progress: ProgressCallback + ): Promise => { + const myCanceller = canceller + if (myCanceller.signal.aborted || tornDown) { + progress(noUpdate) + throw new Error('cache torn down') + } + const updateFile = await getLatestMassStorageUpdateFile( + from.massStorageDeviceFiles + ).catch(() => null) + if (myCanceller.signal.aborted) { + progress(noUpdate) + throw new Error('cache torn down') + } + if (updateFile == null) { + log.info(`No update file in presented files`) + progress(noUpdate) + currentUpdate = noUpdate + return noUpdate + } + log.info(`Update file found for version ${updateFile.version}`) + if (updateFile.version === from.currentVersion) { + progress(noUpdate) + currentUpdate = noUpdate + return noUpdate + } + await writeFile( + path.join(tempdir, 'dummy-release-notes.md'), + fakeReleaseNotesForMassStorage(updateFile.version) + ) + if (myCanceller.signal.aborted) { + progress(noUpdate) + throw new Error('cache torn down') + } + const update = { + version: updateFile.version, + files: { + system: updateFile.path, + releaseNotes: path.join(tempdir, 'dummy-release-notes.md'), + }, + releaseNotes: fakeReleaseNotesForMassStorage(updateFile.version), + downloadProgress: 100, + } as const + currentUpdate = update + progress(update) + return update + } + return { + refreshUpdateCache: progressCallback => { + if (currentCheck != null) { + return new Promise((resolve, reject) => { + reject(new Error('Check already ongoing')) + }) + } + const updatePromise = checkUpdates(progressCallback) + currentCheck = updatePromise + return updatePromise.finally(() => { + currentCheck = null + }) + }, + getUpdateDetails: () => currentUpdate, + lockUpdateCache: () => {}, + unlockUpdateCache: () => {}, + teardown: () => { + canceller.abort() + tornDown = true + canceller = new AbortController() + return rm(tempdir, { recursive: true, force: true }) + }, + name: () => `USBUpdateProvider from ${from.massStorageDeviceRoot}`, + source: () => from, + } +} diff --git a/app-shell-odd/src/system-update/from-usb/scan-device.ts b/app-shell-odd/src/system-update/from-usb/scan-device.ts new file mode 100644 index 00000000000..0c0e7f3e40c --- /dev/null +++ b/app-shell-odd/src/system-update/from-usb/scan-device.ts @@ -0,0 +1,37 @@ +import Semver from 'semver' +import { getVersionFromZipIfValid } from './scan-zip' +import type { FileDetails } from './scan-zip' + +import { createLogger } from '../../log' +const log = createLogger('system-udpate/from-usb/scan-device') + +const higherVersion = (a: FileDetails | null, b: FileDetails): FileDetails => + a == null ? b : Semver.gt(a.version, b.version) ? a : b + +const mostRecentUpdateOf = (candidates: FileDetails[]): FileDetails | null => + candidates.reduce( + (prev, current) => higherVersion(prev, current), + null + ) + +const getMassStorageUpdateFiles = ( + filePaths: string[] +): Promise => + Promise.all( + filePaths.map(path => + path.endsWith('.zip') + ? getVersionFromZipIfValid(path).catch(() => null) + : new Promise(resolve => { + resolve(null) + }) + ) + ).then(values => { + const filtered = values.filter(entry => entry != null) as FileDetails[] + log.debug(`scan device found ${filtered}`) + return filtered + }) + +export const getLatestMassStorageUpdateFile = ( + filePaths: string[] +): Promise => + getMassStorageUpdateFiles(filePaths).then(mostRecentUpdateOf) diff --git a/app-shell-odd/src/system-update/from-usb/scan-zip.ts b/app-shell-odd/src/system-update/from-usb/scan-zip.ts new file mode 100644 index 00000000000..b6bce376096 --- /dev/null +++ b/app-shell-odd/src/system-update/from-usb/scan-zip.ts @@ -0,0 +1,88 @@ +import StreamZip from 'node-stream-zip' +import Semver from 'semver' +import { createLogger } from '../../log' +import { REASONABLE_VERSION_FILE_SIZE_B, VERSION_FILENAME } from '../constants' + +const log = createLogger('system-update/from-usb/scan-zip') + +export interface FileDetails { + path: string + version: string +} + +export const getVersionFromZipIfValid = (path: string): Promise => + new Promise((resolve, reject) => { + const zip = new StreamZip({ file: path, storeEntries: true }) + zip.on('ready', () => { + log.info(`Reading zip from ${path}`) + getVersionFromOpenedZipIfValid(zip) + .then(version => { + log.info(`Zip at ${path} has version ${version}`) + zip.close() + resolve({ version, path }) + }) + .catch(err => { + log.info( + `Zip at ${path} was read but could not be parsed: ${err.name}: ${err.message}` + ) + zip.close() + reject(err) + }) + }) + zip.on('error', err => { + log.info(`Zip at ${path} could not be read: ${err.name}: ${err.message}`) + zip.close() + reject(err) + }) + }) + +export const getVersionFromOpenedZipIfValid = ( + zip: StreamZip +): Promise => + new Promise((resolve, reject) => { + const found = Object.values(zip.entries()).reduce((prev, entry) => { + log.debug( + `Checking if ${entry.name} is ${VERSION_FILENAME}, is a file (${entry.isFile}), and ${entry.size}<${REASONABLE_VERSION_FILE_SIZE_B}` + ) + if ( + entry.isFile && + entry.name === VERSION_FILENAME && + entry.size < REASONABLE_VERSION_FILE_SIZE_B + ) { + log.debug(`${entry.name} is a version file candidate`) + const contents = zip.entryDataSync(entry.name).toString('ascii') + log.debug(`version contents: ${contents}`) + try { + const parsedContents = JSON.parse(contents) + if (parsedContents?.robot_type !== 'OT-3 Standard') { + reject(new Error('not a Flex release file')) + } + const fileVersion = parsedContents?.opentrons_api_version + const version = Semver.valid(fileVersion as string) + if (version === null) { + reject(new Error(`${fileVersion} is not a valid version`)) + return prev + } else { + log.info(`Found version file version ${version}`) + resolve(version) + return true + } + } catch (err: any) { + if (err instanceof Error) { + log.error( + `Failed to read ${entry.name}: ${err.name}: ${err.message}` + ) + } else { + log.error(`Failed to ready ${entry.name}: ${err}`) + } + reject(err) + return prev + } + } else { + return prev + } + }, false) + if (!found) { + reject(new Error('No version file found in zip')) + } + }) diff --git a/app-shell-odd/src/system-update/from-web/__tests__/latest-update.test.ts b/app-shell-odd/src/system-update/from-web/__tests__/latest-update.test.ts new file mode 100644 index 00000000000..b07d6947861 --- /dev/null +++ b/app-shell-odd/src/system-update/from-web/__tests__/latest-update.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest' +import { latestVersionForChannel } from '../latest-update' + +describe('latest-update', () => { + it.each([ + ['8.0.0', '7.0.0', '8.0.0', ''], + ['7.0.0', '8.0.0', '8.0.0', ''], + ['8.10.0', '8.9.0', '8.10.0', ''], + ['8.9.0', '8.10.0', '8.10.0', ''], + ['8.0.0-alpha.0', '8.0.0-alpha.1', '8.0.0-alpha.1', 'alpha'], + ['8.0.0-alpha.1', '8.0.0-alpha.0', '8.0.0-alpha.1', 'alpha'], + ['8.1.0-alpha.0', '8.0.0-alpha.1', '8.1.0-alpha.0', 'alpha'], + ['8.0.0-alpha.1', '8.1.0-alpha.0', '8.1.0-alpha.0', 'alpha'], + ])( + 'choosing between %s and %s should result in %s', + (first, second, higher, channel) => { + expect(latestVersionForChannel([first, second], channel)).toEqual(higher) + } + ) + it('ignores updates from different channels', () => { + expect( + latestVersionForChannel( + ['8.0.0', '9.0.0-alpha.0', '10.0.0-beta.1', '2.0.0'], + 'production' + ) + ).toEqual('8.0.0') + expect( + latestVersionForChannel( + ['8.0.0', '9.0.0-alpha.0', '10.0.0-beta.1', '2.0.0'], + 'alpha' + ) + ).toEqual('9.0.0-alpha.0') + expect( + latestVersionForChannel( + ['8.0.0', '9.0.0-alpha.0', '10.0.0-beta.1', '2.0.0'], + 'beta' + ) + ).toEqual('10.0.0-beta.1') + }) +}) diff --git a/app-shell-odd/src/system-update/from-web/__tests__/provider.test.ts b/app-shell-odd/src/system-update/from-web/__tests__/provider.test.ts new file mode 100644 index 00000000000..3ffe2e4ec08 --- /dev/null +++ b/app-shell-odd/src/system-update/from-web/__tests__/provider.test.ts @@ -0,0 +1,774 @@ +import { vi, describe, it, expect, afterEach } from 'vitest' +import { when } from 'vitest-when' + +import { LocalAbortError } from '../../../http' +import { getProvider } from '../provider' +import { getOrDownloadManifest as _getOrDownloadManifest } from '../release-manifest' +import { cleanUpAndGetOrDownloadReleaseFiles as _cleanUpAndGetOrDownloadReleaseFiles } from '../release-files' + +vi.mock('../../../log') +vi.mock('../release-manifest', async importOriginal => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const original = await importOriginal() + return { + ...original, + getOrDownloadManifest: vi.fn(), + } +}) +vi.mock('../release-files') + +const getOrDownloadManifest = vi.mocked(_getOrDownloadManifest) +const cleanUpAndGetOrDownloadReleaseFiles = vi.mocked( + _cleanUpAndGetOrDownloadReleaseFiles +) + +describe('provider.refreshUpdateCache happy paths', () => { + afterEach(() => { + vi.resetAllMocks() + }) + it('says there is no update if the latest version is the current version', () => { + when(getOrDownloadManifest) + .calledWith( + 'http://opentrons.com/releases.json', + '/some/random/directory', + expect.any(AbortController) + ) + .thenResolve({ + production: { + '1.2.3': { + system: 'http://opentrons.com/system.zip', + fullImage: 'http://opentrons.com/fullImage.zip', + version: 'http://opentrons.com/version.json', + releaseNotes: 'http://opentrons.com/releaseNotes.md', + }, + }, + }) + const progressCallback = vi.fn() + const provider = getProvider({ + manifestUrl: 'http://opentrons.com/releases.json', + channel: 'release', + updateCacheDirectory: '/some/random/directory', + currentVersion: '1.2.3', + }) + expect(provider.getUpdateDetails()).toEqual({ + version: null, + files: null, + releaseNotes: null, + downloadProgress: 0, + }) + return expect(provider.refreshUpdateCache(progressCallback)) + .resolves.toEqual({ + version: null, + files: null, + releaseNotes: null, + downloadProgress: 0, + }) + .then(() => { + expect(progressCallback).toHaveBeenCalledWith({ + version: null, + files: null, + releaseNotes: null, + downloadProgress: 0, + }) + expect(provider.getUpdateDetails()).toEqual({ + version: null, + files: null, + releaseNotes: null, + downloadProgress: 0, + }) + expect(cleanUpAndGetOrDownloadReleaseFiles).not.toHaveBeenCalled() + }) + }) + it('says there is an update if a cached update is needed', () => { + const releaseUrls = { + system: 'http://opentrons.com/system.zip', + fullImage: 'http://opentrons.com/fullImage.zip', + version: 'http://opentrons.com/version.json', + releaseNotes: 'http://opentrons.com/releaseNotes.md', + } + const releaseFiles = { + system: '/some/random/directory/cached-release-1.2.3/ot3-system.zip', + releaseNotes: + '/some/random/directory/cached-release-1.2.3/releaseNotes.md', + } + const releaseData = { + ...releaseFiles, + releaseNotesContent: 'oh look some release notes cool', + } + when(getOrDownloadManifest) + .calledWith( + 'http://opentrons.com/releases.json', + '/some/random/directory', + expect.any(AbortController) + ) + .thenResolve({ + production: { + '1.2.3': releaseUrls, + }, + }) + + when(cleanUpAndGetOrDownloadReleaseFiles) + .calledWith( + releaseUrls, + '/some/random/directory/versions', + '1.2.3', + expect.any(Function), + expect.any(Object) + ) + .thenResolve(releaseData) + + const progressCallback = vi.fn() + const provider = getProvider({ + manifestUrl: 'http://opentrons.com/releases.json', + channel: 'release', + updateCacheDirectory: '/some/random/directory', + currentVersion: '1.0.0', + }) + expect(provider.getUpdateDetails()).toEqual({ + version: null, + files: null, + releaseNotes: null, + downloadProgress: 0, + }) + return expect(provider.refreshUpdateCache(progressCallback)) + .resolves.toEqual({ + version: '1.2.3', + files: releaseFiles, + releaseNotes: 'oh look some release notes cool', + downloadProgress: 100, + }) + .then(() => + expect(progressCallback).toHaveBeenCalledWith({ + version: '1.2.3', + files: releaseFiles, + releaseNotes: 'oh look some release notes cool', + downloadProgress: 100, + }) + ) + }) + it('says there is an update and forwards progress if an update download is needed', () => { + const releaseUrls = { + system: 'http://opentrons.com/system.zip', + fullImage: 'http://opentrons.com/fullImage.zip', + version: 'http://opentrons.com/version.json', + releaseNotes: 'http://opentrons.com/releaseNotes.md', + } + const releaseFiles = { + system: '/some/random/directory/cached-release-1.2.3/ot3-system.zip', + releaseNotes: + '/some/random/directory/cached-release-1.2.3/releaseNotes.md', + } + const releaseData = { + ...releaseFiles, + releaseNotesContent: 'oh look some release notes sweet', + } + when(getOrDownloadManifest) + .calledWith( + 'http://opentrons.com/releases.json', + '/some/random/directory', + expect.any(AbortController) + ) + .thenResolve({ + production: { + '1.2.3': releaseUrls, + }, + }) + + when(cleanUpAndGetOrDownloadReleaseFiles) + .calledWith( + releaseUrls, + '/some/random/directory/versions', + '1.2.3', + expect.any(Function), + expect.any(Object) + ) + .thenDo( + ( + _releaseUrls, + _cacheDir, + _version, + progressCallback, + _abortController + ) => + new Promise(resolve => { + progressCallback({ size: 100, downloaded: 0 }) + resolve() + }) + .then( + () => + new Promise(resolve => { + progressCallback({ size: 100, downloaded: 50 }) + resolve() + }) + ) + .then( + () => + new Promise(resolve => { + progressCallback({ size: 100, downloaded: 100 }) + resolve(releaseData) + }) + ) + ) + + const progressCallback = vi.fn() + const provider = getProvider({ + manifestUrl: 'http://opentrons.com/releases.json', + channel: 'release', + updateCacheDirectory: '/some/random/directory', + currentVersion: '1.0.0', + }) + expect(provider.getUpdateDetails()).toEqual({ + version: null, + files: null, + releaseNotes: null, + downloadProgress: 0, + }) + return expect(provider.refreshUpdateCache(progressCallback)) + .resolves.toEqual({ + version: '1.2.3', + files: releaseFiles, + releaseNotes: 'oh look some release notes sweet', + downloadProgress: 100, + }) + .then(() => { + expect(progressCallback).toHaveBeenCalledWith({ + version: '1.2.3', + files: null, + releaseNotes: null, + downloadProgress: 0, + }) + expect(progressCallback).toHaveBeenCalledWith({ + version: '1.2.3', + files: null, + releaseNotes: null, + downloadProgress: 50, + }) + expect(progressCallback).toHaveBeenCalledWith({ + version: '1.2.3', + files: null, + releaseNotes: null, + downloadProgress: 100, + }) + expect(progressCallback).toHaveBeenCalledWith({ + version: '1.2.3', + files: releaseFiles, + releaseNotes: 'oh look some release notes sweet', + downloadProgress: 100, + }) + expect(provider.getUpdateDetails()).toEqual({ + version: '1.2.3', + files: releaseFiles, + releaseNotes: 'oh look some release notes sweet', + downloadProgress: 100, + }) + }) + }) +}) + +describe('provider.refreshUpdateCache locking', () => { + afterEach(() => { + vi.resetAllMocks() + }) + it('will not start a refresh when locked', () => { + const provider = getProvider({ + manifestUrl: 'http://opentrons.com/releases.json', + channel: 'release', + updateCacheDirectory: '/some/random/directory', + currentVersion: '1.0.0', + }) + provider.lockUpdateCache() + return expect(provider.refreshUpdateCache(vi.fn())).rejects.toThrow() + }) + it('will start a refresh when locked then unlocked', () => { + const provider = getProvider({ + manifestUrl: 'http://opentrons.com/releases.json', + channel: 'release', + updateCacheDirectory: '/some/random/directory', + currentVersion: '1.2.3', + }) + when(getOrDownloadManifest) + .calledWith( + 'http://opentrons.com/releases.json', + '/some/random/directory', + expect.any(AbortController) + ) + .thenResolve({ + production: { + '1.2.3': { + system: 'http://opentrons.com/system.zip', + fullImage: 'http://opentrons.com/fullImage.zip', + version: 'http://opentrons.com/version.json', + releaseNotes: 'http://opentrons.com/releaseNotes.md', + }, + }, + }) + provider.lockUpdateCache() + provider.unlockUpdateCache() + return expect(provider.refreshUpdateCache(vi.fn())).resolves.toEqual({ + version: null, + files: null, + releaseNotes: null, + downloadProgress: 0, + }) + }) + it('will abort when locked in the manifest phase and return the previous update', () => { + const provider = getProvider({ + manifestUrl: 'http://opentrons.com/releases.json', + channel: 'release', + updateCacheDirectory: '/some/random/directory', + currentVersion: '1.0.0', + }) + const releaseUrls = { + system: 'http://opentrons.com/system.zip', + fullImage: 'http://opentrons.com/fullImage.zip', + version: 'http://opentrons.com/version.json', + releaseNotes: 'http://opentrons.com/releaseNotes.md', + } + when(getOrDownloadManifest) + .calledWith( + 'http://opentrons.com/releases.json', + '/some/random/directory', + expect.any(AbortController) + ) + .thenResolve({ + production: { + '1.2.3': releaseUrls, + }, + }) + const releaseFiles = { + system: '/some/random/directory/cached-release-1.2.3/ot3-system.zip', + releaseNotes: + '/some/random/directory/cached-release-1.2.3/releaseNotes.md', + } + const releaseData = { ...releaseFiles, releaseNotesContent: 'oh hello' } + when(cleanUpAndGetOrDownloadReleaseFiles) + .calledWith( + releaseUrls, + '/some/random/directory/versions', + '1.2.3', + expect.any(Function), + expect.any(Object) + ) + .thenResolve(releaseData) + + return expect(provider.refreshUpdateCache(vi.fn())) + .resolves.toEqual({ + version: '1.2.3', + files: releaseFiles, + releaseNotes: 'oh hello', + downloadProgress: 100, + }) + .then(() => { + when(getOrDownloadManifest) + .calledWith( + 'http://opentrons.com/releases.json', + '/some/random/directory', + expect.any(AbortController) + ) + .thenDo( + (_manifestUrl, _cacheDirectory, abortController) => + new Promise((resolve, reject) => { + abortController.signal.addEventListener( + 'abort', + () => { + reject(new LocalAbortError(abortController.signal.reason)) + }, + { once: true } + ) + provider.lockUpdateCache() + }) + ) + const progress = vi.fn() + return expect(provider.refreshUpdateCache(progress)) + .rejects.toThrow() + .then(() => + expect(progress).toHaveBeenCalledWith({ + version: '1.2.3', + files: releaseFiles, + releaseNotes: 'oh hello', + downloadProgress: 100, + }) + ) + }) + .then(() => + expect(provider.getUpdateDetails()).toEqual({ + version: '1.2.3', + files: releaseFiles, + releaseNotes: 'oh hello', + downloadProgress: 100, + }) + ) + }) + it('will abort when locked between manifest and download phases and return the previous update', () => { + const provider = getProvider({ + manifestUrl: 'http://opentrons.com/releases.json', + channel: 'release', + updateCacheDirectory: '/some/random/directory', + currentVersion: '1.0.0', + }) + const releaseUrls = { + system: 'http://opentrons.com/system.zip', + fullImage: 'http://opentrons.com/fullImage.zip', + version: 'http://opentrons.com/version.json', + releaseNotes: 'http://opentrons.com/releaseNotes.md', + } + when(getOrDownloadManifest) + .calledWith( + 'http://opentrons.com/releases.json', + '/some/random/directory', + expect.any(AbortController) + ) + .thenResolve({ + production: { + '1.2.3': releaseUrls, + }, + }) + const releaseFiles = { + system: '/some/random/directory/cached-release-1.2.3/ot3-system.zip', + releaseNotes: + '/some/random/directory/cached-release-1.2.3/releaseNotes.md', + } + const releaseData = { ...releaseFiles, releaseNotesContent: 'hi' } + when(cleanUpAndGetOrDownloadReleaseFiles) + .calledWith( + releaseUrls, + '/some/random/directory/versions', + '1.2.3', + expect.any(Function), + expect.any(Object) + ) + .thenResolve(releaseData) + + return expect(provider.refreshUpdateCache(vi.fn())) + .resolves.toEqual({ + version: '1.2.3', + files: releaseFiles, + releaseNotes: 'hi', + downloadProgress: 100, + }) + .then(() => { + when(getOrDownloadManifest) + .calledWith( + expect.any(String), + expect.any(String), + expect.any(AbortController) + ) + .thenDo( + () => + new Promise(resolve => { + provider.lockUpdateCache() + resolve({ production: { '1.2.3': releaseUrls } }) + }) + ) + const progress = vi.fn() + return expect(provider.refreshUpdateCache(progress)) + .rejects.toThrow() + .then(() => + expect(progress).toHaveBeenCalledWith({ + version: '1.2.3', + files: releaseFiles, + releaseNotes: 'hi', + downloadProgress: 100, + }) + ) + }) + .then(() => + expect(provider.getUpdateDetails()).toEqual({ + version: '1.2.3', + files: releaseFiles, + releaseNotes: 'hi', + downloadProgress: 100, + }) + ) + }) + it('will abort when locked in the file download phase and return the previous update', () => { + const provider = getProvider({ + manifestUrl: 'http://opentrons.com/releases.json', + channel: 'release', + updateCacheDirectory: '/some/random/directory', + currentVersion: '1.0.0', + }) + const releaseUrls = { + system: 'http://opentrons.com/system.zip', + fullImage: 'http://opentrons.com/fullImage.zip', + version: 'http://opentrons.com/version.json', + releaseNotes: 'http://opentrons.com/releaseNotes.md', + } + when(getOrDownloadManifest) + .calledWith( + 'http://opentrons.com/releases.json', + '/some/random/directory', + expect.any(AbortController) + ) + .thenResolve({ + production: { + '1.2.3': releaseUrls, + }, + }) + const releaseFiles = { + system: '/some/random/directory/cached-release-1.2.3/ot3-system.zip', + releaseNotes: + '/some/random/directory/cached-release-1.2.3/releaseNotes.md', + } + const releaseData = { + ...releaseFiles, + releaseNotesContent: 'content', + } + when(cleanUpAndGetOrDownloadReleaseFiles) + .calledWith( + releaseUrls, + '/some/random/directory/versions', + '1.2.3', + expect.any(Function), + expect.any(Object) + ) + .thenResolve(releaseData) + + return expect(provider.refreshUpdateCache(vi.fn())) + .resolves.toEqual({ + version: '1.2.3', + files: releaseFiles, + releaseNotes: 'content', + downloadProgress: 100, + }) + .then(() => { + when(getOrDownloadManifest) + .calledWith( + 'http://opentrons.com/releases.json', + '/some/random/directory', + expect.any(AbortController) + ) + .thenResolve({ + production: { + '1.2.3': releaseUrls, + }, + }) + when(cleanUpAndGetOrDownloadReleaseFiles) + .calledWith( + expect.any(Object), + expect.any(String), + expect.any(String), + expect.any(Function), + expect.any(AbortController) + ) + .thenDo( + ( + _releaseUrls, + _cacheDirectory, + _version, + _progress, + abortController + ) => + new Promise((resolve, reject) => { + abortController.signal.addEventListener( + 'abort', + () => { + reject(new LocalAbortError(abortController.signal.reason)) + }, + { once: true } + ) + provider.lockUpdateCache() + }) + ) + const progress = vi.fn() + return expect(provider.refreshUpdateCache(progress)) + .rejects.toThrow() + .then(() => + expect(progress).toHaveBeenCalledWith({ + version: '1.2.3', + files: releaseFiles, + releaseNotes: 'content', + downloadProgress: 100, + }) + ) + }) + .then(() => { + expect(provider.getUpdateDetails()).toEqual({ + version: '1.2.3', + files: releaseFiles, + releaseNotes: 'content', + downloadProgress: 100, + }) + }) + }) + it('will abort when locked in the last-chance phase and return the previous update', () => { + const provider = getProvider({ + manifestUrl: 'http://opentrons.com/releases.json', + channel: 'release', + updateCacheDirectory: '/some/random/directory', + currentVersion: '1.0.0', + }) + const releaseUrls = { + system: 'http://opentrons.com/system.zip', + fullImage: 'http://opentrons.com/fullImage.zip', + version: 'http://opentrons.com/version.json', + releaseNotes: 'http://opentrons.com/releaseNotes.md', + } + when(getOrDownloadManifest) + .calledWith( + 'http://opentrons.com/releases.json', + '/some/random/directory', + expect.any(AbortController) + ) + .thenResolve({ + production: { + '1.2.3': releaseUrls, + }, + }) + const releaseFiles = { + system: '/some/random/directory/cached-release-1.2.3/ot3-system.zip', + releaseNotes: + '/some/random/directory/cached-release-1.2.3/releaseNotes.md', + } + const releaseData = { + ...releaseFiles, + releaseNotesContent: 'there is some', + } + when(cleanUpAndGetOrDownloadReleaseFiles) + .calledWith( + releaseUrls, + '/some/random/directory/versions', + '1.2.3', + expect.any(Function), + expect.any(Object) + ) + .thenResolve(releaseData) + + return expect(provider.refreshUpdateCache(vi.fn())) + .resolves.toEqual({ + version: '1.2.3', + files: releaseFiles, + releaseNotes: 'there is some', + downloadProgress: 100, + }) + .then(() => { + when(getOrDownloadManifest) + .calledWith( + 'http://opentrons.com/releases.json', + '/some/random/directory', + expect.any(AbortController) + ) + .thenResolve({ + production: { + '1.2.3': releaseUrls, + }, + }) + when(cleanUpAndGetOrDownloadReleaseFiles) + .calledWith( + expect.any(Object), + expect.any(String), + expect.any(String), + expect.any(Function), + expect.any(AbortController) + ) + .thenDo( + ( + _releaseUrls, + _cacheDirectory, + _version, + _progress, + _abortController + ) => + new Promise(resolve => { + provider.lockUpdateCache() + resolve(releaseData) + }) + ) + const progress = vi.fn() + return expect(provider.refreshUpdateCache(progress)) + .rejects.toThrow() + .then(() => + expect(progress).toHaveBeenCalledWith({ + version: '1.2.3', + files: releaseFiles, + releaseNotes: 'there is some', + downloadProgress: 100, + }) + ) + }) + .then(() => + expect(provider.getUpdateDetails()).toEqual({ + version: '1.2.3', + files: releaseFiles, + releaseNotes: 'there is some', + downloadProgress: 100, + }) + ) + }) + it('will not run two checks at once', () => { + when(getOrDownloadManifest) + .calledWith( + 'http://opentrons.com/releases.json', + '/some/random/directory', + expect.any(AbortController) + ) + .thenResolve({ + production: { + '1.2.3': { + system: 'http://opentrons.com/system.zip', + fullImage: 'http://opentrons.com/fullImage.zip', + version: 'http://opentrons.com/version.json', + releaseNotes: 'http://opentrons.com/releaseNotes.md', + }, + }, + }) + const progressCallback = vi.fn() + const provider = getProvider({ + manifestUrl: 'http://opentrons.com/releases.json', + channel: 'release', + updateCacheDirectory: '/some/random/directory', + currentVersion: '1.2.3', + }) + const first = provider.refreshUpdateCache(progressCallback) + const second = provider.refreshUpdateCache(progressCallback) + return Promise.all([ + expect(first).resolves.toEqual({ + version: null, + files: null, + releaseNotes: null, + downloadProgress: 0, + }), + expect(second).rejects.toThrow(), + ]).then(() => expect(getOrDownloadManifest).toHaveBeenCalledOnce()) + }) + it('will run a second check after the first completes', () => { + when(getOrDownloadManifest) + .calledWith( + 'http://opentrons.com/releases.json', + '/some/random/directory', + expect.any(AbortController) + ) + .thenResolve({ + production: { + '1.2.3': { + system: 'http://opentrons.com/system.zip', + fullImage: 'http://opentrons.com/fullImage.zip', + version: 'http://opentrons.com/version.json', + releaseNotes: 'http://opentrons.com/releaseNotes.md', + }, + }, + }) + const progressCallback = vi.fn() + const provider = getProvider({ + manifestUrl: 'http://opentrons.com/releases.json', + channel: 'release', + updateCacheDirectory: '/some/random/directory', + currentVersion: '1.2.3', + }) + return expect(provider.refreshUpdateCache(progressCallback)) + .resolves.toEqual({ + version: null, + files: null, + releaseNotes: null, + downloadProgress: 0, + }) + .then(() => + expect(provider.refreshUpdateCache(progressCallback)).resolves.toEqual({ + version: null, + files: null, + releaseNotes: null, + downloadProgress: 0, + }) + ) + }) +}) diff --git a/app-shell-odd/src/system-update/from-web/__tests__/release-files.test.ts b/app-shell-odd/src/system-update/from-web/__tests__/release-files.test.ts new file mode 100644 index 00000000000..34df59eaf49 --- /dev/null +++ b/app-shell-odd/src/system-update/from-web/__tests__/release-files.test.ts @@ -0,0 +1,514 @@ +// TODO(mc, 2020-06-11): test all release-files functions +import { vi, describe, it, expect, afterEach } from 'vitest' +import { when } from 'vitest-when' +import path from 'path' +import { promises as fs } from 'fs' + +import { fetchToFile as httpFetchToFile } from '../../../http' +import { + ensureCleanReleaseCacheForVersion, + getReleaseFiles, + downloadReleaseFiles, + getOrDownloadReleaseFiles, +} from '../release-files' + +import { directoryWithCleanup } from '../../utils' +import type { ReleaseSetUrls } from '../../types' + +vi.mock('../../../http') +vi.mock('../../../log') + +const fetchToFile = vi.mocked(httpFetchToFile) + +describe('ensureCleanReleaseCacheForVersion', () => { + it('should create the appropriate directory tree if it does not exist', () => + directoryWithCleanup(directory => + ensureCleanReleaseCacheForVersion( + path.join(directory, 'somerandomdirectory', 'someotherrandomdirectory'), + '1.2.3' + ) + .then(cacheDirectory => { + expect(cacheDirectory).toEqual( + path.join( + directory, + 'somerandomdirectory', + 'someotherrandomdirectory', + 'cached-release-1.2.3' + ) + ) + return fs.stat(cacheDirectory) + }) + .then(stats => expect(stats.isDirectory()).toBeTruthy()) + )) + it('should create the appropriate directory if the base directory entry is occupied by a file', () => + directoryWithCleanup(directory => + fs + .writeFile( + path.join(directory, 'somerandomdirectory'), + 'somerandomdata' + ) + .then(() => + ensureCleanReleaseCacheForVersion( + path.join(directory, 'somerandomdirectory'), + '1.2.3' + ) + ) + .then(cacheDirectory => { + expect(cacheDirectory).toEqual( + path.join(directory, 'somerandomdirectory', 'cached-release-1.2.3') + ) + return fs.stat(cacheDirectory) + }) + .then(stats => expect(stats.isDirectory()).toBeTruthy()) + )) + it('should create the appropriate directory if the version directory entry is occupied by a file', () => + directoryWithCleanup(directory => + fs + .mkdir(path.join(directory, 'somerandomdirectory')) + .then(() => + fs.writeFile( + path.join(directory, 'somerandomdirectory', 'cached-release-1.2.3'), + 'somerandomdata' + ) + ) + .then(() => + ensureCleanReleaseCacheForVersion( + path.join(directory, 'somerandomdirectory'), + '1.2.3' + ) + ) + .then(baseDirectory => { + expect(baseDirectory).toEqual( + path.join(directory, 'somerandomdirectory', 'cached-release-1.2.3') + ) + return fs.stat(baseDirectory) + }) + .then(stats => expect(stats.isDirectory()).toBeTruthy()) + )) + it('should remove caches for other versions from the cache directory', () => + directoryWithCleanup(directory => + fs + .mkdir(path.join(directory, 'cached-release-0.1.2')) + .then(() => fs.mkdir(path.join(directory, 'cached-release-4.5.6'))) + .then(() => + fs.writeFile( + path.join(directory, 'cached-release-4.5.6', 'test.zip'), + 'asfjohasda' + ) + ) + .then(() => ensureCleanReleaseCacheForVersion(directory, '1.2.3')) + .then(cacheDirectory => { + expect(cacheDirectory).toEqual( + path.join(directory, 'cached-release-1.2.3') + ) + return fs.readdir(directory) + }) + .then(contents => expect(contents).toEqual(['cached-release-1.2.3'])) + )) + it('should leave already-existing correct version cache directories untouched', () => + directoryWithCleanup(directory => + fs + .mkdir(path.join(directory, 'cached-release-1.2.3')) + .then(() => + fs.writeFile( + path.join(directory, 'cached-release-1.2.3', 'system.zip'), + '123123' + ) + ) + .then(() => ensureCleanReleaseCacheForVersion(directory, '1.2.3')) + .then(cacheDirectory => fs.readdir(cacheDirectory)) + .then(contents => { + expect(contents).toEqual(['system.zip']) + return fs.readFile( + path.join(directory, 'cached-release-1.2.3', 'system.zip'), + { encoding: 'utf-8' } + ) + }) + .then(contents => expect(contents).toEqual('123123')) + )) +}) + +describe('getReleaseFiles', () => { + it('should fail if no release files are cached', () => + directoryWithCleanup(directory => + expect( + getReleaseFiles( + { + fullImage: 'http://opentrons.com/fullImage.zip', + system: 'http://opentrons.com/ot3-system.zip', + version: 'http//opentrons.com/VERSION.json', + releaseNotes: 'http://opentrons.com/releaseNotes.md', + }, + directory + ) + ).rejects.toThrow() + )) + it('should fail if system is not present but all others are', () => + directoryWithCleanup(directory => + fs + .writeFile(path.join(directory, 'fullImage.zip'), 'aslkdjasd') + .then(() => fs.writeFile(path.join(directory, 'VERSION.json'), 'asdas')) + .then(() => + fs.writeFile(path.join(directory, 'releaseNotes.md'), 'asdalsda') + ) + .then(() => + expect( + getReleaseFiles( + { + fullImage: 'http://opentrons.com/fullImage.zip', + system: 'http://opentrons.com/ot3-system.zip', + version: 'http//opentrons.com/VERSION.json', + releaseNotes: 'http://opentrons.com/releaseNotes.md', + }, + directory + ) + ).rejects.toThrow() + ) + )) + it('should return available files if system.zip is one of them', () => + directoryWithCleanup(directory => + fs + .writeFile(path.join(directory, 'ot3-system.zip'), 'asdjlhasd') + .then(() => + expect( + getReleaseFiles( + { + fullImage: 'http://opentrons.com/fullImage.zip', + system: 'http://opentrons.com/ot3-system.zip', + version: 'http//opentrons.com/VERSION.json', + releaseNotes: 'http://opentrons.com/releaseNotes.md', + }, + directory + ) + ).resolves.toEqual({ + system: path.join(directory, 'ot3-system.zip'), + releaseNotes: null, + releaseNotesContent: null, + }) + ) + )) + it('should find release notes if available', () => + directoryWithCleanup(directory => + fs + .writeFile(path.join(directory, 'ot3-system.zip'), 'asdjlhasd') + .then(() => + fs.writeFile(path.join(directory, 'releaseNotes.md'), 'asdasda') + ) + .then(() => + expect( + getReleaseFiles( + { + fullImage: 'http://opentrons.com/fullImage.zip', + system: 'http://opentrons.com/ot3-system.zip', + version: 'http//opentrons.com/VERSION.json', + releaseNotes: 'http://opentrons.com/releaseNotes.md', + }, + directory + ) + ).resolves.toEqual({ + system: path.join(directory, 'ot3-system.zip'), + releaseNotes: path.join(directory, 'releaseNotes.md'), + releaseNotesContent: 'asdasda', + }) + ) + )) +}) + +describe('downloadReleaseFiles', () => { + afterEach(() => { + vi.resetAllMocks() + }) + it('should try and fetch both system zip and release notes', () => + directoryWithCleanup(directory => { + let tempSystemPath = '' + when(fetchToFile) + .calledWith( + 'http://opentrons.com/ot3-system.zip', + expect.any(String), + expect.any(Object) + ) + .thenDo((_url, dest, _opts) => { + tempSystemPath = dest + return fs + .writeFile(dest, 'this is the contents of the system.zip') + .then(() => dest) + }) + when(fetchToFile) + .calledWith( + 'http://opentrons.com/releaseNotes.md', + expect.any(String), + expect.any(Object) + ) + .thenDo((_url, dest) => { + return fs + .writeFile(dest, 'this is the contents of the release notes') + .then(() => dest) + }) + const progress = vi.fn() + return downloadReleaseFiles( + { + system: 'http://opentrons.com/ot3-system.zip', + releaseNotes: 'http://opentrons.com/releaseNotes.md', + } as ReleaseSetUrls, + directory, + progress, + new AbortController() + ).then(files => { + expect(files).toEqual({ + system: path.join(directory, 'ot3-system.zip'), + releaseNotes: path.join(directory, 'releaseNotes.md'), + releaseNotesContent: 'this is the contents of the release notes', + }) + return Promise.all([ + fs + .readFile(files.system, { encoding: 'utf-8' }) + .then(contents => + expect(contents).toEqual('this is the contents of the system.zip') + ), + fs + .readFile(files.releaseNotes as string, { encoding: 'utf-8' }) + .then(contents => + expect(contents).toEqual( + 'this is the contents of the release notes' + ) + ), + expect(fs.stat(path.dirname(tempSystemPath))).rejects.toThrow(), + ]) + }) + })) + it('should fetch only system zip if only system is available', () => + directoryWithCleanup(directory => { + when(fetchToFile) + .calledWith( + 'http://opentrons.com/ot3-system.zip', + expect.any(String), + expect.any(Object) + ) + .thenDo((_url, dest, _opts) => { + return fs + .writeFile(dest, 'this is the contents of the system.zip') + .then(() => dest) + }) + const progress = vi.fn() + return downloadReleaseFiles( + { + system: 'http://opentrons.com/ot3-system.zip', + } as ReleaseSetUrls, + directory, + progress, + new AbortController() + ).then(files => { + expect(files).toEqual({ + system: path.join(directory, 'ot3-system.zip'), + releaseNotes: null, + releaseNotesContent: null, + }) + return fs + .readFile(files.system, { encoding: 'utf-8' }) + .then(contents => + expect(contents).toEqual('this is the contents of the system.zip') + ) + }) + })) + it('should tolerate failing to fetch release notes', () => + directoryWithCleanup(directory => { + when(fetchToFile) + .calledWith( + 'http://opentrons.com/ot3-system.zip', + expect.any(String), + expect.any(Object) + ) + .thenDo((_url, dest, _opts) => { + return fs + .writeFile(dest, 'this is the contents of the system.zip') + .then(() => dest) + }) + when(fetchToFile) + .calledWith( + 'http://opentrons.com/releaseNotes.md', + expect.any(String), + expect.any(Object) + ) + .thenReject(new Error('oh no!')) + const progress = vi.fn() + return downloadReleaseFiles( + { + system: 'http://opentrons.com/ot3-system.zip', + releaseNotes: 'http://opentrons.com/releaseNotes.md', + } as ReleaseSetUrls, + directory, + progress, + new AbortController() + ).then(files => { + expect(files).toEqual({ + system: path.join(directory, 'ot3-system.zip'), + releaseNotes: null, + releaseNotesContent: null, + }) + return fs + .readFile(files.system, { encoding: 'utf-8' }) + .then(contents => + expect(contents).toEqual('this is the contents of the system.zip') + ) + }) + })) + it('should fail if it cannot fetch system zip', () => + directoryWithCleanup(directory => { + let tempSystemPath = '' + when(fetchToFile) + .calledWith( + 'http://opentrons.com/ot3-system.zip', + expect.any(String), + expect.any(Object) + ) + .thenReject(new Error('oh no')) + when(fetchToFile) + .calledWith( + 'http://opentrons.com/releaseNotes.md', + expect.any(String), + expect.any(Object) + ) + .thenDo((_url, dest) => { + tempSystemPath = dest + return fs + .writeFile(dest, 'this is the contents of the release notes') + .then(() => dest) + }) + const progress = vi.fn() + return expect( + downloadReleaseFiles( + { + system: 'http://opentrons.com/ot3-system.zip', + releaseNotes: 'http://opentrons.com/releaseNotes.md', + } as ReleaseSetUrls, + directory, + progress, + new AbortController() + ) + ) + .rejects.toThrow() + .then(() => + expect(fs.stat(path.dirname(tempSystemPath))).rejects.toThrow() + ) + })) + it('should allow the http requests to be aborted', () => + directoryWithCleanup(directory => { + const aborter = new AbortController() + const progressCallback = vi.fn() + when(fetchToFile) + .calledWith('http://opentrons.com/ot3-system.zip', expect.any(String), { + onProgress: progressCallback, + signal: aborter.signal, + }) + .thenDo( + (_url, dest, options) => + new Promise((resolve, reject) => { + const listener = () => { + reject(options.signal.reason) + } + options.signal.addEventListener('abort', listener, { once: true }) + aborter.abort('oh no!') + return fs + .writeFile(dest, 'this is the contents of the system.zip') + .then(() => dest) + }) + ) + return expect( + downloadReleaseFiles( + { + system: 'http://opentrons.com/ot3-system.zip', + } as ReleaseSetUrls, + directory, + progressCallback, + aborter + ) + ).rejects.toThrow() + })) +}) + +describe('getOrDownloadReleaseFiles', () => { + it('should not download release files if they are cached', () => + directoryWithCleanup(directory => + fs + .writeFile(path.join(directory, 'ot3-system.zip'), 'asdjlhasd') + .then(() => + expect( + getOrDownloadReleaseFiles( + { + system: 'http://opentrons.com/ot3-system.zip', + releaseNotes: 'http://opentrons.com/releaseNotes.md', + } as ReleaseSetUrls, + directory, + vi.fn(), + new AbortController() + ) + ) + .resolves.toEqual({ + system: path.join(directory, 'ot3-system.zip'), + releaseNotes: null, + releaseNotesContent: null, + }) + .then(() => expect(fetchToFile).not.toHaveBeenCalled()) + ) + )) + it('should download release files if they are not cached', () => + directoryWithCleanup(directory => { + when(fetchToFile) + .calledWith( + 'http://opentrons.com/ot3-system.zip', + expect.any(String), + expect.any(Object) + ) + .thenDo((_url, dest, _opts) => { + return fs + .writeFile(dest, 'this is the contents of the system.zip') + .then(() => dest) + }) + + return expect( + getOrDownloadReleaseFiles( + { + system: 'http://opentrons.com/ot3-system.zip', + } as ReleaseSetUrls, + directory, + vi.fn(), + new AbortController() + ) + ) + .resolves.toEqual({ + system: path.join(directory, 'ot3-system.zip'), + releaseNotes: null, + releaseNotesContent: null, + }) + .then(() => + fs + .readFile(path.join(directory, 'ot3-system.zip'), { + encoding: 'utf-8', + }) + .then(contents => + expect(contents).toEqual('this is the contents of the system.zip') + ) + ) + })) + it('should fail if the file is not cached and can not be downloaded', () => + directoryWithCleanup(directory => { + when(fetchToFile) + .calledWith( + 'http://opentrons.com/ot3-system.zip', + expect.any(String), + expect.any(Object) + ) + .thenReject(new Error('oh no')) + + return expect( + getOrDownloadReleaseFiles( + { + system: 'http://opentrons.com/ot3-system.zip', + } as ReleaseSetUrls, + directory, + vi.fn(), + new AbortController() + ) + ).rejects.toThrow() + })) +}) diff --git a/app-shell-odd/src/system-update/from-web/__tests__/release-manifest.test.ts b/app-shell-odd/src/system-update/from-web/__tests__/release-manifest.test.ts new file mode 100644 index 00000000000..8062cd6b28b --- /dev/null +++ b/app-shell-odd/src/system-update/from-web/__tests__/release-manifest.test.ts @@ -0,0 +1,185 @@ +import { describe, it, vi, expect } from 'vitest' +import { when } from 'vitest-when' +import path from 'path' +import { readdir, writeFile, mkdir, readFile } from 'fs/promises' +import { fetchJson as _fetchJson } from '../../../http' +import { ensureCacheDir, getOrDownloadManifest } from '../release-manifest' +import { directoryWithCleanup } from '../../utils' + +vi.mock('../../../http') +// note: this doesn't look like it's needed but it is because http uses log +vi.mock('../../../log') +const fetchJson = vi.mocked(_fetchJson) + +const MOCK_MANIFEST = { + production: { + '1.2.3': { + fullImage: 'https://opentrons.com/no', + system: 'https://opentrons.com/no2', + version: 'https://opentrons.com/no3', + releaseNotes: 'https://opentrons.com/no4', + }, + }, +} + +describe('ensureCacheDirectory', () => { + it('should create the cache directory if it or its parents do not exist', () => + directoryWithCleanup(directory => + ensureCacheDir( + path.join(directory as string, 'somerandomname', 'someotherrandomname') + ) + .then(ensuredDirectory => { + expect(ensuredDirectory).toEqual( + path.join(directory, 'somerandomname', 'someotherrandomname') + ) + return readdir(path.join(directory, 'somerandomname'), { + withFileTypes: true, + }) + }) + .then(contents => { + expect(contents).toHaveLength(1) + expect(contents[0].isDirectory()).toBeTruthy() + expect(contents[0].name).toEqual('someotherrandomname') + return readdir(path.join(contents[0].path, contents[0].name)) + }) + .then(contents => { + expect(contents).toHaveLength(0) + }) + )) + it('should delete and recreate the cache directory if it is a file', () => + directoryWithCleanup(directory => + writeFile(path.join(directory, 'somerandomname'), 'alsdasda') + .then(() => ensureCacheDir(path.join(directory, 'somerandomname'))) + .then(ensuredDirectory => { + expect(ensuredDirectory).toEqual( + path.join(directory, 'somerandomname') + ) + return readdir(directory, { withFileTypes: true }) + }) + .then(contents => { + expect(contents).toHaveLength(1) + expect(contents[0].isDirectory()).toBeTruthy() + expect(contents[0].name).toEqual('somerandomname') + return readdir(path.join(contents[0].path, contents[0].name)) + }) + .then(contents => { + expect(contents).toHaveLength(0) + }) + )) + + it('should remove a non-file with the same name as the manifest file', () => + directoryWithCleanup(directory => + mkdir(path.join(directory, 'somerandomname', 'manifest.json'), { + recursive: true, + }) + .then(() => + writeFile( + path.join(directory, 'somerandomname', 'testfile'), + 'testdata' + ) + ) + .then(() => ensureCacheDir(path.join(directory, 'somerandomname'))) + .then(ensuredDirectory => readdir(ensuredDirectory)) + .then(contents => { + expect(contents).not.toContain('manifest.json') + return readFile(path.join(directory, 'somerandomname', 'testfile'), { + encoding: 'utf-8', + }) + }) + .then(contents => expect(contents).toEqual('testdata')) + )) + + it('should preserve extra contents of the directory if the directory exists', () => + directoryWithCleanup(directory => + mkdir(path.join(directory, 'somerandomname'), { recursive: true }) + .then(() => + writeFile( + path.join(directory, 'somerandomname', 'somerandomfile'), + 'somerandomdata' + ) + ) + .then(() => ensureCacheDir(path.join(directory, 'somerandomname'))) + .then(ensuredDirectory => { + expect(ensuredDirectory).toEqual( + path.join(directory, 'somerandomname') + ) + return readFile( + path.join(directory, 'somerandomname', 'somerandomfile'), + { encoding: 'utf-8' } + ) + }) + .then(contents => { + expect(contents).toEqual('somerandomdata') + return readdir(directory) + }) + .then(contents => expect(contents).toEqual(['somerandomname'])) + )) +}) + +describe('getOrDownloadManifest', () => { + const localManifest = { + production: { + '4.5.6': { + fullImage: 'https://opentrons.com/no', + system: 'https://opentrons.com/no2', + version: 'https://opentrons.com/no3', + releaseNotes: 'https://opentrons.com/no4', + }, + }, + } + it('should download a new manifest if possible', () => + directoryWithCleanup(directory => + writeFile( + path.join(directory, 'manifest.json'), + JSON.stringify(localManifest) + ) + .then(() => { + when(fetchJson) + .calledWith( + 'http://opentrons.com/releases.json', + expect.any(Object) + ) + .thenResolve(MOCK_MANIFEST) + return getOrDownloadManifest( + 'http://opentrons.com/releases.json', + directory, + new AbortController() + ) + }) + .then(manifest => expect(manifest).toEqual(MOCK_MANIFEST)) + )) + it('should use a cached manifest if the download fails', () => + directoryWithCleanup(directory => + writeFile( + path.join(directory, 'manifest.json'), + JSON.stringify(localManifest) + ) + .then(() => { + when(fetchJson) + .calledWith( + 'http://opentrons.com/releases.json', + expect.any(Object) + ) + .thenReject(new Error('oh no!')) + return getOrDownloadManifest( + 'http://opentrons.com/releases.json', + directory, + new AbortController() + ) + }) + .then(manifest => expect(manifest).toEqual(localManifest)) + )) + it('should reject if no manifest is available', () => + directoryWithCleanup(directory => { + when(fetchJson) + .calledWith('http://opentrons.com/releases.json', expect.any(Object)) + .thenReject(new Error('oh no!')) + return expect( + getOrDownloadManifest( + 'http://opentrons.com/releases.json', + directory, + new AbortController() + ) + ).rejects.toThrow() + })) +}) diff --git a/app-shell-odd/src/system-update/from-web/index.ts b/app-shell-odd/src/system-update/from-web/index.ts new file mode 100644 index 00000000000..0a9c34e3370 --- /dev/null +++ b/app-shell-odd/src/system-update/from-web/index.ts @@ -0,0 +1,2 @@ +export { getProvider } from './provider' +export type { WebUpdateSource } from './provider' diff --git a/app-shell-odd/src/system-update/from-web/latest-update.ts b/app-shell-odd/src/system-update/from-web/latest-update.ts new file mode 100644 index 00000000000..1a270c85ddd --- /dev/null +++ b/app-shell-odd/src/system-update/from-web/latest-update.ts @@ -0,0 +1,28 @@ +import semver from 'semver' + +const channelFinder = (version: string, channel: string): boolean => { + // return the latest alpha/beta if a user subscribes to alpha/beta updates + if (['alpha', 'beta'].includes(channel)) { + return version.includes(channel) + } else { + // otherwise get the latest stable version + return !version.includes('alpha') && !version.includes('beta') + } +} + +export const latestVersionForChannel = ( + availableVersions: string[], + channel: string +): string | null => + availableVersions + .filter(version => channelFinder(version, channel)) + .sort((a, b) => (semver.gt(a, b) ? 1 : -1)) + .pop() ?? null + +export const shouldUpdate = ( + currentVersion: string, + availableVersion: string | null +): string | null => + availableVersion != null && currentVersion !== availableVersion + ? availableVersion + : null diff --git a/app-shell-odd/src/system-update/from-web/provider.ts b/app-shell-odd/src/system-update/from-web/provider.ts new file mode 100644 index 00000000000..ca5c8da9fc9 --- /dev/null +++ b/app-shell-odd/src/system-update/from-web/provider.ts @@ -0,0 +1,209 @@ +import path from 'path' +import { rm } from 'fs/promises' + +import { createLogger } from '../../log' +import { LocalAbortError } from '../../http' + +import type { + UpdateProvider, + ResolvedUpdate, + UnresolvedUpdate, + ProgressCallback, + NoUpdate, +} from '../types' + +import { getOrDownloadManifest, getReleaseSet } from './release-manifest' +import { cleanUpAndGetOrDownloadReleaseFiles } from './release-files' +import { latestVersionForChannel, shouldUpdate } from './latest-update' + +import type { DownloadProgress } from '../../http' + +const log = createLogger('systemUpdate/from-web/provider') + +export interface WebUpdateSource { + manifestUrl: string + channel: string + updateCacheDirectory: string + currentVersion: string +} + +export function getProvider( + from: WebUpdateSource +): UpdateProvider { + let locked = false + let canceller = new AbortController() + const lockCache = (): void => { + locked = true + canceller.abort('cache locked') + canceller = new AbortController() + } + const versionCacheDir = path.join(from.updateCacheDirectory, 'versions') + const noUpdate = { + version: null, + files: null, + releaseNotes: null, + downloadProgress: 0, + } as const + let currentUpdate: UnresolvedUpdate = noUpdate + let currentCheck: Promise | null = null + const updater = async ( + progress: ProgressCallback + ): Promise => { + const myCanceller = canceller + // this needs to be an `as`-assertion on the value because we can only guarantee that + // currentUpdate is resolved by the function of the program: we know that this function, + // which is the only thing that can alter currentUpdate, will always end with a resolved update, + // and we know that this function will not be running twice at the same time. + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const previousUpdate = { + version: currentUpdate.version, + files: currentUpdate.files == null ? null : { ...currentUpdate.files }, + releaseNotes: currentUpdate.releaseNotes, + downloadProgress: currentUpdate.downloadProgress, + } as ResolvedUpdate + if (locked) { + throw new Error('cache locked') + } + const returnNoUpdate = (): NoUpdate => { + currentUpdate = noUpdate + progress(noUpdate) + return noUpdate + } + const manifest = await getOrDownloadManifest( + from.manifestUrl, + from.updateCacheDirectory, + myCanceller + ).catch((error: Error) => { + if (myCanceller.signal.aborted) { + log.info('aborted cache update because cache was locked') + currentUpdate = previousUpdate + progress(previousUpdate) + throw error + } + log.info( + `Failed to get or download update manifest: ${error.name}: ${error.message}` + ) + return null + }) + if (manifest == null) { + log.info(`no manifest found, returning`) + return returnNoUpdate() + } + const latestVersion = latestVersionForChannel( + Object.keys(manifest.production), + from.channel + ) + + const versionToUpdate = shouldUpdate(from.currentVersion, latestVersion) + if (versionToUpdate == null) { + log.debug(`no update found, returning`) + return returnNoUpdate() + } + const releaseUrls = getReleaseSet(manifest, versionToUpdate) + if (releaseUrls == null) { + log.debug(`no release urls found, returning`) + return returnNoUpdate() + } + log.info(`Finding version ${latestVersion}`) + const downloadingUpdate = { + version: latestVersion, + files: null, + releaseNotes: null, + downloadProgress: 0, + } as const + progress(downloadingUpdate) + currentUpdate = downloadingUpdate + + if (myCanceller.signal.aborted) { + log.info('aborted cache update because cache was locked') + currentUpdate = previousUpdate + progress(previousUpdate) + throw new LocalAbortError('cache locked') + } + const localFiles = await cleanUpAndGetOrDownloadReleaseFiles( + releaseUrls, + versionCacheDir, + versionToUpdate, + (downloadProgress: DownloadProgress): void => { + const downloadProgressPercent = + downloadProgress.size == null || downloadProgress.size === 0.0 + ? 0 + : (downloadProgress.downloaded / downloadProgress.size) * 100 + log.debug( + `Downloading update ${versionToUpdate}: ${downloadProgress.downloaded}/${downloadProgress.size}B (${downloadProgressPercent}%)` + ) + const update = { + version: versionToUpdate, + files: null, + releaseNotes: null, + downloadProgress: downloadProgressPercent, + } + currentUpdate = update + progress(update) + }, + myCanceller + ).catch((err: Error) => { + if (myCanceller.signal.aborted) { + currentUpdate = previousUpdate + progress(previousUpdate) + throw err + } else { + log.warn(`Failed to fetch update data: ${err.name}: ${err.message}`) + } + return null + }) + + if (localFiles == null) { + log.info( + `Download of ${versionToUpdate} failed, no release data is available` + ) + return returnNoUpdate() + } + if (myCanceller.signal.aborted) { + currentUpdate = previousUpdate + progress(previousUpdate) + throw new LocalAbortError('cache locked') + } + + const updateDetails = { + version: versionToUpdate, + files: { + system: localFiles.system, + releaseNotes: localFiles.releaseNotes, + }, + releaseNotes: localFiles.releaseNotesContent, + downloadProgress: 100, + } as const + currentUpdate = updateDetails + progress(updateDetails) + return updateDetails + } + return { + getUpdateDetails: () => currentUpdate, + refreshUpdateCache: (progress: ProgressCallback) => { + if (currentCheck != null) { + return new Promise((resolve, reject) => { + reject(new Error('Check already ongoing')) + }) + } else { + const updaterPromise = updater(progress) + currentCheck = updaterPromise + return updaterPromise.finally(() => { + currentCheck = null + }) + } + }, + + teardown: () => { + lockCache() + return rm(from.updateCacheDirectory, { recursive: true, force: true }) + }, + lockUpdateCache: lockCache, + unlockUpdateCache: () => { + locked = false + }, + name: () => + `WebUpdateProvider from ${from.manifestUrl} channel ${from.channel}`, + source: () => from, + } +} diff --git a/app-shell-odd/src/system-update/from-web/release-files.ts b/app-shell-odd/src/system-update/from-web/release-files.ts new file mode 100644 index 00000000000..a3c45cf5d42 --- /dev/null +++ b/app-shell-odd/src/system-update/from-web/release-files.ts @@ -0,0 +1,243 @@ +// functions for downloading and storing release files + +import path from 'path' +import tempy from 'tempy' +import { move, readdir, rm, mkdirp, readFile } from 'fs-extra' +import { fetchToFile } from '../../http' +import { createLogger } from '../../log' + +import type { DownloadProgress } from '../../http' +import type { ReleaseSetUrls, ReleaseSetFilepaths } from '../types' +import type { Dirent } from 'fs' + +const log = createLogger('systemUpdate/from-web/release-files') +const outPath = (dir: string, url: string): string => { + return path.join(dir, path.basename(url)) +} + +const RELEASE_DIRECTORY_PREFIX = 'cached-release-' + +export const directoryNameForRelease = (version: string): string => + `${RELEASE_DIRECTORY_PREFIX}${version}` + +export const directoryForRelease = ( + baseDirectory: string, + version: string +): string => path.join(baseDirectory, directoryNameForRelease(version)) + +async function ensureReleaseCache(baseDirectory: string): Promise { + try { + return await readdir(baseDirectory, { withFileTypes: true }) + } catch (error: any) { + console.log( + `Could not read download cache base directory: ${error.name}: ${error.message}: remaking` + ) + await rm(baseDirectory, { force: true, recursive: true }) + await mkdirp(baseDirectory) + return [] + } +} + +export const ensureCleanReleaseCacheForVersion = ( + baseDirectory: string, + version: string +): Promise => + ensureReleaseCache(baseDirectory) + .then(contents => + Promise.all( + contents.map(contained => + !contained.isDirectory() || + contained.name !== directoryNameForRelease(version) + ? rm(path.join(baseDirectory, contained.name), { + force: true, + recursive: true, + }) + : new Promise(resolve => { + resolve() + }) + ) + ) + ) + .then(() => mkdirp(directoryForRelease(baseDirectory, version))) + .then(() => directoryForRelease(baseDirectory, version)) + +export interface ReleaseSetData extends ReleaseSetFilepaths { + releaseNotesContent: string | null +} + +export const augmentWithReleaseNotesContent = ( + releaseFiles: ReleaseSetFilepaths +): Promise => + releaseFiles.releaseNotes == null + ? new Promise(resolve => { + resolve({ ...releaseFiles, releaseNotesContent: null }) + }) + : readReleaseNotes(releaseFiles.releaseNotes) + .then(releaseNotesContent => ({ ...releaseFiles, releaseNotesContent })) + .catch(err => { + log.error( + `Release notes should be present but cannot be read: ${err.name}: ${err.message}` + ) + return { ...releaseFiles, releaseNotesContent: null } + }) + +// checks `directory` for system update files matching the given `urls`, and +// downloads them if they can't be found +export function getReleaseFiles( + urls: ReleaseSetUrls, + directory: string +): Promise { + return readdir(directory).then((files: string[]) => { + log.info(`Files in system update download directory ${directory}: ${files}`) + const expected = { + system: path.basename(urls.system), + releaseNotes: + urls?.releaseNotes == null ? null : path.basename(urls.releaseNotes), + } + const foundFiles = files.reduce>( + ( + releaseSetFilePaths: Partial, + thisFile: string + ): Partial => { + if (thisFile === expected.system) { + return { ...releaseSetFilePaths, system: thisFile } + } + if ( + expected.releaseNotes != null && + thisFile === expected.releaseNotes + ) { + return { ...releaseSetFilePaths, releaseNotes: thisFile } + } + return releaseSetFilePaths + }, + {} + ) + if (foundFiles?.system != null) { + const files = { + system: outPath(directory, foundFiles.system), + releaseNotes: + foundFiles?.releaseNotes != null + ? outPath(directory, foundFiles.releaseNotes) + : null, + } + log.info( + `Found system file ${foundFiles.system} in cache directory ${directory}` + ) + return augmentWithReleaseNotesContent(files) + } + + throw new Error( + `no release files cached: could not find system file ${outPath( + directory, + urls.system + )} in ${files}` + ) + }) +} + +// downloads the entire release set to a temporary directory, and once they're +// all successfully downloaded, renames the directory to `directory` +export function downloadReleaseFiles( + urls: ReleaseSetUrls, + directory: string, + // `onProgress` will be called with download progress as the files are read + onProgress: (progress: DownloadProgress) => void, + canceller: AbortController +): Promise { + const tempDir: string = tempy.directory() + const tempSystemPath = outPath(tempDir, urls.system) + const tempNotesPath = outPath(tempDir, urls.releaseNotes ?? '') + // downloads are streamed directly to the filesystem to avoid loading them + // all into memory simultaneously + const notesReq = + urls.releaseNotes != null + ? fetchToFile(urls.releaseNotes, tempNotesPath, { + signal: canceller.signal, + }).catch(err => { + log.warn( + `release notes not available from ${urls.releaseNotes}: ${err.name}: ${err.message}` + ) + return null + }) + : Promise.resolve(null) + if (urls.releaseNotes != null) { + log.info(`Downloading ${urls.releaseNotes} to ${tempNotesPath}`) + } else { + log.info('No release notes available, not downloading') + } + log.info(`Downloading ${urls.system} to ${tempSystemPath}`) + const systemReq = fetchToFile(urls.system, tempSystemPath, { + onProgress, + signal: canceller.signal, + }) + return Promise.all([systemReq, notesReq]) + .then(results => { + const [systemTemp, releaseNotesTemp] = results + const systemPath = outPath(directory, systemTemp) + const notesPath = releaseNotesTemp + ? outPath(directory, releaseNotesTemp) + : null + + log.info(`Download complete, ${tempDir}=>${directory}`) + + return move(tempDir, directory, { overwrite: true }).then(() => { + log.info(`Move complete`) + return augmentWithReleaseNotesContent({ + system: systemPath, + releaseNotes: notesPath, + }) + }) + }) + .catch(error => { + log.error( + `Failed to download release files: ${error.name}: ${error.message}` + ) + return rm(tempDir, { force: true, recursive: true }).then(() => { + throw error + }) + }) +} + +export async function getOrDownloadReleaseFiles( + urls: ReleaseSetUrls, + releaseCacheDirectory: string, + onProgress: (progress: DownloadProgress) => void, + canceller: AbortController +): Promise { + try { + return await getReleaseFiles(urls, releaseCacheDirectory) + } catch (error: any) { + log.info( + `Could not find cached release files for ${releaseCacheDirectory}: ${error.name}: ${error.message}, attempting to download` + ) + return await downloadReleaseFiles( + urls, + releaseCacheDirectory, + onProgress, + canceller + ) + } +} + +export const cleanUpAndGetOrDownloadReleaseFiles = ( + urls: ReleaseSetUrls, + baseDirectory: string, + version: string, + onProgress: (progress: DownloadProgress) => void, + canceller: AbortController +): Promise => + ensureCleanReleaseCacheForVersion(baseDirectory, version).then(versionCache => + getOrDownloadReleaseFiles(urls, versionCache, onProgress, canceller) + ) + +const readReleaseNotes = (path: string | null): Promise => + path == null + ? new Promise(resolve => { + resolve(null) + }) + : readFile(path, { encoding: 'utf-8' }).catch(err => { + log.warn( + `Could not read release notes from ${path}: ${err.name}: ${err.message}` + ) + return null + }) diff --git a/app-shell-odd/src/system-update/from-web/release-manifest.ts b/app-shell-odd/src/system-update/from-web/release-manifest.ts new file mode 100644 index 00000000000..9433067cb17 --- /dev/null +++ b/app-shell-odd/src/system-update/from-web/release-manifest.ts @@ -0,0 +1,101 @@ +import * as FS from 'fs/promises' +import path from 'path' +import { readJson, outputJson } from 'fs-extra' + +import type { Stats } from 'fs' +import { fetchJson, LocalAbortError } from '../../http' +import type { ReleaseManifest, ReleaseSetUrls } from '../types' +import { createLogger } from '../../log' + +const log = createLogger('systemUpdate/from-web/provider') + +export function getReleaseSet( + manifest: ReleaseManifest, + version: string +): ReleaseSetUrls | null { + return manifest.production[version] ?? null +} + +export const getCachedReleaseManifest = ( + cacheDir: string +): Promise => readJson(`${cacheDir}/manifest.json`) + +const removeAndRemake = (directory: string): Promise => + FS.rm(directory, { recursive: true, force: true }) + .then(() => FS.mkdir(directory, { recursive: true })) + .then(() => FS.stat(directory)) + +export const ensureCacheDir = (directory: string): Promise => + FS.stat(directory) + .catch(() => removeAndRemake(directory)) + .then(stats => + stats.isDirectory() + ? new Promise(resolve => { + resolve(stats) + }) + : removeAndRemake(directory) + ) + .then(() => FS.readdir(directory, { withFileTypes: true })) + .then(contents => { + const manifestCandidate = contents.find( + entry => entry.name === 'manifest.json' + ) + if (manifestCandidate == null || manifestCandidate.isFile()) { + return new Promise(resolve => { + resolve(directory) + }) + } + return FS.rm(path.join(directory, 'manifest.json'), { + force: true, + recursive: true, + }).then(() => directory) + }) + +export const downloadManifest = ( + manifestUrl: string, + cacheDir: string, + cancel: AbortController +): Promise => { + log.info(`Attempting to fetch release manifest from ${manifestUrl}`) + return fetchJson(manifestUrl, { + signal: cancel.signal, + }).then(manifest => { + log.info('Fetched release manifest OK') + return outputJson(path.join(cacheDir, 'manifest.json'), manifest).then( + () => manifest + ) + }) +} + +export const ensureCacheDirAndDownloadManifest = ( + manifestUrl: string, + cacheDir: string, + cancel: AbortController +): Promise => + ensureCacheDir(cacheDir).then(ensuredCacheDir => + downloadManifest(manifestUrl, ensuredCacheDir, cancel) + ) + +export async function getOrDownloadManifest( + manifestUrl: string, + cacheDir: string, + cancel: AbortController +): Promise { + try { + return await ensureCacheDirAndDownloadManifest( + manifestUrl, + cacheDir, + cancel + ) + } catch (error: any) { + if (error instanceof LocalAbortError) { + log.info('Aborted during manifest fetch') + throw error + } else { + log.info( + `Could not fetch manifest: ${error.name}: ${error.message}, falling back to cached` + ) + return await getCachedReleaseManifest(cacheDir) + } + } +} diff --git a/app-shell-odd/src/system-update/handler.ts b/app-shell-odd/src/system-update/handler.ts new file mode 100644 index 00000000000..8344578e9fa --- /dev/null +++ b/app-shell-odd/src/system-update/handler.ts @@ -0,0 +1,380 @@ +// system update handler + +import Semver from 'semver' + +import { CONFIG_INITIALIZED, VALUE_UPDATED } from '../constants' +import { createLogger } from '../log' +import { postFile } from '../http' +import { getConfig } from '../config' +import { getSystemUpdateDir } from './directories' +import { SYSTEM_FILENAME, FLEX_MANIFEST_URL } from './constants' +import { getProvider as getWebUpdateProvider } from './from-web' +import { getProvider as getUsbUpdateProvider } from './from-usb' + +import type { Action, Dispatch } from '../types' +import type { UpdateProvider, UnresolvedUpdate, ReadyUpdate } from './types' +import type { USBUpdateSource } from './from-usb' + +export const CURRENT_SYSTEM_VERSION = _PKG_VERSION_ + +const log = createLogger('system-update/handler') + +export interface UpdateDriver { + handleAction: (action: Action) => Promise + reload: () => Promise + shouldReload: () => boolean + teardown: () => Promise +} + +export function createUpdateDriver(dispatch: Dispatch): UpdateDriver { + log.info(`Running robot system updates storing to ${getSystemUpdateDir()}`) + + let webUpdate: UnresolvedUpdate = { + version: null, + files: null, + releaseNotes: null, + downloadProgress: 0, + } + let webProvider = getWebUpdateProvider({ + manifestUrl: FLEX_MANIFEST_URL, + channel: getConfig('update').channel, + updateCacheDirectory: getSystemUpdateDir(), + currentVersion: CURRENT_SYSTEM_VERSION, + }) + const usbProviders: Record> = {} + let currentBestUsbUpdate: + | (ReadyUpdate & { providerName: string }) + | null = null + + const updateBestUsbUpdate = (): void => { + currentBestUsbUpdate = null + Object.values(usbProviders).forEach(provider => { + const providerUpdate = provider.getUpdateDetails() + if (providerUpdate.files == null) { + // nothing to do, keep null + } else if (currentBestUsbUpdate == null) { + currentBestUsbUpdate = { + ...(providerUpdate as ReadyUpdate), + providerName: provider.name(), + } + } else if ( + Semver.gt(providerUpdate.version, currentBestUsbUpdate.version) + ) { + currentBestUsbUpdate = { + ...(providerUpdate as ReadyUpdate), + providerName: provider.name(), + } + } + }) + } + + const dispatchStaticUpdateData = (): void => { + if (currentBestUsbUpdate != null) { + dispatchUpdateInfo( + { + version: currentBestUsbUpdate.version, + releaseNotes: currentBestUsbUpdate.releaseNotes, + force: true, + }, + dispatch + ) + } else { + dispatchUpdateInfo( + { + version: webUpdate.version, + releaseNotes: webUpdate.releaseNotes, + force: false, + }, + dispatch + ) + } + } + + return { + handleAction: (action: Action): Promise => { + switch (action.type) { + case 'shell:CHECK_UPDATE': + return webProvider + .refreshUpdateCache(updateStatus => { + webUpdate = updateStatus + if (currentBestUsbUpdate == null) { + if ( + updateStatus.version != null && + updateStatus.files == null && + updateStatus.downloadProgress === 0 + ) { + dispatch({ + type: 'robotUpdate:UPDATE_VERSION', + payload: { + version: updateStatus.version, + force: false, + target: 'flex', + }, + }) + } else if ( + updateStatus.version != null && + updateStatus.files == null && + updateStatus.downloadProgress !== 0 + ) { + dispatch({ + // TODO: change this action type to 'systemUpdate:DOWNLOAD_PROGRESS' + type: 'robotUpdate:DOWNLOAD_PROGRESS', + payload: { + progress: updateStatus.downloadProgress, + target: 'flex', + }, + }) + } else if (updateStatus.files != null) { + dispatchStaticUpdateData() + } + } + }) + .catch(err => { + log.warn( + `Error finding updates with ${webProvider.name()}: ${ + err.name + }: ${err.message}` + ) + return { + version: null, + files: null, + downloadProgress: 0, + releaseNotes: null, + } as const + }) + .then(result => { + webUpdate = result + dispatchStaticUpdateData() + }) + case 'shell:ROBOT_MASS_STORAGE_DEVICE_ENUMERATED': + log.info( + `mass storage device enumerated at ${action.payload.rootPath}` + ) + if (usbProviders[action.payload.rootPath] != null) { + return new Promise(resolve => { + resolve() + }) + } + usbProviders[action.payload.rootPath] = getUsbUpdateProvider({ + currentVersion: CURRENT_SYSTEM_VERSION, + massStorageDeviceRoot: action.payload.rootPath, + massStorageDeviceFiles: action.payload.filePaths, + }) + return usbProviders[action.payload.rootPath] + .refreshUpdateCache(() => {}) + .then(() => { + updateBestUsbUpdate() + dispatchStaticUpdateData() + }) + .catch(err => { + log.error( + `Failed to get updates from ${action.payload.rootPath}: ${err.name}: ${err.message}` + ) + }) + + case 'shell:ROBOT_MASS_STORAGE_DEVICE_REMOVED': + log.info(`mass storage removed at ${action.payload.rootPath}`) + const provider = usbProviders[action.payload.rootPath] + if (provider != null) { + return provider + .teardown() + .then(() => { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete usbProviders[action.payload.rootPath] + updateBestUsbUpdate() + }) + .catch(err => { + log.error( + `Failed to tear down provider ${provider.name()}: ${ + err.name + }: ${err.message}` + ) + }) + .then(() => { + dispatchStaticUpdateData() + }) + } + return new Promise(resolve => { + resolve() + }) + case 'robotUpdate:UPLOAD_FILE': { + const { host, path, systemFile } = action.payload + // eslint-disable-next-line @typescript-eslint/no-floating-promises + return postFile( + `http://${host.ip}:${host.port}${path}`, + SYSTEM_FILENAME, + systemFile + ) + .then(() => ({ + type: 'robotUpdate:FILE_UPLOAD_DONE' as const, + payload: host.name, + })) + .catch((error: Error) => { + log.warn('Error uploading update to robot', { + path, + systemFile, + error, + }) + + return { + type: 'robotUpdate:UNEXPECTED_ERROR' as const, + payload: { + message: `Error uploading update to robot: ${error.message}`, + }, + } + }) + .then(dispatch) + } + case 'robotUpdate:READ_SYSTEM_FILE': { + const getDetails = (): { + systemFile: string + version: string + isManualFile: false + } | null => { + if (currentBestUsbUpdate) { + return { + systemFile: currentBestUsbUpdate.files.system, + version: currentBestUsbUpdate.version, + isManualFile: false, + } + } else if (webUpdate.files?.system != null) { + return { + systemFile: webUpdate.files.system, + version: webUpdate.version as string, // version is string if files is not null + isManualFile: false, + } + } else { + return null + } + } + return new Promise(resolve => { + const details = getDetails() + if (details == null) { + dispatch({ + type: 'robotUpdate:UNEXPECTED_ERROR', + payload: { message: 'System update file not downloaded' }, + }) + resolve() + return + } + + dispatch({ + type: 'robotUpdate:FILE_INFO' as const, + payload: details, + }) + resolve() + }) + } + case 'robotUpdate:READ_USER_FILE': { + return new Promise(resolve => { + dispatch({ + type: 'robotUpdate:UNEXPECTED_ERROR', + payload: { + message: 'Updates of this kind are not implemented for ODD', + }, + }) + resolve() + }) + } + } + return new Promise(resolve => { + resolve() + }) + }, + reload: () => { + webProvider.lockUpdateCache() + return webProvider + .teardown() + .catch(err => { + log.error( + `Failed to tear down web provider ${webProvider.name()}: ${ + err.name + }: ${err.message}` + ) + }) + .then(() => { + webProvider = getWebUpdateProvider({ + manifestUrl: FLEX_MANIFEST_URL, + channel: getConfig('update').channel, + updateCacheDirectory: getSystemUpdateDir(), + currentVersion: CURRENT_SYSTEM_VERSION, + }) + }) + .catch(err => { + const message = `System updates failed to handle config change: ${err.name}: ${err.message}` + log.error(message) + dispatch({ + type: 'robotUpdate:UNEXPECTED_ERROR', + payload: { message: message }, + }) + }) + }, + shouldReload: () => + getConfig('update').channel !== webProvider.source().channel, + teardown: () => { + return Promise.allSettled([ + webProvider.teardown(), + ...Object.values(usbProviders).map(provider => provider.teardown()), + ]) + .catch(errs => { + log.error(`Failed to tear down some providers: ${errs}`) + }) + .then(results => { + log.info('all providers torn down') + }) + }, + } +} + +export interface UpdatableDriver { + getUpdateDriver: () => UpdateDriver | null + handleAction: (action: Action) => Promise +} + +export function manageDriver(dispatch: Dispatch): UpdatableDriver { + let updateDriver: UpdateDriver | null = null + return { + handleAction: action => { + if (action.type === CONFIG_INITIALIZED) { + log.info('Initializing update driver') + return new Promise(resolve => { + updateDriver = createUpdateDriver(dispatch) + resolve() + }) + } else if (updateDriver != null) { + if (action.type === VALUE_UPDATED && updateDriver.shouldReload()) { + return updateDriver.reload() + } else { + return updateDriver.handleAction(action) + } + } else { + return new Promise(resolve => { + log.warn( + `update driver manager received action ${action.type} before initialization` + ) + resolve() + }) + } + }, + getUpdateDriver: () => updateDriver, + } +} + +export function registerRobotSystemUpdate(dispatch: Dispatch): Dispatch { + return manageDriver(dispatch).handleAction +} + +const dispatchUpdateInfo = ( + info: { version: string | null; releaseNotes: string | null; force: boolean }, + dispatch: Dispatch +): void => { + const { version, releaseNotes, force } = info + dispatch({ + type: 'robotUpdate:UPDATE_INFO', + payload: { releaseNotes, version, force, target: 'flex' }, + }) + dispatch({ + type: 'robotUpdate:UPDATE_VERSION', + payload: { version, force, target: 'flex' }, + }) +} diff --git a/app-shell-odd/src/system-update/index.ts b/app-shell-odd/src/system-update/index.ts index 7d8e62fb8ac..4ec36b05a57 100644 --- a/app-shell-odd/src/system-update/index.ts +++ b/app-shell-odd/src/system-update/index.ts @@ -1,394 +1,2 @@ // system update files -import path from 'path' -import { ensureDir } from 'fs-extra' -import { readFile } from 'fs/promises' -import StreamZip from 'node-stream-zip' -import Semver from 'semver' -import { UI_INITIALIZED } from '../constants' -import { createLogger } from '../log' -import { - getLatestSystemUpdateUrls, - getLatestVersion, - isUpdateAvailable, - updateLatestVersion, -} from '../update' -import { - getReleaseFiles, - readUserFileInfo, - cleanupReleaseFiles, -} from './release-files' -import { uploadSystemFile } from './update' -import { getSystemUpdateDir } from './directories' - -import type { DownloadProgress } from '../http' -import type { Action, Dispatch } from '../types' -import type { ReleaseSetFilepaths } from './types' - -const log = createLogger('systemUpdate/index') -const REASONABLE_VERSION_FILE_SIZE_B = 4096 - -let isGettingLatestSystemFiles = false -const isGettingMassStorageUpdatesFrom: Set = new Set() -let massStorageUpdateSet: ReleaseSetFilepaths | null = null -let systemUpdateSet: ReleaseSetFilepaths | null = null - -const readFileInfoAndDispatch = ( - dispatch: Dispatch, - fileName: string, - isManualFile: boolean = false -): Promise => - readUserFileInfo(fileName) - .then(fileInfo => ({ - type: 'robotUpdate:FILE_INFO' as const, - payload: { - systemFile: fileInfo.systemFile, - version: fileInfo.versionInfo.opentrons_api_version, - isManualFile, - }, - })) - .catch((error: Error) => ({ - type: 'robotUpdate:UNEXPECTED_ERROR' as const, - payload: { message: error.message }, - })) - .then(dispatch) - -export function registerRobotSystemUpdate(dispatch: Dispatch): Dispatch { - log.info(`Running robot system updates storing to ${getSystemUpdateDir()}`) - return function handleAction(action: Action) { - switch (action.type) { - case UI_INITIALIZED: - case 'shell:CHECK_UPDATE': - // short circuit early if we're already downloading the latest system files - if (isGettingLatestSystemFiles) { - log.info(`system update download already in progress`) - return - } - updateLatestVersion() - .then(() => { - if (isUpdateAvailable() && !isGettingLatestSystemFiles) { - isGettingLatestSystemFiles = true - return getLatestSystemUpdateFiles(dispatch) - } - }) - .then(() => { - isGettingLatestSystemFiles = false - }) - .catch((error: Error) => { - log.warn('Error checking for update', { - error, - }) - isGettingLatestSystemFiles = false - }) - - break - - case 'robotUpdate:UPLOAD_FILE': { - const { host, path, systemFile } = action.payload - // eslint-disable-next-line @typescript-eslint/no-floating-promises - uploadSystemFile(host, path, systemFile) - .then(() => ({ - type: 'robotUpdate:FILE_UPLOAD_DONE' as const, - payload: host.name, - })) - .catch((error: Error) => { - log.warn('Error uploading update to robot', { - path, - systemFile, - error, - }) - - return { - type: 'robotUpdate:UNEXPECTED_ERROR' as const, - payload: { - message: `Error uploading update to robot: ${error.message}`, - }, - } - }) - .then(dispatch) - - break - } - - case 'robotUpdate:READ_USER_FILE': { - const { systemFile } = action.payload as { systemFile: string } - // eslint-disable-next-line @typescript-eslint/no-floating-promises - readFileInfoAndDispatch(dispatch, systemFile, true) - break - } - case 'robotUpdate:READ_SYSTEM_FILE': { - const systemFile = - massStorageUpdateSet?.system ?? systemUpdateSet?.system - if (systemFile == null) { - dispatch({ - type: 'robotUpdate:UNEXPECTED_ERROR', - payload: { message: 'System update file not downloaded' }, - }) - return - } - // eslint-disable-next-line @typescript-eslint/no-floating-promises - readFileInfoAndDispatch(dispatch, systemFile) - break - } - case 'shell:ROBOT_MASS_STORAGE_DEVICE_ENUMERATED': - if (isGettingMassStorageUpdatesFrom.has(action.payload.rootPath)) { - return - } - isGettingMassStorageUpdatesFrom.add(action.payload.rootPath) - getLatestMassStorageUpdateFiles(action.payload.filePaths, dispatch) - .then(() => { - isGettingMassStorageUpdatesFrom.delete(action.payload.rootPath) - }) - .catch(() => { - isGettingMassStorageUpdatesFrom.delete(action.payload.rootPath) - }) - break - case 'shell:ROBOT_MASS_STORAGE_DEVICE_REMOVED': - if ( - massStorageUpdateSet !== null && - massStorageUpdateSet.system.startsWith(action.payload.rootPath) - ) { - console.log( - `Mass storage device ${action.payload.rootPath} removed, reverting to non-usb updates` - ) - massStorageUpdateSet = null - getCachedSystemUpdateFiles(dispatch) - } else { - console.log( - `Mass storage device ${action.payload.rootPath} removed but this was not an update source` - ) - } - break - } - } -} - -const getVersionFromOpenedZipIfValid = (zip: StreamZip): Promise => - new Promise((resolve, reject) => { - Object.values(zip.entries()).forEach(entry => { - if ( - entry.isFile && - entry.name === 'VERSION.json' && - entry.size < REASONABLE_VERSION_FILE_SIZE_B - ) { - const contents = zip.entryDataSync(entry.name).toString('ascii') - try { - const parsedContents = JSON.parse(contents) - if (parsedContents?.robot_type !== 'OT-3 Standard') { - reject(new Error('not a Flex release file')) - } - const fileVersion = parsedContents?.opentrons_api_version - const version = Semver.valid(fileVersion as string) - if (version === null) { - reject(new Error(`${fileVersion} is not a valid version`)) - } else { - resolve(version) - } - } catch (error) { - reject(error) - } - } - }) - }) - -interface FileDetails { - path: string - version: string -} - -const getVersionFromZipIfValid = (path: string): Promise => - new Promise((resolve, reject) => { - const zip = new StreamZip({ file: path, storeEntries: true }) - zip.on('ready', () => { - getVersionFromOpenedZipIfValid(zip) - .then(version => { - zip.close() - resolve({ version, path }) - }) - .catch(err => { - zip.close() - reject(err) - }) - }) - zip.on('error', err => { - zip.close() - reject(err) - }) - }) - -const fakeReleaseNotesForMassStorage = (version: string): string => ` -# Opentrons Robot Software Version ${version} - -This update is from a USB mass storage device connected to your Flex, and release notes cannot be shown. - -Don't remove the USB mass storage device while the update is in progress. -` - -export const getLatestMassStorageUpdateFiles = ( - filePaths: string[], - dispatch: Dispatch -): Promise => - Promise.all( - filePaths.map(path => - path.endsWith('.zip') - ? getVersionFromZipIfValid(path).catch(() => null) - : new Promise(resolve => { - resolve(null) - }) - ) - ).then(values => { - const update = values.reduce( - (prev, current) => - prev === null - ? current === null - ? prev - : current - : current === null - ? prev - : Semver.gt(current.version, prev.version) - ? current - : prev, - null - ) - if (update === null) { - console.log('no updates found in mass storage device') - } else { - console.log(`found update to version ${update.version} on mass storage`) - const releaseNotes = fakeReleaseNotesForMassStorage(update.version) - massStorageUpdateSet = { system: update.path, releaseNotes } - dispatchUpdateInfo( - { version: update.version, releaseNotes, force: true }, - dispatch - ) - } - }) - -const dispatchUpdateInfo = ( - info: { version: string | null; releaseNotes: string | null; force: boolean }, - dispatch: Dispatch -): void => { - const { version, releaseNotes, force } = info - dispatch({ - type: 'robotUpdate:UPDATE_INFO', - payload: { releaseNotes, version, force, target: 'flex' }, - }) - dispatch({ - type: 'robotUpdate:UPDATE_VERSION', - payload: { version, force, target: 'flex' }, - }) -} - -// Get latest system update version -// 1. Ensure the system update directory exists -// 2. Get the manifest file from the local cache -// 3. Get the release files according to the manifest -// a. If the files need downloading, dispatch progress updates to UI -// 4. Cache the filepaths of the update files in memory -// 5. Dispatch info or error to UI -export function getLatestSystemUpdateFiles( - dispatch: Dispatch -): Promise { - const fileDownloadDir = path.join( - getSystemUpdateDir(), - 'robot-system-updates' - ) - - return ensureDir(getSystemUpdateDir()) - .then(() => getLatestSystemUpdateUrls()) - .then(urls => { - if (urls === null) { - const latestVersion = getLatestVersion() - log.warn('No release files in manifest', { - version: latestVersion, - }) - return Promise.reject( - new Error(`No release files in manifest for version ${latestVersion}`) - ) - } - - let prevPercentDone = 0 - - const handleProgress = (progress: DownloadProgress): void => { - const { downloaded, size } = progress - if (size !== null) { - const percentDone = Math.round((downloaded / size) * 100) - if (Math.abs(percentDone - prevPercentDone) > 0) { - if (massStorageUpdateSet === null) { - dispatch({ - // TODO: change this action type to 'systemUpdate:DOWNLOAD_PROGRESS' - type: 'robotUpdate:DOWNLOAD_PROGRESS', - payload: { progress: percentDone, target: 'flex' }, - }) - } - prevPercentDone = percentDone - } - } - } - - return getReleaseFiles(urls, fileDownloadDir, handleProgress) - .then(filepaths => { - return cacheUpdateSet(filepaths) - }) - .then(updateInfo => { - massStorageUpdateSet === null && - dispatchUpdateInfo({ force: false, ...updateInfo }, dispatch) - }) - .catch((error: Error) => { - dispatch({ - type: 'robotUpdate:DOWNLOAD_ERROR', - payload: { error: error.message, target: 'flex' }, - }) - }) - .then(() => - cleanupReleaseFiles(getSystemUpdateDir(), 'robot-system-updates') - ) - .catch((error: Error) => { - log.warn('Unable to cleanup old release files', { error }) - }) - }) -} - -export function getCachedSystemUpdateFiles( - dispatch: Dispatch -): Promise { - if (systemUpdateSet) { - return getInfoFromUpdateSet(systemUpdateSet) - .then(updateInfo => { - dispatchUpdateInfo({ force: false, ...updateInfo }, dispatch) - }) - .catch(err => { - console.log(`Could not get info from update set: ${err}`) - }) - } else { - dispatchUpdateInfo( - { version: null, releaseNotes: null, force: false }, - dispatch - ) - return new Promise(resolve => { - resolve('no files') - }) - } -} - -function getInfoFromUpdateSet( - filepaths: ReleaseSetFilepaths -): Promise<{ version: string; releaseNotes: string | null }> { - const version = getLatestVersion() - const releaseNotesContentPromise = filepaths.releaseNotes - ? readFile(filepaths.releaseNotes, 'utf8') - : new Promise(resolve => { - resolve(null) - }) - return releaseNotesContentPromise - .then(releaseNotes => ({ - version: version, - releaseNotes, - })) - .catch(() => ({ version: version, releaseNotes: '' })) -} - -function cacheUpdateSet( - filepaths: ReleaseSetFilepaths -): Promise<{ version: string; releaseNotes: string | null }> { - systemUpdateSet = filepaths - return getInfoFromUpdateSet(systemUpdateSet) -} +export { registerRobotSystemUpdate } from './handler' diff --git a/app-shell-odd/src/system-update/release-files.ts b/app-shell-odd/src/system-update/release-files.ts deleted file mode 100644 index 6ea57648d05..00000000000 --- a/app-shell-odd/src/system-update/release-files.ts +++ /dev/null @@ -1,148 +0,0 @@ -// functions for downloading and storing release files -import assert from 'assert' -import path from 'path' -import { promisify } from 'util' -import tempy from 'tempy' -import { move, readdir, remove } from 'fs-extra' -import StreamZip from 'node-stream-zip' -import getStream from 'get-stream' - -import { createLogger } from '../log' -import { fetchToFile } from '../http' -import type { DownloadProgress } from '../http' -import type { ReleaseSetUrls, ReleaseSetFilepaths, UserFileInfo } from './types' - -const VERSION_FILENAME = 'VERSION.json' - -const log = createLogger('systemUpdate/release-files') -const outPath = (dir: string, url: string): string => { - return path.join(dir, path.basename(url)) -} - -// checks `directory` for system update files matching the given `urls`, and -// downloads them if they can't be found -export function getReleaseFiles( - urls: ReleaseSetUrls, - directory: string, - onProgress: (progress: DownloadProgress) => unknown -): Promise { - return readdir(directory) - .catch(error => { - log.warn('Error retrieving files from filesystem', { error }) - return [] - }) - .then((files: string[]) => { - log.debug('Files in system update download directory', { files }) - const system = outPath(directory, urls.system) - const releaseNotes = outPath(directory, urls.releaseNotes ?? '') - - // TODO: check for release notes when OT-3 manifest points to real release notes - if (files.some(f => f === path.basename(system))) { - return { system, releaseNotes } - } - - return downloadReleaseFiles(urls, directory, onProgress) - }) -} - -// downloads the entire release set to a temporary directory, and once they're -// all successfully downloaded, renames the directory to `directory` -// TODO(mc, 2019-07-09): DRY this up if/when more than 2 files are required -export function downloadReleaseFiles( - urls: ReleaseSetUrls, - directory: string, - // `onProgress` will be called with download progress as the files are read - onProgress: (progress: DownloadProgress) => unknown -): Promise { - const tempDir: string = tempy.directory() - const tempSystemPath = outPath(tempDir, urls.system) - const tempNotesPath = outPath(tempDir, urls.releaseNotes ?? '') - - log.debug('directory created for robot update downloads', { tempDir }) - - // downloads are streamed directly to the filesystem to avoid loading them - // all into memory simultaneously - const systemReq = fetchToFile(urls.system, tempSystemPath, { onProgress }) - const notesReq = urls.releaseNotes - ? fetchToFile(urls.releaseNotes, tempNotesPath) - : Promise.resolve(null) - - return Promise.all([systemReq, notesReq]).then(results => { - const [systemTemp, releaseNotesTemp] = results - const systemPath = outPath(directory, systemTemp) - const notesPath = releaseNotesTemp - ? outPath(directory, releaseNotesTemp) - : null - - log.debug('renaming directory', { from: tempDir, to: directory }) - - return move(tempDir, directory, { overwrite: true }).then(() => ({ - system: systemPath, - releaseNotes: notesPath, - })) - }) -} - -export function readUserFileInfo(systemFile: string): Promise { - const openZip = new Promise((resolve, reject) => { - const zip = new StreamZip({ file: systemFile, storeEntries: true }) - .once('ready', handleReady) - .once('error', handleError) - - function handleReady(): void { - cleanup() - resolve(zip) - } - - function handleError(error: Error): void { - cleanup() - zip.close() - reject(error) - } - - function cleanup(): void { - zip.removeListener('ready', handleReady) - zip.removeListener('error', handleError) - } - }) - - return openZip.then(zip => { - const entries = zip.entries() - const streamFromZip = promisify(zip.stream.bind(zip)) - - assert(VERSION_FILENAME in entries, `${VERSION_FILENAME} not in archive`) - - const result = streamFromZip(VERSION_FILENAME) - // @ts-expect-error(mc, 2021-02-17): stream may be undefined - .then(getStream) - .then(JSON.parse) - .then(versionInfo => ({ - systemFile, - versionInfo, - })) - - result.finally(() => { - zip.close() - }) - - return result - }) -} - -export function cleanupReleaseFiles( - downloadsDir: string, - currentRelease: string -): Promise { - log.debug('deleting release files not part of release ', currentRelease) - - return readdir(downloadsDir, { withFileTypes: true }) - .then(files => { - return ( - files - // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions - .filter(f => f.isDirectory() && f.name !== currentRelease) - .map(f => path.join(downloadsDir, f.name)) - ) - }) - .then(removals => Promise.all(removals.map(f => remove(f)))) -} diff --git a/app-shell-odd/src/system-update/release-manifest.ts b/app-shell-odd/src/system-update/release-manifest.ts deleted file mode 100644 index d27c8a04449..00000000000 --- a/app-shell-odd/src/system-update/release-manifest.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { readJson, outputJson } from 'fs-extra' -import { fetchJson } from '../http' -import { createLogger } from '../log' -import { getManifestCacheDir } from './directories' -import type { ReleaseManifest, ReleaseSetUrls } from './types' - -const log = createLogger('systemUpdate/release-manifest') - -export function getReleaseSet( - manifest: ReleaseManifest, - version: string -): ReleaseSetUrls | null { - return manifest.production[version] ?? null -} - -export const getCachedReleaseManifest = (): Promise => - readJson(getManifestCacheDir()) - -export const downloadAndCacheReleaseManifest = ( - manifestUrl: string -): Promise => - fetchJson(manifestUrl) - .then(manifest => { - return outputJson(getManifestCacheDir(), manifest).then(() => manifest) - }) - .catch((error: Error) => { - log.error('Error downloading the release manifest', { error }) - return readJson(getManifestCacheDir()) - }) diff --git a/app-shell-odd/src/system-update/types.ts b/app-shell-odd/src/system-update/types.ts index 8555d980791..12c2f5dc674 100644 --- a/app-shell-odd/src/system-update/types.ts +++ b/app-shell-odd/src/system-update/types.ts @@ -16,24 +16,47 @@ export interface ReleaseSetFilepaths { releaseNotes: string | null } -// shape of VERSION.json in update file -export interface VersionInfo { - buildroot_version: string - buildroot_sha: string - buildroot_branch: string - buildroot_buildid: string - build_type: string - opentrons_api_version: string - opentrons_api_sha: string - opentrons_api_branch: string - update_server_version: string - update_server_sha: string - update_server_branch: string +export interface NoUpdate { + version: null + files: null + releaseNotes: null + downloadProgress: 0 } -export interface UserFileInfo { - // filepath of update file - systemFile: string - // parsed contents of VERSION.json - versionInfo: VersionInfo +export interface FoundUpdate { + version: string + files: null + releaseNotes: null + downloadProgress: number +} + +export interface ReadyUpdate { + version: string + files: ReleaseSetFilepaths + releaseNotes: string | null + downloadProgress: 100 +} + +export type ResolvedUpdate = NoUpdate | ReadyUpdate +export type UnresolvedUpdate = ResolvedUpdate | FoundUpdate +export type ProgressCallback = (status: UnresolvedUpdate) => void + +// Interface provided by the web and usb sourced updaters. Type variable is +// specified by the updater implementation. +export interface UpdateProvider { + // Call before disposing to make sure any temporary storage is removed + teardown: () => Promise + // Scan an implementation-defined location for updates + refreshUpdateCache: (progress: ProgressCallback) => Promise + // Get the details of a found update, if any. + getUpdateDetails: () => UnresolvedUpdate + // Lock the update cache, which will prevent anything from accidentally overwriting stuff + // while it's being sent as an update + lockUpdateCache: () => void + // Reverse lockUpdateCache() + unlockUpdateCache: () => void + // get an identifier for logging + name: () => string + // get the current source + source: () => UpdateSourceDetails } diff --git a/app-shell-odd/src/system-update/update.ts b/app-shell-odd/src/system-update/update.ts deleted file mode 100644 index d1adb6e9c3d..00000000000 --- a/app-shell-odd/src/system-update/update.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { postFile } from '../http' -import type { - RobotModel, - ViewableRobot, -} from '@opentrons/app/src/redux/discovery/types' - -const OT2_FILENAME = 'ot2-system.zip' -const SYSTEM_FILENAME = 'system-update.zip' - -const getSystemFileName = (robotModel: RobotModel): string => { - if (robotModel === 'OT-2 Standard' || robotModel === null) { - return OT2_FILENAME - } - return SYSTEM_FILENAME -} - -export function uploadSystemFile( - robot: ViewableRobot, - urlPath: string, - file: string -): Promise { - const url = `http://${robot.ip}:${robot.port}${urlPath}` - - return postFile(url, getSystemFileName(robot.robotModel), file) -} diff --git a/app-shell-odd/src/system-update/utils.ts b/app-shell-odd/src/system-update/utils.ts new file mode 100644 index 00000000000..e0a334ba5d4 --- /dev/null +++ b/app-shell-odd/src/system-update/utils.ts @@ -0,0 +1,18 @@ +import { rm } from 'fs/promises' +import tempy from 'tempy' + +export const directoryWithCleanup = ( + task: (directory: string) => Promise +): Promise => { + const directory = tempy.directory() + return new Promise((resolve, reject) => + task(directory as string) + .then(result => { + resolve(result) + }) + .catch(err => { + reject(err) + }) + .finally(() => rm(directory as string, { recursive: true, force: true })) + ) +} diff --git a/app-shell-odd/src/system.ts b/app-shell-odd/src/system.ts new file mode 100644 index 00000000000..36c427a7e94 --- /dev/null +++ b/app-shell-odd/src/system.ts @@ -0,0 +1,22 @@ +import { UPDATE_BRIGHTNESS } from './constants' +import { createLogger } from './log' +import systemd from './systemd' + +import type { Action } from './types' + +const log = createLogger('system') + +export function registerUpdateBrightness(): (action: Action) => void { + return function handleAction(action: Action) { + switch (action.type) { + case UPDATE_BRIGHTNESS: + console.log('update the brightness') + systemd + .updateBrightness(action.payload.message) + .catch(err => + log.debug('Something wrong when updating the brightness', err) + ) + break + } + } +} diff --git a/app-shell-odd/src/types.ts b/app-shell-odd/src/types.ts index 2899171a08b..5d8f8a9502a 100644 --- a/app-shell-odd/src/types.ts +++ b/app-shell-odd/src/types.ts @@ -112,11 +112,13 @@ export type CLEAR_CACHE_TYPE = 'discovery:CLEAR_CACHE' export interface ConfigInitializedAction { type: CONFIG_INITIALIZED_TYPE payload: { config: Config } + meta: { shell: true } } export interface ConfigValueUpdatedAction { type: CONFIG_VALUE_UPDATED_TYPE payload: { path: string; value: any } + meta: { shell: true } } export interface StartDiscoveryAction { diff --git a/app-shell-odd/src/update.ts b/app-shell-odd/src/update.ts deleted file mode 100644 index d1ea2f154b3..00000000000 --- a/app-shell-odd/src/update.ts +++ /dev/null @@ -1,113 +0,0 @@ -import semver from 'semver' -import { UI_INITIALIZED, UPDATE_BRIGHTNESS } from './constants' -import { createLogger } from './log' -import { getConfig } from './config' -import { - downloadAndCacheReleaseManifest, - getCachedReleaseManifest, - getReleaseSet, -} from './system-update/release-manifest' -import systemd from './systemd' - -import type { Action, Dispatch } from './types' -import type { ReleaseSetUrls } from './system-update/types' - -const log = createLogger('update') - -const OPENTRONS_PROJECT: string = _OPENTRONS_PROJECT_ - -export const FLEX_MANIFEST_URL = - OPENTRONS_PROJECT && OPENTRONS_PROJECT.includes('robot-stack') - ? 'https://builds.opentrons.com/ot3-oe/releases.json' - : 'https://ot3-development.builds.opentrons.com/ot3-oe/releases.json' - -const PKG_VERSION = _PKG_VERSION_ -let LATEST_OT_SYSTEM_VERSION = PKG_VERSION - -const channelFinder = (version: string, channel: string): boolean => { - // return the latest alpha/beta if a user subscribes to alpha/beta updates - if (['alpha', 'beta'].includes(channel)) { - return version.includes(channel) - } else { - // otherwise get the latest stable version - return !version.includes('alpha') && !version.includes('beta') - } -} - -export const getLatestSystemUpdateUrls = (): Promise => { - return getCachedReleaseManifest() - .then(manifest => getReleaseSet(manifest, getLatestVersion())) - .catch((error: Error) => { - log.warn('Error retrieving release manifest', { - version: getLatestVersion(), - error, - }) - return Promise.reject(error) - }) -} - -export const updateLatestVersion = (): Promise => { - const channel = getConfig('update').channel - - return downloadAndCacheReleaseManifest(FLEX_MANIFEST_URL) - .then(response => { - const latestAvailableVersion = Object.keys(response.production) - .sort((a, b) => { - if (semver.lt(a, b)) { - return 1 - } - return -1 - }) - .find(verson => channelFinder(verson, channel)) - const changed = LATEST_OT_SYSTEM_VERSION !== latestAvailableVersion - LATEST_OT_SYSTEM_VERSION = latestAvailableVersion ?? PKG_VERSION - if (changed) { - log.info( - `Update: latest version available from ${FLEX_MANIFEST_URL} is ${latestAvailableVersion}` - ) - } - return LATEST_OT_SYSTEM_VERSION - }) - .catch((e: Error) => { - log.warn( - `Update: error fetching latest system version from ${FLEX_MANIFEST_URL}: ${e.message}, keeping latest version at ${LATEST_OT_SYSTEM_VERSION}` - ) - return LATEST_OT_SYSTEM_VERSION - }) -} - -export const getLatestVersion = (): string => { - return LATEST_OT_SYSTEM_VERSION -} - -export const getCurrentVersion = (): string => PKG_VERSION - -export const isUpdateAvailable = (): boolean => - getLatestVersion() !== getCurrentVersion() - -export function registerUpdate( - dispatch: Dispatch -): (action: Action) => unknown { - return function handleAction(action: Action) { - switch (action.type) { - case UI_INITIALIZED: - case 'shell:CHECK_UPDATE': - return updateLatestVersion() - } - } -} - -export function registerUpdateBrightness(): (action: Action) => unknown { - return function handleAction(action: Action) { - switch (action.type) { - case UPDATE_BRIGHTNESS: - console.log('update the brightness') - systemd - .updateBrightness(action.payload.message) - .catch(err => - log.debug('Something wrong when updating the brightness', err) - ) - break - } - } -} diff --git a/app-shell-odd/src/usb.ts b/app-shell-odd/src/usb.ts index 44252c6a339..1c5e6bd14a7 100644 --- a/app-shell-odd/src/usb.ts +++ b/app-shell-odd/src/usb.ts @@ -2,6 +2,7 @@ import * as fs from 'fs' import * as fsPromises from 'fs/promises' import { join } from 'path' import { flatten } from 'lodash' +import { createLogger } from './log' import { robotMassStorageDeviceAdded, robotMassStorageDeviceEnumerated, @@ -16,7 +17,12 @@ import type { Dispatch, Action } from './types' const FLEX_USB_MOUNT_DIR = '/media/' const FLEX_USB_DEVICE_DIR = '/dev/' -const FLEX_USB_MOUNT_FILTER = /sd[a-z]+[0-9]+$/ +// filter matches sda0, sdc9, sdb +const FLEX_USB_DEVICE_FILTER = /sd[a-z]+[0-9]*$/ +// filter matches sda0, sdc9, sdb, VOLUME-sdc10 +const FLEX_USB_MOUNT_FILTER = /([^/]+-)?(sd[a-z]+[0-9]*)$/ + +const log = createLogger('mass-storage') // These are for backoff algorithm // apply the delay from 1 sec 64 sec @@ -48,11 +54,15 @@ const isWeirdDirectoryAndShouldSkip = (dirName: string): boolean => .map(keyword => dirName.includes(keyword)) .reduce((prev, current) => prev || current, false) -const enumerateMassStorage = (path: string): Promise => { +const doEnumerateMassStorage = ( + path: string, + depth: number +): Promise => { + log.info(`Enumerating mass storage path ${path}`) return callWithRetry(() => fsPromises.readdir(path).then(entries => { - if (entries.length === 0) { - throw new Error('No entries found, retrying...') + if (entries.length === 0 && depth === 0) { + throw new Error('No entries found for top level, retrying...') } return entries }) @@ -62,29 +72,44 @@ const enumerateMassStorage = (path: string): Promise => { Promise.all( entries.map(entry => entry.isDirectory() && !isWeirdDirectoryAndShouldSkip(entry.name) - ? enumerateMassStorage(join(path, entry.name)) + ? doEnumerateMassStorage(join(path, entry.name), depth + 1) : new Promise(resolve => { resolve([join(path, entry.name)]) }) ) ) ) - .catch(error => { - console.error(`Error enumerating mass storage: ${error}`) + .catch((error: Error) => { + log.error( + `Error enumerating mass storage path ${path}: ${error.name}: ${error.message}` + ) return [] }) .then(flatten) - .then(result => { - return result - }) + .then(result => result) +} + +const enumerateMassStorage = (path: string): Promise => { + log.info(`Beginning scan of mass storage device at ${path}`) + return doEnumerateMassStorage(path, 0).then(results => { + log.info(`Found ${results.length} files in ${path}`) + return results + }) } + export function watchForMassStorage(dispatch: Dispatch): () => void { - console.log('watching for mass storage') + log.info('watching for mass storage') let prevDirs: string[] = [] const handleNewlyPresent = (path: string): Promise => { dispatch(robotMassStorageDeviceAdded(path)) return enumerateMassStorage(path) .then(contents => { + log.debug( + `mass storage device at ${path} enumerated: ${JSON.stringify( + contents + )}` + ) + log.info(`Enumerated ${path} with ${contents.length} results`) dispatch(robotMassStorageDeviceEnumerated(path, contents)) }) .then(() => path) @@ -101,6 +126,9 @@ export function watchForMassStorage(dispatch: Dispatch): () => void { const newlyAbsent = prevDirs.filter( entry => !sortedEntries.includes(entry) ) + log.info( + `rescan: newly present: ${newlyPresent} newly absent: ${newlyAbsent}` + ) return Promise.all([ ...newlyAbsent.map(entry => { if (entry.match(FLEX_USB_MOUNT_FILTER)) { @@ -119,6 +147,7 @@ export function watchForMassStorage(dispatch: Dispatch): () => void { ]) }) .then(present => { + log.info(`now present: ${present}`) prevDirs = present.filter((entry): entry is string => entry !== null) }) @@ -133,6 +162,9 @@ export function watchForMassStorage(dispatch: Dispatch): () => void { return } if (!fileName.match(FLEX_USB_MOUNT_FILTER)) { + log.debug( + `mediaWatcher: filename ${fileName} does not match ${FLEX_USB_MOUNT_FILTER}` + ) return } const fullPath = join(FLEX_USB_MOUNT_DIR, fileName) @@ -140,25 +172,36 @@ export function watchForMassStorage(dispatch: Dispatch): () => void { .stat(fullPath) .then(info => { if (!info.isDirectory) { + log.debug(`mediaWatcher: ${fullPath} is not a directory`) return } if (prevDirs.includes(fullPath)) { + log.debug(`mediaWatcher: ${fullPath} is known`) return } - console.log(`New mass storage device ${fileName} detected`) + log.info(`New mass storage device ${fileName} detected`) prevDirs.push(fullPath) return handleNewlyPresent(fullPath) }) - .catch(() => { + .catch(err => { if (prevDirs.includes(fullPath)) { - console.log(`Mass storage device at ${fileName} removed`) + log.info( + `Mass storage device at ${fileName} removed because its mount point disappeared`, + err + ) prevDirs = prevDirs.filter(entry => entry !== fullPath) dispatch(robotMassStorageDeviceRemoved(fullPath)) + } else { + log.debug( + `Mass storage device candidate mountpoint at ${fileName} disappeared`, + err + ) } }) } ) } catch { + log.error(`Failed to start watcher for ${FLEX_USB_MOUNT_DIR}`) return null } } @@ -170,21 +213,42 @@ export function watchForMassStorage(dispatch: Dispatch): () => void { { persistent: true }, (event, fileName) => { if (!!!fileName) return - if (!fileName.match(FLEX_USB_MOUNT_FILTER)) return - const fullPath = join(FLEX_USB_DEVICE_DIR, fileName) - const mountPath = join(FLEX_USB_MOUNT_DIR, fileName) - fsPromises.stat(fullPath).catch(() => { - if (prevDirs.includes(mountPath)) { - console.log(`Mass storage device at ${fileName} removed`) - prevDirs = prevDirs.filter(entry => entry !== mountPath) - dispatch( - robotMassStorageDeviceRemoved(join(FLEX_USB_MOUNT_DIR, fileName)) + if (!fileName.match(FLEX_USB_DEVICE_FILTER)) return + if (event !== 'rename') { + log.debug( + `devWatcher: ignoring ${event} event for ${fileName} (not rename)` + ) + return + } + log.debug(`devWatcher: ${event} event for ${fileName}`) + fsPromises + .readdir(FLEX_USB_DEVICE_DIR) + .then(contents => { + if (contents.includes(fileName)) { + log.debug( + `devWatcher: ${fileName} found in /dev, this is an attach` + ) + // this is an attach + return + } + const prevDir = prevDirs.filter(dir => dir.includes(fileName)).at(0) + log.debug( + `devWatcher: ${fileName} not in /dev, this is a remove, previously mounted at ${prevDir}` ) - // we don't care if this fails because it's racing the system removing - // the mount dir in the common case - fsPromises.unlink(mountPath).catch(() => {}) - } - }) + if (prevDir != null) { + log.info(`Mass storage device at ${fileName} removed`) + prevDirs = prevDirs.filter(entry => entry !== prevDir) + dispatch(robotMassStorageDeviceRemoved(prevDir)) + // we don't care if this fails because it's racing the system removing + // the mount dir in the common case + fsPromises.unlink(prevDir).catch(() => {}) + } + }) + .catch(err => { + log.info( + `Failed to handle mass storage device ${fileName}: ${err.name}: ${err.message}` + ) + }) } ) diff --git a/app-shell/build/release-notes.md b/app-shell/build/release-notes.md index 27ff23e0909..a093e29e6ed 100644 --- a/app-shell/build/release-notes.md +++ b/app-shell/build/release-notes.md @@ -8,6 +8,30 @@ By installing and using Opentrons software, you agree to the Opentrons End-User --- +## Opentrons App Changes in 8.2.0 + +Welcome to the v8.2.0 release of the Opentrons App! This release adds support for the Opentrons Absorbance Plate Reader Module, as well as other features. + +### New Features + +- Run protocols that use the Absorbance Plate Reader and check the status of the module on the robot details screen for your Flex. +- Run protocols that use the new Opentrons Tough PCR Auto-Sealing Lid with the Thermocycler Module GEN2. Stacks of these lids appear in a consolidated view when setting up labware. + +### Improved Features + +- Error recovery now works in more situations and has more options. + - Recover from gripper errors. + - Recover from failure to drop tips. + - Indicate that an error was improperly detected and skip similar errors later in the run. + - Choose from more options of where to drop tips as part of recovery. + - Disable error recovery entirely, if your application requires it. Runs will fail on any error. + +### Bug Fixes + +- Fixed an app crash when performing certain error recovery steps with Python API version 2.15 protocols. + +--- + ## Opentrons App Changes in 8.1.0 Welcome to the v8.1.0 release of the Opentrons App! diff --git a/app-shell/src/config/actions.ts b/app-shell/src/config/actions.ts index eabc9b47a16..5d96e6c1171 100644 --- a/app-shell/src/config/actions.ts +++ b/app-shell/src/config/actions.ts @@ -111,6 +111,7 @@ import type { export const configInitialized = (config: Config): ConfigInitializedAction => ({ type: CONFIG_INITIALIZED, payload: { config }, + meta: { shell: true }, }) // config value has been updated @@ -120,6 +121,7 @@ export const configValueUpdated = ( ): ConfigValueUpdatedAction => ({ type: VALUE_UPDATED, payload: { path, value }, + meta: { shell: true }, }) export const customLabwareList = ( diff --git a/app-shell/src/main.ts b/app-shell/src/main.ts index ef422a455cc..0f4ab41733b 100644 --- a/app-shell/src/main.ts +++ b/app-shell/src/main.ts @@ -18,7 +18,6 @@ import { registerProtocolStorage } from './protocol-storage' import { getConfig, getStore, getOverrides, registerConfig } from './config' import { registerUsb } from './usb' import { registerNotify, closeAllNotifyConnections } from './notifications' - import type { BrowserWindow } from 'electron' import type { Action, Dispatch, Logger } from './types' import type { LogEntry } from 'winston' diff --git a/app-shell/src/types.ts b/app-shell/src/types.ts index 8a1bea51a20..f608b4512af 100644 --- a/app-shell/src/types.ts +++ b/app-shell/src/types.ts @@ -96,9 +96,11 @@ export type CLEAR_CACHE_TYPE = 'discovery:CLEAR_CACHE' export interface ConfigInitializedAction { type: CONFIG_INITIALIZED_TYPE payload: { config: Config } + meta: { shell: true } } export interface ConfigValueUpdatedAction { type: CONFIG_VALUE_UPDATED_TYPE payload: { path: string; value: any } + meta: { shell: true } } diff --git a/app/package.json b/app/package.json index 034e5dd7cec..e68327af200 100644 --- a/app/package.json +++ b/app/package.json @@ -68,6 +68,7 @@ "uuid": "3.2.1" }, "devDependencies": { + "@tanstack/react-query-devtools": "5.59.16", "@types/classnames": "2.2.5", "@types/file-saver": "2.0.1", "@types/jszip": "3.1.7", diff --git a/app/src/App/DesktopApp.tsx b/app/src/App/DesktopApp.tsx index 6b09da2e2d2..df2105007b3 100644 --- a/app/src/App/DesktopApp.tsx +++ b/app/src/App/DesktopApp.tsx @@ -38,6 +38,7 @@ import { useRobot, useIsFlex } from '/app/redux-resources/robots' import { ProtocolTimeline } from '/app/pages/Desktop/Protocols/ProtocolDetails/ProtocolTimeline' import { PortalRoot as ModalPortalRoot } from './portal' import { DesktopAppFallback } from './DesktopAppFallback' +import { ReactQueryDevtools } from './tools' import type { RouteProps } from './types' @@ -108,6 +109,7 @@ export const DesktopApp = (): JSX.Element => { + diff --git a/app/src/App/OnDeviceDisplayApp.tsx b/app/src/App/OnDeviceDisplayApp.tsx index 42335754432..6e8c78449f0 100644 --- a/app/src/App/OnDeviceDisplayApp.tsx +++ b/app/src/App/OnDeviceDisplayApp.tsx @@ -22,6 +22,7 @@ import { MaintenanceRunTakeover } from '/app/organisms/TakeoverModal' import { FirmwareUpdateTakeover } from '/app/organisms/FirmwareUpdateModal/FirmwareUpdateTakeover' import { IncompatibleModuleTakeover } from '/app/organisms/IncompatibleModule' import { EstopTakeover } from '/app/organisms/EmergencyStop' +import { ChooseLanguage } from '/app/pages/ODD/ChooseLanguage' import { ConnectViaEthernet } from '/app/pages/ODD/ConnectViaEthernet' import { ConnectViaUSB } from '/app/pages/ODD/ConnectViaUSB' import { ConnectViaWifi } from '/app/pages/ODD/ConnectViaWifi' @@ -54,6 +55,7 @@ import { updateBrightness } from '/app/redux/shell' import { SLEEP_NEVER_MS } from '/app/local-resources/config' import { useProtocolReceiptToast, useSoftwareUpdatePoll } from './hooks' import { ODDTopLevelRedirects } from './ODDTopLevelRedirects' +import { ReactQueryDevtools } from '/app/App/tools' import { OnDeviceDisplayAppFallback } from './OnDeviceDisplayAppFallback' @@ -66,6 +68,7 @@ import type { Dispatch } from '/app/redux/types' hackWindowNavigatorOnLine() export const ON_DEVICE_DISPLAY_PATHS = [ + '/choose-language', '/dashboard', '/deck-configuration', '/emergency-stop', @@ -94,6 +97,8 @@ function getPathComponent( path: typeof ON_DEVICE_DISPLAY_PATHS[number] ): JSX.Element { switch (path) { + case '/choose-language': + return case '/dashboard': return case '/deck-configuration': @@ -179,6 +184,7 @@ export const OnDeviceDisplayApp = (): JSX.Element => { // TODO (sb:6/12/23) Create a notification manager to set up preference and order of takeover modals return ( + @@ -187,9 +193,9 @@ export const OnDeviceDisplayApp = (): JSX.Element => { ) : ( <> - + diff --git a/app/src/App/__tests__/OnDeviceDisplayApp.test.tsx b/app/src/App/__tests__/OnDeviceDisplayApp.test.tsx index 662b2523436..2d2d09ca15e 100644 --- a/app/src/App/__tests__/OnDeviceDisplayApp.test.tsx +++ b/app/src/App/__tests__/OnDeviceDisplayApp.test.tsx @@ -5,6 +5,7 @@ import { MemoryRouter } from 'react-router-dom' import { renderWithProviders } from '/app/__testing-utils__' import { i18n } from '/app/i18n' import { LocalizationProvider } from '../../LocalizationProvider' +import { ChooseLanguage } from '/app/pages/ODD/ChooseLanguage' import { ConnectViaEthernet } from '/app/pages/ODD/ConnectViaEthernet' import { ConnectViaUSB } from '/app/pages/ODD/ConnectViaUSB' import { ConnectViaWifi } from '/app/pages/ODD/ConnectViaWifi' @@ -48,6 +49,7 @@ vi.mock('@opentrons/react-api-client', async () => { vi.mock('../../LocalizationProvider') vi.mock('/app/pages/ODD/Welcome') vi.mock('/app/pages/ODD/NetworkSetupMenu') +vi.mock('/app/pages/ODD/ChooseLanguage') vi.mock('/app/pages/ODD/ConnectViaEthernet') vi.mock('/app/pages/ODD/ConnectViaUSB') vi.mock('/app/pages/ODD/ConnectViaWifi') @@ -109,6 +111,10 @@ describe('OnDeviceDisplayApp', () => { vi.resetAllMocks() }) + it('renders ChooseLanguage component from /choose-language', () => { + render('/choose-language') + expect(vi.mocked(ChooseLanguage)).toHaveBeenCalled() + }) it('renders Welcome component from /welcome', () => { render('/welcome') expect(vi.mocked(Welcome)).toHaveBeenCalled() diff --git a/app/src/App/tools/ReactQueryDevtools.tsx b/app/src/App/tools/ReactQueryDevtools.tsx new file mode 100644 index 00000000000..a57f2df3d4d --- /dev/null +++ b/app/src/App/tools/ReactQueryDevtools.tsx @@ -0,0 +1,22 @@ +import { lazy, Suspense } from 'react' + +import { useFeatureFlag } from '/app/redux/config' + +// Lazily load to enable devtools when env.process.DEV is false (ex, when dev code is pushed to a physical ODD) +const ReactQueryTools = lazy(() => + import('react-query/devtools/development').then(d => ({ + default: d.ReactQueryDevtools, + })) +) + +export function ReactQueryDevtools(): JSX.Element { + const enableRQTools = useFeatureFlag('reactQueryDevtools') + + return ( + + {enableRQTools && ( + + )} + + ) +} diff --git a/app/src/App/tools/__tests__/ReactQueryDevtools.test.tsx b/app/src/App/tools/__tests__/ReactQueryDevtools.test.tsx new file mode 100644 index 00000000000..dcca081d19e --- /dev/null +++ b/app/src/App/tools/__tests__/ReactQueryDevtools.test.tsx @@ -0,0 +1,52 @@ +import { screen } from '@testing-library/react' +import { vi, describe, it, expect, beforeEach } from 'vitest' + +import { renderWithProviders } from '/app/__testing-utils__' +import { i18n } from '/app/i18n' +import { ReactQueryDevtools } from '/app/App/tools' +import { useFeatureFlag } from '/app/redux/config' + +vi.mock('react-query/devtools/development', () => ({ + ReactQueryDevtools: vi + .fn() + .mockReturnValue(

MOCK_REACT_QUERY_DEVTOOLS
), +})) +vi.mock('/app/redux/config') + +const render = () => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('ReactQueryDevtools', () => { + const mockUseFF = vi.fn() + + beforeEach(() => { + vi.mocked(useFeatureFlag).mockReturnValue(true) + }) + + it('uses the correct feature flag', () => { + vi.mocked(useFeatureFlag).mockImplementation(mockUseFF) + + render() + + expect(mockUseFF).toHaveBeenCalledWith('reactQueryDevtools') + }) + + it('renders the devtools if the FF is enabled', async () => { + render() + + await screen.findByText('MOCK_REACT_QUERY_DEVTOOLS') + }) + + it('does not the devtools if the FF is disabled', async () => { + vi.mocked(useFeatureFlag).mockReturnValue(false) + + render() + + expect( + screen.queryByText('MOCK_REACT_QUERY_DEVTOOLS') + ).not.toBeInTheDocument() + }) +}) diff --git a/app/src/App/tools/index.ts b/app/src/App/tools/index.ts new file mode 100644 index 00000000000..99f2b6dc3fd --- /dev/null +++ b/app/src/App/tools/index.ts @@ -0,0 +1 @@ +export * from './ReactQueryDevtools' diff --git a/app/src/assets/images/absorbance_reader_instruction_manual_code.png b/app/src/assets/images/absorbance_reader_instruction_manual_code.png new file mode 100644 index 00000000000..70c45ab3b56 Binary files /dev/null and b/app/src/assets/images/absorbance_reader_instruction_manual_code.png differ diff --git a/app/src/assets/images/labware/opentrons_flex_deck_riser.png b/app/src/assets/images/labware/opentrons_flex_deck_riser.png new file mode 100644 index 00000000000..a06f26bf445 Binary files /dev/null and b/app/src/assets/images/labware/opentrons_flex_deck_riser.png differ diff --git a/app/src/assets/images/labware/opentrons_tough_pcr_auto_sealing_lid.png b/app/src/assets/images/labware/opentrons_tough_pcr_auto_sealing_lid.png new file mode 100644 index 00000000000..bc0cffa3df6 Binary files /dev/null and b/app/src/assets/images/labware/opentrons_tough_pcr_auto_sealing_lid.png differ diff --git a/app/src/assets/localization/en/app_settings.json b/app/src/assets/localization/en/app_settings.json index 5f6354da6c5..a5edc2c727f 100644 --- a/app/src/assets/localization/en/app_settings.json +++ b/app/src/assets/localization/en/app_settings.json @@ -5,6 +5,7 @@ "__dev_internal__forceHttpPolling": "Poll all network requests instead of using MQTT", "__dev_internal__protocolStats": "Protocol Stats", "__dev_internal__protocolTimeline": "Protocol Timeline", + "__dev_internal__reactQueryDevtools": "Enable React Query Devtools", "add_folder_button": "Add labware source folder", "add_ip_button": "Add", "add_ip_error": "Enter an IP Address or Hostname", @@ -15,11 +16,14 @@ "additional_labware_folder_title": "Additional Custom Labware Source Folder", "advanced": "Advanced", "app_changes": "App Changes in ", + "app_language_description": "All app features use this language. Protocols and other user content will not change language.", + "app_language_preferences": "App Language Preferences", "app_settings": "App Settings", "bug_fixes": "Bug Fixes", "cal_block": "Always use calibration block to calibrate", "change_folder_button": "Change labware source folder", "channel": "Channel", + "choose_your_language": "Choose your language", "clear_confirm": "Clear unavailable robots", "clear_robots_button": "Clear unavailable robots list", "clear_robots_description": "Clear the list of unavailable robots on the Devices page. This action cannot be undone.", @@ -47,6 +51,7 @@ "installing_update": "Installing update...", "ip_available": "Available", "ip_description_first": "Enter an IP address or hostname to connect to a robot.", + "language": "Language", "language_preference": "Language preference", "manage_versions": "It is very important for the robot and app software to be on the same version. Manage the robot software versions via Robot Settings > Advanced.", "new_features": "New Features", @@ -73,6 +78,7 @@ "restarting_app": "Download complete, restarting the app...", "restore_previous": "See how to restore a previous software version", "searching": "Searching for 30s", + "select_a_language": "Select a language to personalize your experience.", "select_language": "Select language", "setup_connection": "Set up connection", "share_display_usage": "Share display usage", diff --git a/app/src/assets/localization/en/pipette_wizard_flows.json b/app/src/assets/localization/en/pipette_wizard_flows.json index 53ae23d07e2..78dc2b852a6 100644 --- a/app/src/assets/localization/en/pipette_wizard_flows.json +++ b/app/src/assets/localization/en/pipette_wizard_flows.json @@ -49,7 +49,7 @@ "install_probe": "Take the calibration probe from its storage location. Ensure its collar is unlocked. Push the pipette ejector up and press the probe firmly onto the {{location}} pipette nozzle. Twist the collar to lock the probe. Test that the probe is secure by gently pulling it back and forth.", "loose_detach": "Loosen screws and detach ", "move_gantry_to_front": "Move gantry to front", - "must_detach_mounting_plate": "You must detach the mounting plate and reattach the z-axis carraige before using other pipettes. We do not recommend exiting this process before completion.", + "must_detach_mounting_plate": "You must detach the mounting plate and reattach the z-axis carriage before using other pipettes. We do not recommend exiting this process before completion.", "name_and_volume_detected": "{{name}} Pipette Detected", "next": "next", "ninety_six_channel": "{{ninetySix}} pipette", diff --git a/app/src/assets/localization/en/protocol_command_text.json b/app/src/assets/localization/en/protocol_command_text.json index 5640f3306a5..c2ee88bcd5a 100644 --- a/app/src/assets/localization/en/protocol_command_text.json +++ b/app/src/assets/localization/en/protocol_command_text.json @@ -1,10 +1,11 @@ { - "absorbance_reader_open_lid": "Opening Absorbance Reader lid", "absorbance_reader_close_lid": "Closing Absorbance Reader lid", "absorbance_reader_initialize": "Initializing Absorbance Reader to perform {{mode}} measurement at {{wavelengths}}", + "absorbance_reader_open_lid": "Opening Absorbance Reader lid", "absorbance_reader_read": "Reading plate in Absorbance Reader", "adapter_in_mod_in_slot": "{{adapter}} on {{module}} in {{slot}}", "adapter_in_slot": "{{adapter}} in {{slot}}", + "air_gap_in_place": "Air gapping {{volume}} µL", "aspirate": "Aspirating {{volume}} µL from well {{well_name}} of {{labware}} in {{labware_location}} at {{flow_rate}} µL/sec", "aspirate_in_place": "Aspirating {{volume}} µL in place at {{flow_rate}} µL/sec ", "blowout": "Blowing out at well {{well_name}} of {{labware}} in {{labware_location}} at {{flow_rate}} µL/sec", @@ -27,6 +28,7 @@ "dispense_push_out": "Dispensing {{volume}} µL into well {{well_name}} of {{labware}} in {{labware_location}} at {{flow_rate}} µL/sec and pushing out {{push_out_volume}} µL", "drop_tip": "Dropping tip in {{well_name}} of {{labware}}", "drop_tip_in_place": "Dropping tip in place", + "dropping_tip_in_trash": "Dropping tip in {{trash}}", "engaging_magnetic_module": "Engaging Magnetic Module", "fixed_trash": "Fixed Trash", "home_gantry": "Homing all gantry, pipette, and plunger axes", @@ -73,16 +75,16 @@ "setting_thermocycler_lid_temp": "Setting Thermocycler lid temperature to {{temp}}", "single": "single", "slot": "Slot {{slot_name}}", - "turning_rail_lights_off": "Turning rail lights off", - "turning_rail_lights_on": "Turning rail lights on", "target_temperature": "target temperature", "tc_awaiting_for_duration": "Waiting for Thermocycler profile to complete", "tc_run_profile_steps": "Temperature: {{celsius}}°C, hold time: {{duration}}", - "tc_starting_extended_profile_cycle": "{{repetitions}} repetitions of the following steps:", "tc_starting_extended_profile": "Running thermocycler profile with {{elementCount}} total steps and cycles:", + "tc_starting_extended_profile_cycle": "{{repetitions}} repetitions of the following steps:", "tc_starting_profile": "Running thermocycler profile with {{stepCount}} steps:", "touch_tip": "Touching tip", "trash_bin_in_slot": "Trash Bin in {{slot_name}}", + "turning_rail_lights_off": "Turning rail lights off", + "turning_rail_lights_on": "Turning rail lights on", "unlatching_hs_latch": "Unlatching labware on Heater-Shaker", "wait_for_duration": "Pausing for {{seconds}} seconds. {{message}}", "wait_for_resume": "Pausing protocol", diff --git a/app/src/assets/localization/en/protocol_setup.json b/app/src/assets/localization/en/protocol_setup.json index f2e284e607e..17f60958d55 100644 --- a/app/src/assets/localization/en/protocol_setup.json +++ b/app/src/assets/localization/en/protocol_setup.json @@ -120,6 +120,7 @@ "labware_position_check_step_description": "Recommended workflow that helps you verify the position of each labware on the deck.", "labware_position_check_step_title": "Labware Position Check", "labware_position_check_text": "Labware Position Check is a recommended workflow that helps you verify the position of each labware on the deck. During this check, you can create Labware Offsets that adjust how the robot moves to each labware in the X, Y and Z directions.", + "labware_quantity": "Quantity: {{quantity}}", "labware_setup_step_description": "Gather the following labware and full tip racks. To run your protocol without Labware Position Check, place and secure labware in their initial locations.", "labware_setup_step_title": "Labware", "last_calibrated": "Last calibrated: {{date}}", @@ -159,6 +160,7 @@ "module_connected": "Connected", "module_disconnected": "Disconnected", "module_instructions_link": "{{moduleName}} setup instructions", + "module_instructions_manual": "For step-by-step instructions on setting up your module, consult the Quickstart Guide that came in its box. You can also click the link below or scan the QR code to read the module Instruction Manual.", "module_mismatch_body": "Check that the modules connected to this robot are of the right type and generation", "module_name": "Module", "module_not_connected": "Not connected", diff --git a/app/src/assets/localization/en/run_details.json b/app/src/assets/localization/en/run_details.json index e9f39f81d06..28df0734619 100644 --- a/app/src/assets/localization/en/run_details.json +++ b/app/src/assets/localization/en/run_details.json @@ -31,9 +31,11 @@ "custom_values": "Custom values", "data_out_of_date": "This data is likely out of date", "date": "Date", + "device_details": "Device details", "door_is_open": "Robot door is open", "door_open_pause": "Current Step - Paused - Door Open", "download": "Download", + "download_files": "Download files", "download_run_log": "Download run log", "downloading_run_log": "Downloading run log", "drop_tip": "Dropping tip in {{well_name}} of {{labware}} in {{labware_location}}", @@ -45,6 +47,7 @@ "error_info": "Error {{errorCode}}: {{errorType}}", "error_type": "Error: {{errorType}}", "failed_step": "Failed step", + "files_available_robot_details": "All files associated with the protocol run are available on the robot detail screen.", "final_step": "Final Step", "ignore_stored_data": "Ignore stored data", "labware": "labware", diff --git a/app/src/assets/localization/zh/anonymous.json b/app/src/assets/localization/zh/anonymous.json index 696dff65751..045245c84f7 100644 --- a/app/src/assets/localization/zh/anonymous.json +++ b/app/src/assets/localization/zh/anonymous.json @@ -2,6 +2,8 @@ "a_robot_software_update_is_available": "需要更新工作站软件版本才能使用该版本的桌面应用程序运行协议。转到工作站这个金属块是一个特制的工具,完美适配您的甲板,有助于校准。如果您没有校准块,请发送电子邮件给支持团队,以便我们寄送一个给您。在您提供的信息中,请确保包括您的姓名、公司或机构名称和寄送地址。在等待校准块到达过程中,您可以暂时利用工作站里垃圾桶上的平面进行校准。", "calibration_on_opentrons_tips_is_important": "使用上述吸头和吸头盒进行校准非常重要,因为工作站的准确性是基于这些吸头的已知尺寸来确定的。", "choose_what_data_to_share": "选择要共享的工作站数据。", @@ -15,6 +17,7 @@ "contact_support_for_connection_help": "如果以上方法都无法解决问题,请联系支持人员寻求帮助(通过此应用程序中的问号链接,或发送电子邮件至{{support_email}}。)", "deck_fixture_setup_modal_bottom_description": "有关安装不同类型固定装置的详细信息,请与支持人员联系。", "delete_protocol_from_app": "删除协议,针对错误进行修改,然后从桌面应用程序将协议重新发送到此工作站。", + "delete_transfer_from_app": "删除快速移液,修改并解决错误,在工作站显示屏幕上重新创建此移液。", "error_boundary_description": "您需要重新启动触摸屏。联系支持人员以获取帮助。", "estop_pressed_description": "首先,安全清理甲板上的任何实验耗材和洒出的液体。然后,顺时针旋转急停开关。最后,让工作站将龙门架移动到其原位。", "find_your_robot": "在应用程序的“设备”栏找到您的工作站,以安装软件更新。", @@ -34,6 +37,7 @@ "module_calibration_get_started": "开始前,请从工作台上移除实验耗材并清理工作区,以便于校准。还需准备好右侧显示的所需设备。校准适配器随模块一起提供。移液器探头随移液器一起提供。", "module_error_contact_support": "尝试关闭模块电源,然后再打开。如果报错仍然存在,请与支持人员联系。", "network_setup_menu_description": "您将使用此连接来运行软件更新,并将协议加载到您的工作站上。", + "new_robot_instructions": "设置新工作站时,请遵循触摸屏上的指示。有关更多信息,请参阅您的工作站快速入门指南。", "oem_mode_description": "启用OEM模式,以从Flex触摸屏中移除Opentrons的相关信息。", "opentrons_app_successfully_updated": "应用程序已成功更新。", "opentrons_app_update": "应用程序更新", @@ -42,6 +46,7 @@ "opentrons_app_will_use_interpreter": "在指定路径后,应用程序将使用此路径的Python解释器,而不是默认绑定的Python解释器。", "opentrons_cares_about_privacy": "我们注重您的隐私。我们匿名化所有数据,仅用于改进我们的产品。", "opentrons_def": "已验证的数据", + "opentrons_flex_quickstart_guide": "快速入门指南", "opentrons_labware_def": "已验证的实验耗材数据", "opentrons_tip_racks_recommended": "建议使用Opentrons吸头盒。其他吸头盒无法保证精度。", "opentrons_tip_rack_name": "opentrons", @@ -62,6 +67,7 @@ "share_logs_with_opentrons_description": "通过自动发送匿名的工作站日志来帮助改进此产品。这些日志用于解决工作站问题和发现错误趋势。", "show_labware_offset_snippets_description": "仅适用于需要在应用程序之外应用耗材校准数据的用户。启用后,在设置协议过程中可访问Jupyter Notebook和SSH的代码片段。", "something_seems_wrong": "您的移液器可能有问题。退出设置并联系支持人员以获取帮助。", + "storage_limit_reached_description": "您的工作站已达到可存储的快速移液数量上限。在创建新的快速移液之前,您必须删除一个现有的快速移液。", "these_are_advanced_settings": "这些是高级设置。请勿在没有支持团队帮助的情况下尝试调整这些设置。更改这些设置可能会影响您的移液器寿命。这些设置不会覆盖协议中定义的任何移液器设置。", "update_requires_restarting_app": "更新需要重新启动应用程序。", "update_robot_software_description": "绕过自动更新过程并手动更新工作站软件", diff --git a/app/src/assets/localization/zh/app_settings.json b/app/src/assets/localization/zh/app_settings.json index 9e4a9f1bbc7..3405d5edbfd 100644 --- a/app/src/assets/localization/zh/app_settings.json +++ b/app/src/assets/localization/zh/app_settings.json @@ -1,10 +1,10 @@ { + "__dev_internal__enableLabwareCreator": "启用应用实验耗材创建器", + "__dev_internal__enableLocalization": "Enable App Localization", "__dev_internal__forceHttpPolling": "强制轮询所有网络请求,而不是使用MQTT", - "__dev_internal__protocolStats": "协议统计", "__dev_internal__enableRunNotes": "在协议运行期间显示备注", - "__dev_internal__enableQuickTransfer": "启用快速移液", - "__dev_internal__enableLabwareCreator": "Enable App Labware Creator", - "__dev_internal__enableLocalization": "Enable App Localization", + "__dev_internal__protocolStats": "协议统计", + "__dev_internal__protocolTimeline": "协议时间线", "add_folder_button": "添加实验耗材源文件夹", "add_ip_button": "添加", "add_ip_error": "输入IP地址或主机名", diff --git a/app/src/assets/localization/zh/branded.json b/app/src/assets/localization/zh/branded.json index 4bff5f976d4..c38888398f1 100644 --- a/app/src/assets/localization/zh/branded.json +++ b/app/src/assets/localization/zh/branded.json @@ -2,6 +2,8 @@ "a_robot_software_update_is_available": "需要更新工作站软件才能使用此版本的Opentrons应用程序运行协议。转到工作站", "about_flex_gripper": "关于Flex转板抓手", "alternative_security_types_description": "Opentrons应用程序支持将Flex连接到各种企业接入点。通过USB连接并在应用程序中完成设置。", + "attach_a_pipette_for_quick_transfer": "要创建快速移液,您需要将移液器安装到您的Opentrons Flex上。", + "attach_a_pipette": "将移液器连接到Flex", "calibration_block_description": "这个金属块是一个特制的工具,完美适配您的甲板,有助于校准。如果您没有校准块,请发送电子邮件至support@opentrons.com,以便我们寄送一个给您。在您提供的信息中,请确保包括您的姓名、公司或机构名称和寄送地址。在等待校准块到达过程中,您可以暂时利用工作站里垃圾桶上的平面进行校准。", "calibration_on_opentrons_tips_is_important": "使用上述Opentrons吸头和吸头盒进行校准非常重要,因为工作站的准确性是基于这些吸头的已知尺寸来确定的。", "choose_what_data_to_share": "选择要与Opentrons共享的数据。", @@ -15,6 +17,7 @@ "contact_support_for_connection_help": "如果以上方法都无法解决问题,请联系Opentrons支持人员寻求帮助(通过此应用程序中的问号链接,或发送电子邮件至{{support_email}}。)", "deck_fixture_setup_modal_bottom_description": "有关安装不同类型固定装置的详细信息,请扫描二维码或在support.opentrons.com上搜索“deck configuration”", "delete_protocol_from_app": "删除协议,针对错误进行修改,然后从Opentrons应用程序将协议重新发送到此工作站。", + "delete_transfer_from_app": "删除快速移液,修改并解决错误,在Flex显示屏幕上重新创建此移液。", "error_boundary_description": "您需要重新启动触摸屏。然后从Opentrons应用程序下载工作站日志并将其发送到support@opentrons.com寻求帮助。", "estop_pressed_description": "首先,安全清理甲板上的任何实验耗材或洒出液体。然后,顺时针旋转急停开关。最后,让Flex将龙门架移动到其原位。", "find_your_robot": "在Opentrons应用程序中找到您的工作站以安装软件更新。", @@ -34,6 +37,7 @@ "module_calibration_get_started": "开始前,请从工作台上移除实验耗材并清理工作区,以便于校准。还需准备好右侧显示的所需设备。校准适配器随模块一起提供。移液器探头随Flex移液器一起提供。", "module_error_contact_support": "尝试关闭模块电源,然后再打开。如果报错仍然存在,请与Opentrons支持人员联系。", "network_setup_menu_description": "您将使用此连接来运行软件更新,并将协议加载到您的Opentrons Flex上。", + "new_robot_instructions": "设置新的Flex时,请遵循触摸屏上的指示。有关更多信息,请参阅您的工作站快速入门指南。", "oem_mode_description": "启用OEM模式,以从Flex触摸屏中移除Opentrons的所有信息。", "opentrons_app_successfully_updated": "Opentrons应用程序已成功更新。.", "opentrons_app_update": "Opentrons应用程序更新", @@ -42,6 +46,7 @@ "opentrons_app_will_use_interpreter": "如果指定,Opentrons应用程序将在此路径使用Python解释器,而不是默认绑定的Python解释器。", "opentrons_cares_about_privacy": "Opentrons关心您的隐私。我们匿名化所有数据,仅用于改进我们的产品。", "opentrons_def": "已验证的Opentrons数据", + "opentrons_flex_quickstart_guide": "Opentrons Flex 快速入门指南", "opentrons_labware_def": "已验证的Opentrons实验耗材数据", "opentrons_tip_rack_name": "opentrons", "opentrons_tip_racks_recommended": "建议使用Opentrons吸头盒。其他吸头盒无法保证精度。", @@ -62,6 +67,7 @@ "share_logs_with_opentrons_description": "通过自动发送匿名的工作站日志,帮助Opentrons改进产品和服务。Opentrons使用这些日志来解决工作站问题并发现错误趋势。", "show_labware_offset_snippets_description": "仅适用于需要在Opentrons应用程序之外应用耗材校准数据的用户。启用后,在设置协议过程中可访问Jupyter Notebook和SSH的代码片段。", "something_seems_wrong": "您的移液器可能有问题。退出设置并联系Opentrons支持人员以获取帮助。", + "storage_limit_reached_description": "您的 Opentrons Flex 已达到可存储的快速移液数量上限。在创建新的快速移液之前,您必须删除一个现有的快速移液。", "these_are_advanced_settings": "这些是高级设置。请勿在没有Opentrons支持团队帮助的情况下尝试调整这些设置。更改这些设置可能会影响您的移液器寿命。这些设置不会覆盖协议中定义的任何移液器设置。", "update_requires_restarting_app": "更新需要重新启动Opentrons应用程序。", "update_robot_software_description": "绕过Opentrons应用程序自动更新过程并手动更新工作站软件", diff --git a/app/src/assets/localization/zh/device_details.json b/app/src/assets/localization/zh/device_details.json index 39115321f99..a19e61a365b 100644 --- a/app/src/assets/localization/zh/device_details.json +++ b/app/src/assets/localization/zh/device_details.json @@ -3,6 +3,7 @@ "about_module": "关于{{name}}", "about_pipette_name": "关于{{name}}移液器", "about_pipette": "关于移液器", + "abs_reader_status": "吸光度读板器状态", "add_fixture_description": "将此硬件添加至甲板配置。它在协议分析期间将会被引用。", "add_to_slot": "添加到板位{{slotName}}", "add": "添加", @@ -137,6 +138,7 @@ "recalibrate_pipette_offset": "重新校准移液器偏移", "recalibrate_pipette": "重新校准移液器", "recent_protocol_runs": "最近的协议运行", + "rerun_loading": "数据完全加载前,禁止协议重新运行", "rerun_now": "立即重新运行协议", "reset_all": "重置全部", "reset_estop": "重置急停", diff --git a/app/src/assets/localization/zh/device_settings.json b/app/src/assets/localization/zh/device_settings.json index c69e9e46131..ecd81c941dd 100644 --- a/app/src/assets/localization/zh/device_settings.json +++ b/app/src/assets/localization/zh/device_settings.json @@ -319,6 +319,7 @@ "wpa2_personal": "WPA2个人", "wpa2_personal_description": "大多数实验室都使用此方法", "yes_clear_data_and_restart_robot": "是,清除数据并重新启动工作站", + "you_should_not_downgrade": "您不应降级到工作站制造日期之前的软件版本。", "your_mac_address_is": "您的MAC地址是{{macAddress}}", "your_robot_is_ready_to_go": "您的工作站已准备就绪。" } diff --git a/app/src/assets/localization/zh/devices_landing.json b/app/src/assets/localization/zh/devices_landing.json index 587959751e9..8e6af9d5ba9 100644 --- a/app/src/assets/localization/zh/devices_landing.json +++ b/app/src/assets/localization/zh/devices_landing.json @@ -8,8 +8,8 @@ "devices": "设备", "disconnect_from_network": "断开网络连接", "empty": "空", - "forget_unavailable_robot": "忘记无法使用的工作站", - "go_to_run": "去运行", + "forget_unavailable_robot": "删除无法使用的工作站", + "go_to_run": "返回运行界面", "home_gantry": "龙门架归位", "how_to_setup_a_robot": "如何设置新工作站", "idle": "空闲", @@ -28,6 +28,7 @@ "modules": "模块", "no_robots_found": "未找到工作站", "not_available": "不可用({{count}})", + "ot2_quickstart_guide": "OT-2 快速入门指南", "refresh": "刷新", "restart_the_app": "重启应用程序", "restart_the_robot": "重启工作站", diff --git a/app/src/assets/localization/zh/drop_tip_wizard.json b/app/src/assets/localization/zh/drop_tip_wizard.json index 8984387755e..a5ae8faebfc 100644 --- a/app/src/assets/localization/zh/drop_tip_wizard.json +++ b/app/src/assets/localization/zh/drop_tip_wizard.json @@ -5,22 +5,31 @@ "blowout_liquid": "吹出液体", "cant_safely_drop_tips": "无法安全丢弃吸头", "choose_blowout_location": "选择吹液位置", + "choose_deck_location": "选择甲板位置", "choose_drop_tip_location": "选择吸头丢弃位置", "confirm_blowout_location": "移液器是否位于应吹出液体的位置?", "confirm_drop_tip_location": "移液器是否位于应丢弃吸头的位置?", + "confirm_position": "确认位置", "confirm_removal_and_home": "确认移除并回到原点", + "continue": "继续", "drop_tip_complete": "吸头丢弃完成", "drop_tip_failed": "丢弃吸头操作未能完成,请联系技术支持获取帮助。", "drop_tips": "丢弃吸头", "error_dropping_tips": "丢弃吸头时发生错误", + "exit_and_home_pipette": "退出并归位移液器", "exit_screen_title": "在完成吸头丢弃前退出?", + "exit": "退出", "getting_ready": "正在准备…", "go_back": "返回", + "jog_too_far": "移动过远?", + "liquid_damages_pipette": "如果移液器中有液体,将移液器归位可能会损坏它。您必须在使用移液器之前取下所有吸头。", + "liquid_damages_this_pipette": "如果{{mount}}移液器的移液器中有液体,将其归位可能会损坏它。您必须在使用移液器之前取下所有吸头。", "move_to_slot": "移至板位", "no_proceed_to_drop_tip": "否,继续进行吸头移除", "position_and_blowout": "确保移液器吸头尖端位于指定位置的正上方并保持水平。如果不是,请使用下面的控制键或键盘微调移液器直到正确位置。", "position_and_drop_tip": "确保移液器吸头尖端位于指定位置的正上方并保持水平。如果不是,请使用下面的控制键或键盘微调移液器直到正确位置。", "position_the_pipette": "调整移液器位置", + "remove_any_attached_tips": "移除任何已安装的吸头", "remove_the_tips": "在协议中再次使用前,您可能需要从{{mount}}移液器上移除吸头。", "remove_the_tips_from_pipette": "在协议中再次使用前,您可能需要从移液器上移除吸头。", "remove_the_tips_manually": "手动移除吸头,然后使龙门架回原点。在拾取吸头的状态下归位可能导致移液器吸入液体并损坏。", @@ -29,11 +38,15 @@ "select_blowout_slot_odd": "您可以将液体吹入耗材中。
龙门架移动到选定的板位后,使用位置控制按键将移液器移动到吹出液体的确切位置。", "select_drop_tip_slot": "您可以将吸头返回吸头架或丢弃它们。在右侧的甲板图上选择您想丢弃吸头的板位。确认后龙门架将移动到选定的板位。", "select_drop_tip_slot_odd": "您可以将吸头放回吸头架或丢弃它们。
龙门架移动到选定的板位后,使用位置控制按键将移液器移动到丢弃吸头的确切位置。", + "skip_and_home_pipette": "跳过并归位移液器", "skip": "跳过", "stand_back_blowing_out": "请远离,工作站正在吹出液体", "stand_back_dropping_tips": "请远离,工作站正在丢弃吸头", "stand_back_robot_in_motion": "请远离,工作站正在移动", - "tips_are_attached": "吸头已拾取", - "tips_may_be_attached": "可能已拾取吸头。", + "start_over": "重新开始", + "trash_bin_in_slot": "垃圾桶在 {{slot}}", + "waste_chute_in_slot": "外置垃圾槽在 {{slot}}", + "where_to_blowout": "您希望在哪里吹出液体?", + "where_to_drop_tips": "您希望在哪里丢弃吸头?", "yes_blow_out_liquid": "是的,将液体吹入耗材中" } diff --git a/app/src/assets/localization/zh/error_recovery.json b/app/src/assets/localization/zh/error_recovery.json index 2383ca8f850..dddf7923d4b 100644 --- a/app/src/assets/localization/zh/error_recovery.json +++ b/app/src/assets/localization/zh/error_recovery.json @@ -1,4 +1,5 @@ { + "another_app_controlling_robot": "工作站的触摸屏或另一台装有应用程序的电脑正在控制这个工作站。", "are_you_sure_you_want_to_cancel": "您确定要取消吗?", "at_step": "在步骤", "back_to_menu": "返回菜单", @@ -11,25 +12,44 @@ "change_location": "更改位置", "change_tip_pickup_location": "更换拾取吸头的位置", "choose_a_recovery_action": "选择恢复操作", + "close_door_to_resume": "关闭工作站门以继续", + "close_robot_door": "关闭移液工作站前门", + "close_the_robot_door": "关闭工作站门,然后继续恢复操作。", "confirm": "确认", "continue": "继续", "continue_run_now": "现在继续运行", "continue_to_drop_tip": "继续丢弃吸头", + "ensure_lw_is_accurately_placed": "确保实验耗材已准确放置在甲板槽中,防止进一步出现错误", + "error_details": "错误详情", + "error_on_robot": "{{robot}}上的错误", "error": "错误", "failed_dispense_step_not_completed": "中断运行的最后一步液体排出失败,恢复程序将不会继续运行这一步骤,请手动完成这一步的移液操作。运行将继续从下一步开始。继续之前,请关闭工作站门。", "failed_step": "失败步骤", + "first_is_gripper_holding_labware": "首先,抓扳手是否夹着实验耗材?", "go_back": "返回", + "gripper_error": "抓扳手错误", + "gripper_errors_occur_when": "当抓扳手停滞或与甲板上另一物体碰撞时,会发生抓扳手错误,这通常是由于实验耗材放置不当或实验耗材偏移不准确所致", + "gripper_releasing_labware": "抓扳手正在释放实验耗材", + "gripper_will_release_in_s": "抓扳手将在{{seconds}}秒后释放实验耗材", + "homing_pipette_dangerous": "如果移液器中有液体,将{{mount}}移液器归位可能会损坏它。您必须在使用移液器之前取下所有吸头。", + "if_issue_persists_gripper_error": "如果问题持续存在,请取消运行并重新运行抓扳手校准", + "if_issue_persists_overpressure": "如果问题持续存在,请取消运行并对协议进行必要的更改", + "if_issue_persists_tip_not_detected": "如果问题持续存在,请取消运行并启动实验耗材位置检查", "if_tips_are_attached": "如果吸头还在移液器上,您可以在运行终止前选择吹出已吸取的液体并丢弃吸头。", "ignore_all_errors_of_this_type": "忽略所有此类错误", "ignore_error_and_skip": "忽略错误并跳到下一步", - "skipping_to_step_succeeded": "跳转到步骤{{step}}成功", - "retrying_step_succeeded": "重试步骤{{step}}成功", "ignore_only_this_error": "仅忽略此错误", "ignore_similar_errors_later_in_run": "要在后续的运行中忽略类似错误吗?", + "labware_released_from_current_height": "将从当前高度释放实验耗材", "launch_recovery_mode": "启动恢复模式", "manually_fill_liquid_in_well": "手动填充孔位{{well}}中的液体", "manually_fill_well_and_skip": "手动填充孔位并跳到下一步", + "manually_move_lw_and_skip": "手动移动实验耗材并跳至下一步", + "manually_move_lw_on_deck": "手动移动实验耗材", + "manually_replace_lw_and_retry": "手动更换实验耗材并重试此步骤", + "manually_replace_lw_on_deck": "手动更换实验耗材", "next_step": "下一步", + "next_try_another_action": "接下来,您可以尝试另一个恢复操作或取消运行。", "no_liquid_detected": "未检测到液体", "pick_up_tips": "取吸头", "pipette_overpressure": "移液器超压", @@ -39,23 +59,32 @@ "recovery_action_failed": "{{action}}失败", "recovery_mode": "恢复模式", "recovery_mode_explanation": "恢复模式为您提供运行错误后的手动处理引导。
您可以进行调整以确保发生错误时正在进行的步骤可以完成,或者选择取消协议。当做出调整且未检测到后续错误时,该模式操作完成。根据导致错误的条件,系统将提供相应的调整选项。", + "release_labware_from_gripper": "从抓板手中释放实验耗材", + "release": "释放", + "remove_any_attached_tips": "移除任何已安装的吸头", "replace_tips_and_select_location": "建议更换吸头并选择最后一次取吸头的位置。", "replace_used_tips_in_rack_location": "在吸头板位{{location}}更换已使用的吸头", "replace_with_new_tip_rack": "更换新的吸头盒", - "first_take_any_necessary_actions": "首先,采取必要的准备操作,工作站将从失败的步骤开始继续运行。然后,在工作站继续运行之前关闭前门。", + "resume": "继续", + "retrying_step_succeeded": "重试步骤{{step}}成功", "retry_now": "现在重试", "retry_step": "重试步骤", "retry_with_new_tips": "使用新吸头重试", "retry_with_same_tips": "使用相同吸头重试", "return_to_menu": "返回菜单", - "return_to_the_menu": "返回菜单以选择如何继续。", + "robot_door_is_open": "工作站前门已打开", + "robot_is_canceling_run": "工作站正在取消运行", + "robot_is_in_recovery_mode": "工作站正在恢复模式", + "robot_not_attempt_to_move_lw": "工作站将不再尝试移动实验耗材。运行将从下一步继续。在继续之前,请关闭工作站门。", + "robot_retry_failed_lw_movement": "工作站将会从更换耗材的位置重新尝试失败的移液步骤。在继续之前,请关闭工作站门。", "robot_will_not_check_for_liquid": "工作站将不再检查液体。运行将从下一步继续。继续前请关闭工作站前门。", "robot_will_retry_with_new_tips": "工作站将使用新吸头重试失败的步骤。继续前请关闭工作站前门。", "robot_will_retry_with_same_tips": "工作站将使用相同的吸头重试失败的步骤。继续前请关闭工作站前门。", "robot_will_retry_with_tips": "工作站将使用新吸头重试失败的步骤。", "run_paused": "运行暂停", "select_tip_pickup_location": "选择取吸头位置", - "skip": "跳过", + "skipping_to_step_succeeded": "跳转到步骤{{step}}成功", + "skip_and_home_pipette": "跳过并归位移液器", "skip_to_next_step": "跳到下一步", "skip_to_next_step_new_tips": "使用新吸头跳到下一步", "skip_to_next_step_same_tips": "使用相同吸头跳到下一步", @@ -64,8 +93,13 @@ "stand_back_resuming": "请远离,正在恢复当前步骤", "stand_back_retrying": "请远离,正在重试失败步骤", "stand_back_skipping_to_next_step": "请远离,正在跳到下一步骤", + "take_any_necessary_precautions": "在接住实验耗材之前,请采取必要的预防措施。确认后,夹爪将开始倒计时再释放。", + "take_necessary_actions_failed_pickup": "首先,采取任何必要的行动,让工作站重新尝试移液器拾取。然后,在继续之前关闭工作站门。", + "take_necessary_actions": "首先,采取任何必要的行动,让工作站重新尝试失败的步骤。然后,在继续之前关闭工作站门。", + "terminate_remote_activity": "终止远程活动", "tip_drop_failed": "丢弃吸头失败", "tip_not_detected": "未检测到吸头", + "tip_presence_errors_are_caused": "吸头识别错误通常是由实验器皿放置不当或器皿偏移不准确引起的。", "view_error_details": "查看错误详情", "view_recovery_options": "查看恢复选项", "you_can_still_drop_tips": "在继续选择吸头之前,您仍然可以丢弃移液器上现存的吸头。", diff --git a/app/src/assets/localization/zh/labware_position_check.json b/app/src/assets/localization/zh/labware_position_check.json index fb92623de2e..cb27f78e66c 100644 --- a/app/src/assets/localization/zh/labware_position_check.json +++ b/app/src/assets/localization/zh/labware_position_check.json @@ -95,7 +95,7 @@ "return_tip_section": "放回吸头", "returning_tip_title": "正在板位{{slot}}放回吸头", "reveal_jog_controls": "显示调整面板", - "robot_has_no_offsets_from_previous_runs": "耗材校准数据引用自之前运行的协议,以节省您的时间。如果本协议中的所有耗材已在之前的运行中检查过,这些数据将应用于本次运行。 您可以使用耗材位置校准程序的后续步骤来添加耗材校准数据。", + "robot_has_no_offsets_from_previous_runs": "耗材校准数据引用自之前运行的协议,以节省您的时间。如果本协议中的所有耗材已在之前的运行中检查过,这些数据将应用于本次运行。 您可以在后面的步骤中使用耗材位置校准添加新的偏移量。", "robot_has_offsets_from_previous_runs": "此工作站具有本协议中所用耗材的校准数据。如果您应用了这些校准数据,仍可通过耗材位置校准程序进行调整。", "robot_in_motion": "工作站正在运行,请远离。", "run_labware_position_check": "运行耗材位置校准程序", diff --git a/app/src/assets/localization/zh/protocol_command_text.json b/app/src/assets/localization/zh/protocol_command_text.json index 81fff4fd220..74ab15b69b7 100644 --- a/app/src/assets/localization/zh/protocol_command_text.json +++ b/app/src/assets/localization/zh/protocol_command_text.json @@ -16,6 +16,7 @@ "deactivating_tc_block": "停用热循环仪温控功能", "deactivating_tc_lid": "停用热循环仪热盖功能", "degrees_c": "{{temp}}°C", + "detect_liquid_presence": "正在检测{{labware}}在{{labware_location}}中的{{well_name}}孔中的液体存在", "disengaging_magnetic_module": "下降磁力架模块", "dispense_push_out": "以{{flow_rate}}µL/秒的速度将{{volume}}µL 排液至{{labware_location}}的{{labware}}的{{well_name}}孔中,并推出{{push_out_volume}}µL", "dispense": "以{{flow_rate}}µL/秒的速度将{{volume}}µL 排液至{{labware_location}}的{{labware}}的{{well_name}}孔中", @@ -26,6 +27,16 @@ "fixed_trash": "垃圾桶", "home_gantry": "复位所有龙门架、移液器和柱塞轴", "latching_hs_latch": "在热震荡模块上锁定实验耗材", + "left": "左", + "load_labware_info_protocol_setup_adapter_module": "在{{module_name}}的甲板槽{{slot_name}}上加载适配器{{adapter_name}}中的{{labware}}", + "load_labware_info_protocol_setup_adapter_off_deck": "在板外加载适配器{{adapter_name}}中的{{labware}}", + "load_labware_info_protocol_setup_adapter": "在甲板槽{{slot_name}}上加载适配器{{adapter_name}}中的{{labware}}", + "load_labware_info_protocol_setup_no_module": "在甲板槽{{slot_name}}中加载{{labware}}", + "load_labware_info_protocol_setup_off_deck": "在板外加载{{labware}}", + "load_labware_info_protocol_setup": "在{{module_name}}的甲板槽{{slot_name}}中加载{{labware}}", + "load_liquids_info_protocol_setup": "将{{liquid}}加载到{{labware}}中", + "load_module_protocol_setup": "在甲板槽{{slot_name}}中加载模块{{module}}", + "load_pipette_protocol_setup": "在{{mount_name}}支架上加载{{pipette_name}}", "module_in_slot_plural": "{{module}}", "module_in_slot": "{{module}}在{{slot_name}}号板位", "move_labware_manually": "手动将{{labware}}从{{old_location}}移动到{{new_location}}", @@ -46,7 +57,9 @@ "pause": "暂停", "pickup_tip": "从{{labware_location}}的{{labware}}的{{well_range}}孔中拾取吸头", "prepare_to_aspirate": "准备使用{{pipette}}吸液", + "reloading_labware": "正在重新加载{{labware}}", "return_tip": "将吸头返回到{{labware_location}}的{{labware}}的{{well_name}}孔中", + "right": "右", "save_position": "保存位置", "set_and_await_hs_shake": "设置热震荡模块以{{rpm}}rpm 震动并等待达到该转速", "setting_hs_temp": "将热震荡模块的目标温度设置为{{temp}}", @@ -60,6 +73,8 @@ "tc_starting_profile": "热循环仪开始进行由以下步骤组成的{{repetitions}}次循环:", "trash_bin_in_slot": "垃圾桶在{{slot_name}}", "touch_tip": "吸头接触内壁", + "turning_rail_lights_off": "正在关闭导轨灯", + "turning_rail_lights_on": "正在打开导轨灯", "unlatching_hs_latch": "解锁热震荡模块上的实验耗材", "wait_for_duration": "暂停{{seconds}}秒。{{message}}", "wait_for_resume": "暂停协议", diff --git a/app/src/assets/localization/zh/protocol_details.json b/app/src/assets/localization/zh/protocol_details.json index b62bc2bb89a..22f4c0566fc 100644 --- a/app/src/assets/localization/zh/protocol_details.json +++ b/app/src/assets/localization/zh/protocol_details.json @@ -4,7 +4,7 @@ "both_mounts": "两个移液器支架", "choices": "{{count}}个选择", "choose_file": "选择文件", - "choose_robot_to_run": "选择要运行的工作站\n{{protocol_name}}", + "choose_robot_to_run": "选择要运行{{protocol_name}}的工作站", "clear_and_proceed_to_setup": "清除并继续设置", "connect_modules_to_see_controls": "连接模块以查看控制", "connected": "已连接", @@ -21,6 +21,8 @@ "description": "描述", "extension_mount": "扩展安装支架", "file_required": "需要文件", + "go_to_labware_definition": "转到实验耗材定义", + "go_to_timeline": "转到时间线", "gripper_pick_up_count_description": "使用转板抓手移动单个耗材的指令。", "gripper_pick_up_count": "转板次数", "hardware": "硬件", @@ -28,6 +30,7 @@ "labware": "耗材", "last_analyzed": "上一次分析", "last_updated": "上一次更新", + "left_and_right_mounts": "左+右安装架", "left_mount": "左移液器安装位", "left_right": "左,右", "liquid_name": "液体名称", @@ -40,6 +43,7 @@ "name": "名称", "no_available_robots_found": "未找到可用的工作站", "no_custom_values": "未指定任何自定义值", + "no_labware_specified": "此协议中未指定实验耗材", "no_parameters": "该协议未指定任何参数", "no_summary": "没有为此协议指定摘要。", "not_connected": "未连接", diff --git a/app/src/assets/localization/zh/protocol_info.json b/app/src/assets/localization/zh/protocol_info.json index e279383c495..073b4fd6319 100644 --- a/app/src/assets/localization/zh/protocol_info.json +++ b/app/src/assets/localization/zh/protocol_info.json @@ -25,6 +25,7 @@ "import_a_file": "导入协议以开始", "import_new_protocol": "导入协议", "import": "导入", + "incompatible_file_type": "不兼容的文件类型", "instrument_cal_data_title": "校准数据", "instrument_not_attached": "未连接", "instruments_title": "所需移液器", @@ -44,12 +45,12 @@ "launch_protocol_designer": "打开在线协议编辑器", "manual_steps_learn_more": "了解更多关于手动步骤的信息", "modules_title": "所需模块", - "most_recent_updates": "最新更新", + "most_recent_updates": "按时间排序", "no_history": "无运行历史", "no_labware_offset_data": "无耗材校准数据", "no_protocol_yet": "还没有协议?", "nothing_here_yet": "没有可显示的协议!", - "oldest_updates": "最早的更新", + "oldest_updates": "按时间倒序排序", "open_a_protocol": "打开一个协议以开始", "open_api_docs": "打开Python API文档", "organization_and_author": "组织/作者", @@ -64,10 +65,10 @@ "protocol_title": "协议 -{{protocol_name}}", "protocol_upload_failed": "协议上传失败。请修复错误后重试", "protocols": "协议", - "quick_transfer": "快速移液", "required_cal_data_title": "校准数据", "required_quantity_title": "数量", "required_type_title": "类型", + "requires_csv": "需要CSV", "robot_name_last_run": "{{robot_name}}的上次运行", "robot_type_first": "{{robotType}}协议优先", "run_again": "再次运行", diff --git a/app/src/assets/localization/zh/protocol_setup.json b/app/src/assets/localization/zh/protocol_setup.json index 7e5007e8a2c..40a5c8c00f9 100644 --- a/app/src/assets/localization/zh/protocol_setup.json +++ b/app/src/assets/localization/zh/protocol_setup.json @@ -8,6 +8,10 @@ "add_to_slot": "添加到{{slotName}}号板位", "additional_labware": "{{count}}个额外的耗材", "additional_off_deck_labware": "额外的甲板外耗材", + "all_files_associated": "与协议运行相关文件的所有详细信息均可在工作站屏幕上获得。", + "applied_labware_offset_data": "已应用的实验耗材偏移数据", + "applied_labware_offsets": "已应用的实验耗材偏移", + "are_you_sure_you_want_to_proceed": "您确定要继续运行吗?", "attach_gripper_failure_reason": "连接所需的转板抓手以继续", "attach_gripper": "连接转板抓手", "attach_module": "校准前连接模块", @@ -39,6 +43,7 @@ "calibration_status": "校准状态", "calibration": "校准", "cancel_and_restart_to_edit": "取消运行并重新启动设置以进行编辑", + "choose_csv_file": "选择CSV文件", "choose_enum": "选择{{displayName}}", "closing": "关闭中...", "complete_setup_before_proceeding": "完成设置后继续运行", @@ -46,12 +51,19 @@ "configured": "已配置", "confirm_heater_shaker_module_modal_description": "在开始运行之前,应使模块的两个锚固件完全伸出,以确保牢固连接。导热适配器应连接到模块上。", "confirm_heater_shaker_module_modal_title": "确认已连接热震荡模块", + "confirm_liquids": "确认液体", + "confirm_locations_and_volumes": "确认位置和体积", + "confirm_offsets": "确认偏移校准数据", + "confirm_placements": "确认放置位置", + "confirm_selection": "确认选择", "confirm_values": "确认这些值", "connect_all_hardware": "首先连接并校准所有硬件", "connect_all_mod": "首先连接所有模块", "connect_modules_for_controls": "连接模块以查看控制", "connection_info_not_available": "一旦运行开始,连接信息不可用", "connection_status": "连接状态", + "csv_files_on_robot": "工作站上的CSV文件", + "csv_files_on_usb": "USB上的CSV文件", "csv_file": "CSV 文件", "currently_configured": "当前已配置", "currently_unavailable": "当前不可用", @@ -63,9 +75,11 @@ "deck_conflict_info_thermocycler": "通过移除位置 A1 和 B1 中的固定装置来更新甲板配置。从甲板配置中移除对应装置或更新协议。", "deck_conflict_info": "通过移除位置 {{cutout}} 中的 {{currentFixture}} 来更新甲板配置。从甲板配置中移除对应装置或更新协议。", "deck_conflict": "甲板位置冲突", + "deck_hardware_ready": "甲板硬件准备", "deck_hardware": "甲板硬件", "deck_map": "甲板布局图", "default_values": "默认值", + "download_files": "下载文件", "example": "示例", "exit_to_deck_configuration": "退出到甲板配置", "extension_mount": "扩展安装支架", @@ -81,6 +95,7 @@ "heater_shaker_extra_attention": "使用闩锁控制,便于放置耗材。", "heater_shaker_labware_list_view": "要添加耗材,请使用切换键来控制闩锁", "how_offset_data_works": "耗材校准数据如何工作", + "individiual_well_volume": "单个孔体积", "initial_liquids_num_plural": "{{count}}种初始液体", "initial_liquids_num": "{{count}}种初始液体", "initial_location": "初始位置", @@ -96,6 +111,7 @@ "labware_latch": "耗材闩锁", "labware_location": "耗材位置", "labware_name": "耗材名称", + "labware_placement": "实验耗材放置", "labware_position_check_not_available_analyzing_on_robot": "在工作站上分析协议时,耗材位置校准不可用", "labware_position_check_not_available_empty_protocol": "耗材位置校准需要协议加载耗材和移液器", "labware_position_check_not_available": "运行开始后,耗材位置校准不可用", @@ -111,10 +127,14 @@ "learn_more_about_offset_data": "了解更多关于耗材校准数据的信息", "learn_more_about_robot_cal_link": "了解更多关于工作站校准的信息", "learn_more": "了解更多", + "liquid_information": "液体信息", + "liquid_name": "液体名称", "liquid_setup_step_description": "查看液体的起始位置和体积", "liquid_setup_step_title": "液体", + "liquids_confirmed": "液体已确认", "liquids_not_in_setup": "此协议未使用液体", "liquids_not_in_the_protocol": "此协议未指定液体。", + "liquids_ready": "液体准备", "liquids": "液体", "list_view": "列表视图", "loading_data": "加载数据...", @@ -141,6 +161,7 @@ "module_mismatch_body": "检查连接到该工作站的模块型号是否正确", "module_name": "模块", "module_not_connected": "未连接", + "module_setup_step_ready": "校准准备", "module_setup_step_title": "甲板硬件", "module_slot_location": "{{slotName}}号板位,{{moduleName}}", "module": "模块", @@ -184,6 +205,8 @@ "offset_data": "偏移校准数据", "offsets_applied_plural": "应用了{{count}}个偏移校准数据", "offsets_applied": "应用了{{count}}个偏移校准数据", + "offsets_confirmed": "偏移校准数据已确认", + "offsets_ready": "偏移校准数据准备", "on_adapter_in_mod": "在{{moduleName}}中的{{adapterName}}上", "on_adapter": "在{{adapterName}}上", "on_deck": "在甲板上", @@ -198,6 +221,8 @@ "pipette_offset_cal_description_bullet_3": "对用于校准移液器的吸头执行吸头长度校准后,重新进行移液器偏移校准。", "pipette_offset_cal_description": "这会测量移液器相对于移液器安装支架和甲板的X、Y和Z值。移液器偏移校准依赖于甲板校准和吸头长度校准。 ", "pipette_offset_cal": "移液器偏移校准", + "placements_confirmed": "位置已确认", + "placements_ready": "位置准备", "placement": "放置", "plug_in_module_to_configure": "插入{{module}}以将其添加到板位", "plug_in_required_module_plural": "插入并启动所需模块以继续", @@ -210,10 +235,12 @@ "proceed_to_run": "继续运行", "protocol_analysis_failed": "协议分析失败", "protocol_can_be_closed": "该协议现在可以关闭。", + "protocol_requires_csv": "此协议需要一个CSV文件。点击下面的CSV文件进行选择。", "protocol_run_canceled": "协议运行已取消。", "protocol_run_complete": "协议运行完成。", "protocol_run_failed": "协议运行失败。", "protocol_run_started": "协议运行已开始。", + "protocol_run_stopped": "协议运行已停止。", "protocol_specifies": "协议指定", "protocol_upload_revamp_feedback": "对此体验有反馈吗?", "quantity": "数量", @@ -237,10 +264,12 @@ "robot_cal_help_title": "工作站校准的工作原理", "robot_calibration_step_description_pipettes_only": "查看该协议所需的硬件和校准。", "robot_calibration_step_description": "查看该协议所需的移液器和吸头长度校准。", + "robot_calibration_step_ready": "校准准备", "robot_calibration_step_title": "硬件", "run_disabled_calibration_not_complete": "确保工作站校准完成后再继续运行", "run_disabled_modules_and_calibration_not_complete": "确保工作站校准完成并且所有模块已连接后再继续运行", "run_disabled_modules_not_connected": "确保所有模块已连接后再继续运行", + "run_labware_position_check_to_get_offsets": "运行实验室位置检查以获取实验室偏移数据。", "run_labware_position_check": "运行耗材位置校准", "run_never_started": "运行未开始", "run": "运行", @@ -252,6 +281,8 @@ "setup_is_view_only": "运行开始后设置仅供查看", "slot_location": "{{slotName}}号板位", "slot_number": "板位编号", + "stacked_slot": "堆叠槽", + "start_run": "开始运行", "status": "状态", "step": "步骤{{index}}", "there_are_no_unconfigured_modules": "没有连接{{module}}。请连接一个模块并放置在{{slot}}号板位中。", @@ -260,20 +291,25 @@ "tip_length_cal_description": "这将测量吸头底部与移液器喷嘴之间的Z轴距离。如果对用于校准移液器的吸头重新进行吸头长度校准,也需要重新进行移液器偏移校准。", "tip_length_cal_title": "吸头长度校准", "tip_length_calibration": "吸头长度校准", + "total_liquid_volume": "总体积", "update_deck_config": "更新甲板配置", "update_deck": "更新甲板", + "update_offsets": "更新偏移校准数据", "updated": "已更新", "usb_connected_no_port_info": "USB端口已连接", + "usb_drive_notification": "在运行开始前,请保持USB处于连接状态", "usb_port_connected": "USB端口{{port}}", "usb_port_number": "USB-{{port}}", "value_out_of_range_generic": "值必须在范围内", "value_out_of_range": "值必须在{{min}}-{{max}}之间", "value": "值", "values_are_view_only": "值仅供查看", + "variable_well_amount": "可变孔数", "view_current_offsets": "查看当前偏移量", "view_moam": "查看工作站中放置同类型模块的设置说明。", "view_setup_instructions": "查看设置说明", "volume": "体积", "what_labware_offset_is": "耗材偏移校准是一种位置调整类型,用于补偿甲板上耗材整体位置的微小实际差异。耗材偏移校准数据是耗材、甲板位和工作站的特定组合。", - "with_the_chosen_value": "使用选定的值时,发生以下错误:" + "with_the_chosen_value": "使用选定的值时,发生以下错误:", + "you_havent_confirmed": "您尚未确认 {{missingSteps}}。在继续运行协议之前,请确保这些步骤正确无误。" } diff --git a/app/src/assets/localization/zh/quick_transfer.json b/app/src/assets/localization/zh/quick_transfer.json index 651ace8ef4b..4a1e2779d52 100644 --- a/app/src/assets/localization/zh/quick_transfer.json +++ b/app/src/assets/localization/zh/quick_transfer.json @@ -1,46 +1,114 @@ { + "a_way_to_move_liquid": "一种将单一液体从一个实验耗材移动到另一个实验耗材的方法。", "add_or_remove_columns": "添加或移除列", "add_or_remove": "添加或移除", + "advanced_setting_disabled": "此移液的高级设置已禁用", "advanced_settings": "高级设置", + "air_gap_before_aspirating": "在吸液前设置空气间隙", + "air_gap_before_dispensing": "在分液前设置空气间隙", + "air_gap_capacity_error": "移液器空间已满,无法添加空气间隙。", + "air_gap_value": "{{volume}} µL", + "air_gap_volume_µL": "空气间隙体积(µL)", + "air_gap": "空气间隙", "all": "所有实验耗材", "always": "每次吸液前", + "aspirate_flow_rate_µL": "吸取流速(µL/s)", + "aspirate_flow_rate": "吸取流速", + "aspirate_settings": "吸取设置", + "aspirate_tip_position": "吸取移液器位置", "aspirate_volume": "每孔吸液体积", "aspirate_volume_µL": "每孔吸液体积(µL)", + "attach_pipette": "连接移液器", + "blow_out_after_dispensing": "分液后吹出", + "blow_out_destination_well": "目标孔", + "blow_out_into_destination_well": "到目标孔", + "blow_out_into_source_well": "到吸液孔", + "blow_out_into_trash_bin": "到垃圾桶", + "blow_out_into_waste_chute": "到外置垃圾槽", + "blow_out_source_well": "源孔", + "blow_out_trash_bin": "垃圾桶", + "blow_out_waste_chute": "外置垃圾槽", + "blow_out": "吹出", "both_mounts": "左侧+右侧支架", "change_tip": "更换吸头", "character_limit_error": "字数超出限制", "column": "列", "columns": "列", + "consolidate_volume_error": "所选的目标孔太小,无法合并。请尝试从更少的孔中合并。", + "create_new_to_edit": "创建新的快速移液以进行编辑", "create_new_transfer": "创建新的快速移液命令", + "create_to_get_started": "创建新的快速移液以开始操作。", "create_transfer": "创建移液命令", + "delay_before_aspirating": "吸取前的延迟", + "delay_before_dispensing": "分液前的延迟", + "delay_duration_s": "延迟时长(秒)", + "delay_position_mm": "距孔底延迟时的位置(mm)", + "delay_value": "{{delay}}秒,距离孔底{{position}}mm", + "delay": "延迟", + "delete_this_transfer": "确定删除此这个快速移液?", + "delete_transfer": "删除快速移液", + "deleted_transfer": "已删除快速移液", "destination": "目标", "destination_labware": "目标实验耗材", - "dispense_volume": "每孔排液体积", + "disabled": "已禁用", + "dispense_flow_rate_µL": "分液流速(µL/s)", + "dispense_flow_rate": "分液流速", + "dispense_settings": "分液设置", + "dispense_tip_position": "分液吸头位置", "dispense_volume_µL": "每孔排液体积(µL)", + "dispense_volume": "每孔排液体积", + "disposal_volume_µL": "废液量(µL)", + "distance_bottom_of_well_mm": "距离孔底的高度(mm)", + "distribute_volume_error": "所选源孔太小,无法从中分液。请尝试向更少的孔中分液。", "enter_characters": "输入最多60个字符", + "error_analyzing": "在尝试分析{{transferName}}时发生错误。", "exit_quick_transfer": "退出快速移液?", + "failed_analysis": "分析失败", + "flow_rate_value": "{{flow_rate}} µL/s", + "got_it": "明白了", "grid": "网格", "grids": "网格", + "labware": "实验耗材", "learn_more": "了解更多", "left_mount": "左侧支架", "lose_all_progress": "您将失去所有此快速移液流程进度.", + "mix_before_aspirating": "在吸液前混匀", + "mix_before_dispensing": "在分液前混匀", + "mix_repetitions": "混匀重复次数", + "mix_value": "{{volume}} µL,混匀{{reps}}次", + "mix_volume_µL": "混匀体积(µL)", + "mix": "混匀", "name_your_transfer": "为您的快速移液流程命名", + "none_to_show": "没有快速移液可显示!", "number_wells_selected_error_learn_more": "具有多个源孔{{selectionUnits}}的快速移液是可以进行一对一或者多对多移液的(为此移液流程同样选择{{wellCount}}个目标孔位{{selectionUnits}})或进行多对一移液,即合并为单个孔位(选择1个目标孔{{selectionUnit}})。", "number_wells_selected_error_message": "选择1个或{{wellCount}}个{{selectionUnits}}来进行此移液操作.", "once": "在移液开始时", + "option_disabled": "已禁用", + "option_enabled": "已启用", "overview": "概览", "perDest": "每个目标孔位", "perSource": "每个源孔位", + "pin_transfer": "快速移液", + "pinned_transfer": "固定快速移液", + "pinned_transfers": "固定快速移液", + "pipette_path_multi_aspirate": "多次吸取", + "pipette_path_multi_dispense_volume_blowout": "多次分液,{{volume}} 微升废弃量,在{{blowOutLocation}}吹出", + "pipette_path_multi_dispense": "多次分液", + "pipette_path_single": "单次转移", + "pipette_path": "移液器路径", + "pipette_currently_attached": "快速移液移液器选项取决于当前您工作站上安装的移液器.", "pipette": "移液器", + "pre_wet_tip": "润湿吸头", "quick_transfer_volume": "快速移液{{volume}}µL", + "quick_transfer": "快速移液", "right_mount": "右侧支架", "reservoir": "储液槽", "run_now": "立即运行", "run_quick_transfer_now": "您想立即运行快速移液流程吗?", + "run_transfer": "运行快速移液", "save": "保存", "save_to_run_later": "保存您的快速移液流程以备后续运行.", "save_for_later": "保存备用", - "source": "源", "select_attached_pipette": "选择已连接的移液器", "select_by": "按...选择", "select_dest_labware": "选择目标实验耗材", @@ -52,25 +120,40 @@ "set_dispense_volume": "设置排液体积", "set_transfer_volume": "设置移液体积", "source_labware": "源实验耗材", - "source_labware_d2": "D2位置的源实验耗材", + "source_labware_c2": "C2 中的源实验耗材", + "source": "源", "starting_well": "起始孔", + "storage_limit_reached": "已达到存储限制", "use_deck_slots": "快速移液将使用板位B2-D2。这些板位将用于放置吸头盒、源实验耗材和目标实验耗材。请确保使用最新的甲板配置,避免碰撞。", "tip_drop_location": "吸头丢弃位置", "tip_management": "吸头管理", + "tip_position_value": "距底部 {{position}} mm", + "tip_position": "移液器位置", "tip_rack": "吸头盒", + "too_many_pins_body": "删除一个快速移液,以便向您的固定列表中添加更多传输。", + "too_many_pins_header": "您已达到上限!", + "touch_tip_before_aspirating": "在吸液前做碰壁动作", + "touch_tip_before_dispensing": "在分液前做碰壁动作", + "touch_tip_position_mm": "在孔底部做碰壁动作的高度(mm)", + "touch_tip_value": "距底部 {{position}} mm", + "touch_tip": "碰壁动作", + "transfer_analysis_failed": "快速移液分析失败", + "transfer_name": "移液名称", "trashBin": "垃圾桶", "trashBin_location": "位于{{slotName}}的垃圾桶", "tubeRack": "试管架", + "unpin_transfer": "取消固定的快速移液", + "unpinned_transfer": "已取消固定的快速移液", "volume_per_well": "每孔体积", "volume_per_well_µL": "每孔体积(µL)", "value_out_of_range": "值必须在{{min}}-{{max}}之间", - "labware": "实验耗材", - "pipette_currently_attached": "快速移液移液器选项取决于当前您工作站上安装的移液器.", "wasteChute": "外置垃圾槽", "wasteChute_location": "位于{{slotName}}的外置垃圾槽", + "welcome_to_quick_transfer": "欢迎使用快速移液!", "wellPlate": "孔板", "well_selection": "孔位选择", "well_ratio": "快速移液可以一对一或者多对多进行移液的(为此移液操作选择同样数量的{{wells}})或可以多对一,也就是合并为单孔(选择1个目标孔位)。", "well": "孔", - "wells": "孔" + "wells": "孔", + "will_be_deleted": "{{transferName}} 将被永久删除。" } diff --git a/app/src/assets/localization/zh/robot_calibration.json b/app/src/assets/localization/zh/robot_calibration.json index 8f83fb649f8..d5959a113c6 100644 --- a/app/src/assets/localization/zh/robot_calibration.json +++ b/app/src/assets/localization/zh/robot_calibration.json @@ -53,6 +53,8 @@ "download_calibration_data_unavailable": "无校准数据可用。", "download_calibration_title": "下载校准数据", "download_details": "下载详情JSON校准文件查看摘要", + "error": "错误", + "exit": "退出", "finish": "完成", "get_started": "开始", "good_calibration": "良好校准", diff --git a/app/src/assets/localization/zh/run_details.json b/app/src/assets/localization/zh/run_details.json index 648df8a66e2..00d584bb4ba 100644 --- a/app/src/assets/localization/zh/run_details.json +++ b/app/src/assets/localization/zh/run_details.json @@ -17,6 +17,7 @@ "canceling_run": "正在取消运行", "clear_protocol_to_make_available": "清除工作站的协议以使其可用", "clear_protocol": "清除协议", + "close_door_to_resume_run": "关闭工作站门以继续运行", "close_door_to_resume": "关闭移液工作站的前门以继续运行", "close_door": "关闭移液工作站前门", "closing_protocol": "正在关闭协议", @@ -32,6 +33,7 @@ "date": "日期", "door_is_open": "工作站前门已打开", "door_open_pause": "当前步骤 - 暂停 - 前门已打开", + "download_files": "下载文件", "download": "下载", "download_run_log": "下载运行日志", "downloading_run_log": "正在下载运行日志", @@ -40,6 +42,7 @@ "end_of_protocol": "协议结束", "end_step_time": "结束", "end": "结束", + "error_details": "错误详情", "error_info": "错误{{errorCode}}:{{errorType}}", "error_type": "错误:{{errorType}}", "failed_step": "步骤失败", @@ -49,7 +52,6 @@ "labware": "耗材", "left": "左", "listed_values": "列出的值仅供查看", - "load_labware_info_protocol_setup_adapter_module": "在{{slot_name}}号板位中的{{module_name}}内加载{{labware}}的{{adapter_name}}", "load_labware_info_protocol_setup_adapter_off_deck": "在甲板外的{{adapter_name}}上加载{{labware}}", "load_labware_info_protocol_setup_adapter": "在{{slot_name}}号板位中的{{adapter_name}}中加载{{labware}}", "load_labware_info_protocol_setup_no_module": "在{{slot_name}}号板位中加载{{labware}}", @@ -68,6 +70,10 @@ "move_labware": "移动耗材", "name": "名称", "no_files_included": "未包含协议文件", + "no_of_error": "{{count}}个错误", + "no_of_errors": "{{count}}个错误", + "no_of_warning": "{{count}}个警告", + "no_of_warnings": "{{count}}个警告", "no_offsets_available": "无耗材校准数据可用", "not_available_for_a_completed_run": "不适用于已完成的运行", "not_available_for_a_run_in_progress": "不适用于正在进行的运行", @@ -96,15 +102,21 @@ "protocol_title": "协议 -{{protocol_name}}", "resume_run": "恢复运行", "return_to_dashboard": "返回控制面板", + "return_to_quick_transfer": "返回快速移液", "right": "右", "robot_has_previous_offsets": "该移液工作站已存储了之前运行协议的耗材校准数据。您想将这些数据应用于此协议的运行吗?您仍然可以通过实验器具位置检查调整校准数据。", "robot_was_recalibrated": "在储存此耗材校准数据后,移液工作站已重新校准", "run_again": "再次运行", "run_canceled_splash": "运行已取消", + "run_canceled_with_errors_splash": "因错误取消运行。", + "run_canceled_with_errors": "因错误取消运行。", "run_canceled": "运行已取消。", + "run_completed_splash": "运行完成", + "run_completed_with_warnings_splash": "运行完成,并伴有警告。", + "run_completed_with_warnings": "运行完成,并伴有警告。", + "run_completed": "运行已完成。", "run_complete_splash": "运行已完成", "run_complete": "运行已完成", - "run_completed": "运行已完成。", "run_cta_disabled": "在开始运行之前,请完成协议选项卡上的所有必要步骤。", "run_failed_modal_body": "在协议执行{{command}}时发生错误。", "run_failed_modal_header": "{{errorName}}:{{errorCode}}协议步骤{{count}}", @@ -124,6 +136,8 @@ "start_step_time": "开始", "start_time": "开始时间", "start": "开始", + "status_awaiting-recovery-blocked-by-open-door": "暂停 - 门已打开", + "status_awaiting-recovery-paused": "暂停", "status_awaiting-recovery": "等待恢复", "status_blocked-by-open-door": "暂停 - 前门打开", "status_failed": "失败", @@ -150,5 +164,7 @@ "view_analysis_error_details": "查看 错误详情", "view_current_step": "查看当前步骤", "view_error_details": "查看错误详情", - "view_error": "查看错误" + "view_error": "查看错误", + "view_warning_details": "查看警告详情", + "warning_details": "警告详情" } diff --git a/app/src/assets/localization/zh/shared.json b/app/src/assets/localization/zh/shared.json index f9b0aa47cca..90b597b2820 100644 --- a/app/src/assets/localization/zh/shared.json +++ b/app/src/assets/localization/zh/shared.json @@ -2,6 +2,7 @@ "a_software_update_is_available": "此工作站有可用的软件更新。更新以运行协议。", "add": "添加", "alphabetical": "按字母排序", + "another_app_controlling_robot": "工作站的触摸屏或另一台装有应用程序的电脑正在控制这个工作站。", "back": "返回", "before_you_begin": "在您开始之前", "browse": "浏览", @@ -26,7 +27,7 @@ "disabled_protocol_is_running": "协议正在运行", "dont_show_me_again": "不再显示", "drag_and_drop": "拖放或 浏览 您的文件", - "empty": "清空", + "empty": "空闲", "ending": "结束中", "error_encountered": "遇到错误", "error": "错误", diff --git a/app/src/assets/localization/zh/top_navigation.json b/app/src/assets/localization/zh/top_navigation.json index c3906955266..cb831731be9 100644 --- a/app/src/assets/localization/zh/top_navigation.json +++ b/app/src/assets/localization/zh/top_navigation.json @@ -13,6 +13,7 @@ "please_load_a_protocol": "请加载协议以继续", "protocol_runs": "协议运行", "protocols": "协议", + "quick_transfer": "快速移液", "robot_settings": "工作站设置", "run": "运行", "settings": "设置" diff --git a/app/src/assets/videos/error-recovery/Gripper_Release.webm b/app/src/assets/videos/error-recovery/Gripper_Release.webm new file mode 100644 index 00000000000..a3ba721fd70 Binary files /dev/null and b/app/src/assets/videos/error-recovery/Gripper_Release.webm differ diff --git a/app/src/i18n.ts b/app/src/i18n.ts index 0a9701a8e87..e1c772cb582 100644 --- a/app/src/i18n.ts +++ b/app/src/i18n.ts @@ -7,6 +7,20 @@ import { titleCase } from '@opentrons/shared-data' import type { InitOptions } from 'i18next' +export const US_ENGLISH = 'en-US' +export const SIMPLIFIED_CHINESE = 'zh-CN' + +// these strings will not be translated so should not be localized +export const US_ENGLISH_DISPLAY_NAME = 'English (US)' +export const SIMPLIFIED_CHINESE_DISPLAY_NAME = '中文' + +export type Language = typeof US_ENGLISH | typeof SIMPLIFIED_CHINESE + +export const LANGUAGES: Array<{ name: string; value: Language }> = [ + { name: US_ENGLISH_DISPLAY_NAME, value: US_ENGLISH }, + { name: SIMPLIFIED_CHINESE_DISPLAY_NAME, value: SIMPLIFIED_CHINESE }, +] + const i18nConfig: InitOptions = { resources, lng: 'en', diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/index.tsx b/app/src/local-resources/commands/hooks/useCommandTextString/index.ts similarity index 98% rename from app/src/local-resources/commands/hooks/useCommandTextString/index.tsx rename to app/src/local-resources/commands/hooks/useCommandTextString/index.ts index 3966a1bc7f4..3b77c607052 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/index.tsx +++ b/app/src/local-resources/commands/hooks/useCommandTextString/index.ts @@ -7,11 +7,11 @@ import type { RobotType, LabwareDefinition2, } from '@opentrons/shared-data' -import type { GetDirectTranslationCommandText } from './utils/getDirectTranslationCommandText' import type { TCProfileStepText, TCProfileCycleText, -} from './utils/getTCRunExtendedProfileCommandText' + GetDirectTranslationCommandText, +} from './utils' import type { CommandTextData } from '/app/local-resources/commands/types' export interface UseCommandTextStringParams { @@ -90,6 +90,7 @@ export function useCommandTextString( case 'dropTip': case 'dropTipInPlace': case 'pickUpTip': + case 'airGapInPlace': return { kind: 'generic', commandText: utils.getPipettingCommandText(fullParams), diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/__tests__/getPipettingCommandText.test.tsx b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/__tests__/getPipettingCommandText.test.tsx new file mode 100644 index 00000000000..82b269bd581 --- /dev/null +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/__tests__/getPipettingCommandText.test.tsx @@ -0,0 +1,186 @@ +import { screen } from '@testing-library/react' +import { vi, describe, it, beforeEach } from 'vitest' +import { useTranslation } from 'react-i18next' + +import { renderWithProviders } from '/app/__testing-utils__' +import { i18n } from '/app/i18n' +import { + getLabwareDefinitionsFromCommands, + getLabwareName, + getLoadedLabware, + getLabwareDisplayLocation, +} from '/app/local-resources/labware' +import { getPipettingCommandText } from '../getPipettingCommandText' +import { getLabwareDefURI } from '@opentrons/shared-data' +import { getFinalLabwareLocation } from '../../getFinalLabwareLocation' +import { getWellRange } from '../../getWellRange' +import { getFinalMoveToAddressableAreaCmd } from '../../getFinalAddressableAreaCmd' +import { getAddressableAreaDisplayName } from '../../getAddressableAreaDisplayName' + +vi.mock('@opentrons/shared-data') +vi.mock('../../getFinalLabwareLocation') +vi.mock('../../getWellRange') +vi.mock('/app/local-resources/labware') +vi.mock('../../getFinalAddressableAreaCmd') +vi.mock('../../getAddressableAreaDisplayName') + +const baseCommandData = { + allRunDefs: {}, + robotType: 'OT-2', + commandTextData: { + commands: [], + labware: [], + modules: [], + pipettes: [{ id: 'pipette-1', pipetteName: 'p300_single' }], + }, +} as any + +function TestWrapper({ command }: { command: any }): JSX.Element { + const { t } = useTranslation('protocol_command_text') + const text = getPipettingCommandText({ + command, + ...baseCommandData, + t, + }) + + return
{text}
+} + +const render = (command: any) => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('getPipettingCommandText', () => { + beforeEach(() => { + vi.mocked(getLabwareDefURI).mockImplementation((def: any) => def.uri) + vi.mocked(getFinalLabwareLocation).mockReturnValue('slot-1' as any) + vi.mocked(getWellRange).mockReturnValue('A1') + vi.mocked(getLabwareDefinitionsFromCommands).mockReturnValue([ + { uri: 'tiprack-uri', parameters: { isTiprack: true } }, + { uri: 'plate-uri', parameters: { isTiprack: false } }, + ] as any) + vi.mocked(getLabwareName).mockReturnValue('Test Labware') + vi.mocked(getLoadedLabware).mockImplementation( + (labware, id) => + ({ + definitionUri: id === 'tiprack-id' ? 'tiprack-uri' : 'plate-uri', + } as any) + ) + vi.mocked(getLabwareDisplayLocation).mockReturnValue('Slot 1') + vi.mocked(getFinalMoveToAddressableAreaCmd).mockReturnValue({ + id: 'cmd-1', + commandType: 'moveToAddressableArea', + } as any) + vi.mocked(getAddressableAreaDisplayName).mockReturnValue('Fixed Trash') + }) + + it('should render aspirate command text correctly', () => { + const command = { + id: 'cmd-1', + commandType: 'aspirate', + params: { + labwareId: 'labware-1', + wellName: 'A1', + volume: 100, + flowRate: 150, + }, + } + + render(command) + screen.getByText( + /Aspirating 100 µL from well A1 of Test Labware in Slot 1 at 150 µL\/sec/ + ) + }) + + it('should render dispense command text correctly', () => { + const command = { + id: 'cmd-1', + commandType: 'dispense', + params: { + labwareId: 'labware-1', + wellName: 'A1', + volume: 100, + flowRate: 150, + }, + } + + render(command) + screen.getByText( + /Dispensing 100 µL into well A1 of Test Labware in Slot 1 at 150 µL\/sec/ + ) + }) + + it('should render dispense with push out command text correctly', () => { + const command = { + id: 'cmd-1', + commandType: 'dispense', + params: { + labwareId: 'labware-1', + wellName: 'A1', + volume: 100, + flowRate: 150, + pushOut: 10, + }, + } + + render(command) + screen.getByText( + /Dispensing 100 µL into well A1 of Test Labware in Slot 1 at 150 µL\/sec and pushing out 10 µL/ + ) + }) + + it('should render pickup tip command text correctly', () => { + const command = { + id: 'cmd-1', + commandType: 'pickUpTip', + params: { + labwareId: 'tiprack-id', + wellName: 'A1', + pipetteId: 'pipette-1', + }, + } + + render(command) + screen.getByText(/Picking up tip\(s\) from A1 of Test Labware in Slot 1/) + }) + + it('should render drop tip in tiprack command text correctly', () => { + const command = { + id: 'cmd-1', + commandType: 'dropTip', + params: { + labwareId: 'tiprack-id', + wellName: 'A1', + }, + } + + render(command) + screen.getByText(/Returning tip to A1 of Test Labware in Slot 1/) + }) + + it('should render drop tip in place command text correctly if there is an addressable area name', () => { + const command = { + id: 'cmd-1', + commandType: 'dropTipInPlace', + params: {}, + } + + render(command) + screen.getByText('Dropping tip in Fixed Trash') + }) + + it('should render drop tip in place command text correctly if there is not an addressable area name', () => { + const command = { + id: 'cmd-1', + commandType: 'dropTipInPlace', + params: {}, + } + + vi.mocked(getFinalMoveToAddressableAreaCmd).mockReturnValue(null) + + render(command) + screen.getByText('Dropping tip in place') + }) +}) diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getAbsorbanceReaderCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getAbsorbanceReaderCommandText.ts similarity index 97% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getAbsorbanceReaderCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getAbsorbanceReaderCommandText.ts index b9e7107b569..926ed749609 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getAbsorbanceReaderCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getAbsorbanceReaderCommandText.ts @@ -5,7 +5,7 @@ import type { AbsorbanceReaderReadRunTimeCommand, RunTimeCommand, } from '@opentrons/shared-data' -import type { HandlesCommands } from './types' +import type { HandlesCommands } from '../types' export type AbsorbanceCreateCommand = | AbsorbanceReaderOpenLidRunTimeCommand diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getCommentCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getCommentCommandText.ts similarity index 83% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getCommentCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getCommentCommandText.ts index 3a1b7ce7e8a..f50de82c96e 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getCommentCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getCommentCommandText.ts @@ -1,5 +1,5 @@ import type { CommentRunTimeCommand } from '@opentrons/shared-data/command' -import type { HandlesCommands } from './types' +import type { HandlesCommands } from '../types' export function getCommentCommandText({ command, diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getConfigureForVolumeCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getConfigureForVolumeCommandText.ts similarity index 92% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getConfigureForVolumeCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getConfigureForVolumeCommandText.ts index 1a4ee2e7c0e..fd9456b4cc3 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getConfigureForVolumeCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getConfigureForVolumeCommandText.ts @@ -1,7 +1,7 @@ import { getPipetteSpecsV2 } from '@opentrons/shared-data' import type { ConfigureForVolumeRunTimeCommand } from '@opentrons/shared-data/command' -import type { HandlesCommands } from './types' +import type { HandlesCommands } from '../types' export function getConfigureForVolumeCommandText({ command, diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getConfigureNozzleLayoutCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getConfigureNozzleLayoutCommandText.ts similarity index 95% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getConfigureNozzleLayoutCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getConfigureNozzleLayoutCommandText.ts index 04d476fadd1..8c9e12f3d5b 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getConfigureNozzleLayoutCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getConfigureNozzleLayoutCommandText.ts @@ -1,7 +1,7 @@ import { getPipetteSpecsV2 } from '@opentrons/shared-data' import type { ConfigureNozzleLayoutRunTimeCommand } from '@opentrons/shared-data' -import type { HandlesCommands } from './types' +import type { HandlesCommands } from '../types' export function getConfigureNozzleLayoutCommandText({ command, diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getCustomCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getCustomCommandText.ts similarity index 91% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getCustomCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getCustomCommandText.ts index da6d5a1d506..97d60249f7e 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getCustomCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getCustomCommandText.ts @@ -1,5 +1,5 @@ import type { CustomRunTimeCommand } from '@opentrons/shared-data/command' -import type { HandlesCommands } from './types' +import type { HandlesCommands } from '../types' export function getCustomCommandText({ command, diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getDelayCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getDelayCommandText.ts similarity index 91% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getDelayCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getDelayCommandText.ts index 8bb24d99661..f421a163b36 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getDelayCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getDelayCommandText.ts @@ -1,5 +1,5 @@ import type { DeprecatedDelayRunTimeCommand } from '@opentrons/shared-data/command' -import type { HandlesCommands } from './types' +import type { HandlesCommands } from '../types' export function getDelayCommandText({ command, diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getDirectTranslationCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getDirectTranslationCommandText.ts similarity index 97% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getDirectTranslationCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getDirectTranslationCommandText.ts index fd586136e90..92e969f402b 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getDirectTranslationCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getDirectTranslationCommandText.ts @@ -1,5 +1,5 @@ import type { RunTimeCommand } from '@opentrons/shared-data/command' -import type { HandlesCommands } from './types' +import type { HandlesCommands } from '../types' const SIMPLE_TRANSLATION_KEY_BY_COMMAND_TYPE: { [commandType in RunTimeCommand['commandType']]?: string diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getHSShakeSpeedCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getHSShakeSpeedCommandText.ts similarity index 87% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getHSShakeSpeedCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getHSShakeSpeedCommandText.ts index 3710e7f0930..157cde89212 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getHSShakeSpeedCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getHSShakeSpeedCommandText.ts @@ -1,5 +1,5 @@ import type { HeaterShakerSetAndWaitForShakeSpeedRunTimeCommand } from '@opentrons/shared-data/command' -import type { HandlesCommands } from './types' +import type { HandlesCommands } from '../types' export function getHSShakeSpeedCommandText({ command, diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getLiquidProbeCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getLiquidProbeCommandText.ts similarity index 92% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getLiquidProbeCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getLiquidProbeCommandText.ts index 171667012fe..014d30318eb 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getLiquidProbeCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getLiquidProbeCommandText.ts @@ -2,14 +2,14 @@ import { getLabwareName, getLabwareDisplayLocation, } from '/app/local-resources/labware' -import { getFinalLabwareLocation } from './getFinalLabwareLocation' +import { getFinalLabwareLocation } from '../getFinalLabwareLocation' import type { LiquidProbeRunTimeCommand, RunTimeCommand, TryLiquidProbeRunTimeCommand, } from '@opentrons/shared-data' -import type { HandlesCommands } from './types' +import type { HandlesCommands } from '../types' type LiquidProbeRunTimeCommands = | LiquidProbeRunTimeCommand diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getLoadCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getLoadCommandText.ts similarity index 97% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getLoadCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getLoadCommandText.ts index d8ab8736e08..cba135218c8 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getLoadCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getLoadCommandText.ts @@ -5,8 +5,8 @@ import { getPipetteSpecsV2, } from '@opentrons/shared-data' -import { getPipetteNameOnMount } from './getPipetteNameOnMount' -import { getLiquidDisplayName } from './getLiquidDisplayName' +import { getPipetteNameOnMount } from '../getPipetteNameOnMount' +import { getLiquidDisplayName } from '../getLiquidDisplayName' import { getLabwareName } from '/app/local-resources/labware' import { @@ -15,7 +15,7 @@ import { } from '/app/local-resources/modules' import type { LoadLabwareRunTimeCommand } from '@opentrons/shared-data' -import type { GetCommandText } from '..' +import type { GetCommandText } from '../..' export const getLoadCommandText = ({ command, diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveLabwareCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getMoveLabwareCommandText.ts similarity index 94% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveLabwareCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getMoveLabwareCommandText.ts index 67fe3d52aaf..29e90946bb4 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveLabwareCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getMoveLabwareCommandText.ts @@ -1,13 +1,13 @@ import { GRIPPER_WASTE_CHUTE_ADDRESSABLE_AREA } from '@opentrons/shared-data' -import { getFinalLabwareLocation } from './getFinalLabwareLocation' +import { getFinalLabwareLocation } from '../getFinalLabwareLocation' import { getLabwareName, getLabwareDisplayLocation, } from '/app/local-resources/labware' import type { MoveLabwareRunTimeCommand } from '@opentrons/shared-data' -import type { HandlesCommands } from './types' +import type { HandlesCommands } from '../types' export function getMoveLabwareCommandText({ command, diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveRelativeCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getMoveRelativeCommandText.ts similarity index 86% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveRelativeCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getMoveRelativeCommandText.ts index 7f3f8bf0aaa..d104e522fcd 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveRelativeCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getMoveRelativeCommandText.ts @@ -1,5 +1,5 @@ import type { MoveRelativeRunTimeCommand } from '@opentrons/shared-data/command' -import type { HandlesCommands } from './types' +import type { HandlesCommands } from '../types' export function getMoveRelativeCommandText({ command, diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveToAddressableAreaCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getMoveToAddressableAreaCommandText.ts similarity index 67% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveToAddressableAreaCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getMoveToAddressableAreaCommandText.ts index 749ef30f451..5dd4adb4ca4 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveToAddressableAreaCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getMoveToAddressableAreaCommandText.ts @@ -1,7 +1,7 @@ -import { getAddressableAreaDisplayName } from './getAddressableAreaDisplayName' +import { getAddressableAreaDisplayName } from '../getAddressableAreaDisplayName' import type { MoveToAddressableAreaRunTimeCommand } from '@opentrons/shared-data/command' -import type { HandlesCommands } from './types' +import type { HandlesCommands } from '../types' export function getMoveToAddressableAreaCommandText({ command, @@ -10,7 +10,7 @@ export function getMoveToAddressableAreaCommandText({ }: HandlesCommands): string { const addressableAreaDisplayName = commandTextData != null - ? getAddressableAreaDisplayName(commandTextData, command.id, t) + ? getAddressableAreaDisplayName(commandTextData.commands, command.id, t) : null return t('move_to_addressable_area', { diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveToAddressabelAreaForDropTipCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getMoveToAddressableAreaForDropTipCommandText.ts similarity index 69% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveToAddressabelAreaForDropTipCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getMoveToAddressableAreaForDropTipCommandText.ts index f7cc0f42e1f..29cd446a9ad 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveToAddressabelAreaForDropTipCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getMoveToAddressableAreaForDropTipCommandText.ts @@ -1,7 +1,7 @@ -import { getAddressableAreaDisplayName } from './getAddressableAreaDisplayName' +import { getAddressableAreaDisplayName } from '../getAddressableAreaDisplayName' import type { MoveToAddressableAreaForDropTipRunTimeCommand } from '@opentrons/shared-data/command' -import type { HandlesCommands } from './types' +import type { HandlesCommands } from '../types' export function getMoveToAddressableAreaForDropTipCommandText({ command, @@ -10,7 +10,7 @@ export function getMoveToAddressableAreaForDropTipCommandText({ }: HandlesCommands): string { const addressableAreaDisplayName = commandTextData != null - ? getAddressableAreaDisplayName(commandTextData, command.id, t) + ? getAddressableAreaDisplayName(commandTextData.commands, command.id, t) : null return t('move_to_addressable_area_drop_tip', { diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveToCoordinatesCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getMoveToCoordinatesCommandText.ts similarity index 86% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveToCoordinatesCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getMoveToCoordinatesCommandText.ts index a3dc5ace9fe..fde6e5aff22 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveToCoordinatesCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getMoveToCoordinatesCommandText.ts @@ -1,5 +1,5 @@ import type { MoveToCoordinatesRunTimeCommand } from '@opentrons/shared-data/command' -import type { HandlesCommands } from './types' +import type { HandlesCommands } from '../types' export function getMoveToCoordinatesCommandText({ command, diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveToSlotCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getMoveToSlotCommandText.ts similarity index 85% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveToSlotCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getMoveToSlotCommandText.ts index b66f5d78513..75904b7cb43 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveToSlotCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getMoveToSlotCommandText.ts @@ -1,5 +1,5 @@ import type { MoveToSlotRunTimeCommand } from '@opentrons/shared-data/command' -import type { HandlesCommands } from './types' +import type { HandlesCommands } from '../types' export function getMoveToSlotCommandText({ command, diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveToWellCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getMoveToWellCommandText.ts similarity index 91% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveToWellCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getMoveToWellCommandText.ts index e3c8d6223be..50bdba0a52f 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveToWellCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getMoveToWellCommandText.ts @@ -3,10 +3,10 @@ import { getLabwareDisplayLocation, } from '/app/local-resources/labware' -import { getFinalLabwareLocation } from './getFinalLabwareLocation' +import { getFinalLabwareLocation } from '../getFinalLabwareLocation' import type { MoveToWellRunTimeCommand } from '@opentrons/shared-data/command' -import type { HandlesCommands } from './types' +import type { HandlesCommands } from '../types' export function getMoveToWellCommandText({ command, diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getPipettingCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getPipettingCommandText.ts similarity index 87% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getPipettingCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getPipettingCommandText.ts index 34ad5eae3a3..2a0d87762b2 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getPipettingCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getPipettingCommandText.ts @@ -1,7 +1,7 @@ import { getLabwareDefURI } from '@opentrons/shared-data' -import { getFinalLabwareLocation } from './getFinalLabwareLocation' -import { getWellRange } from './getWellRange' +import { getFinalLabwareLocation } from '../getFinalLabwareLocation' +import { getWellRange } from '../getWellRange' import { getLabwareDefinitionsFromCommands, @@ -11,7 +11,9 @@ import { } from '/app/local-resources/labware' import type { PipetteName, RunTimeCommand } from '@opentrons/shared-data' -import type { GetCommandText } from '..' +import type { GetCommandText } from '../..' +import { getFinalMoveToAddressableAreaCmd } from '/app/local-resources/commands/hooks/useCommandTextString/utils/getFinalAddressableAreaCmd' +import { getAddressableAreaDisplayName } from '/app/local-resources/commands/hooks/useCommandTextString/utils/getAddressableAreaDisplayName' export const getPipettingCommandText = ({ command, @@ -186,7 +188,14 @@ export const getPipettingCommandText = ({ }) } case 'dropTipInPlace': { - return t('drop_tip_in_place') + const cmd = getFinalMoveToAddressableAreaCmd(allPreviousCommands ?? []) + + if (cmd != null) { + const displayName = getAddressableAreaDisplayName([cmd], cmd?.id, t) + return t('dropping_tip_in_trash', { trash: displayName }) + } else { + return t('drop_tip_in_place') + } } case 'dispenseInPlace': { const { volume, flowRate } = command.params @@ -200,6 +209,10 @@ export const getPipettingCommandText = ({ const { flowRate, volume } = command.params return t('aspirate_in_place', { volume, flow_rate: flowRate }) } + case 'airGapInPlace': { + const { volume } = command.params + return t('air_gap_in_place', { volume }) + } default: { console.warn( 'PipettingCommandText encountered a command with an unrecognized commandType: ', diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getPrepareToAspirateCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getPrepareToAspirateCommandText.ts similarity index 92% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getPrepareToAspirateCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getPrepareToAspirateCommandText.ts index 13d32b6b7d6..f0d68c3fd4d 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getPrepareToAspirateCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getPrepareToAspirateCommandText.ts @@ -1,7 +1,7 @@ import { getPipetteSpecsV2 } from '@opentrons/shared-data' import type { PrepareToAspirateRunTimeCommand } from '@opentrons/shared-data/command' -import type { HandlesCommands } from './types' +import type { HandlesCommands } from '../types' export function getPrepareToAspirateCommandText({ command, diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getRailLightsCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getRailLightsCommandText.ts similarity index 89% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getRailLightsCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getRailLightsCommandText.ts index b731d3ec392..8fe4c18aa4b 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getRailLightsCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getRailLightsCommandText.ts @@ -1,5 +1,5 @@ import type { RunTimeCommand } from '@opentrons/shared-data/command' -import type { HandlesCommands } from './types' +import type { HandlesCommands } from '../types' type HandledCommands = Extract diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getTCRunExtendedProfileCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getTCRunExtendedProfileCommandText.ts similarity index 97% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getTCRunExtendedProfileCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getTCRunExtendedProfileCommandText.ts index 4c4acde0b6f..2d09f07f28c 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getTCRunExtendedProfileCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getTCRunExtendedProfileCommandText.ts @@ -4,8 +4,8 @@ import type { TCProfileCycle, AtomicProfileStep, } from '@opentrons/shared-data/command' -import type { GetTCRunExtendedProfileCommandTextResult } from '..' -import type { HandlesCommands } from './types' +import type { GetTCRunExtendedProfileCommandTextResult } from '../..' +import type { HandlesCommands } from '../types' export interface TCProfileStepText { kind: 'step' diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getTCRunProfileCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getTCRunProfileCommandText.ts similarity index 87% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getTCRunProfileCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getTCRunProfileCommandText.ts index cbc56b02635..a98ce9cfa4a 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getTCRunProfileCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getTCRunProfileCommandText.ts @@ -1,7 +1,7 @@ import { formatDurationLabeled } from '/app/transformations/commands' import type { TCRunProfileRunTimeCommand } from '@opentrons/shared-data/command' -import type { GetTCRunProfileCommandTextResult } from '..' -import type { HandlesCommands } from './types' +import type { GetTCRunProfileCommandTextResult } from '../..' +import type { HandlesCommands } from '../types' export function getTCRunProfileCommandText({ command, diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getTemperatureCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getTemperatureCommandText.ts similarity index 97% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getTemperatureCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getTemperatureCommandText.ts index ee60a76c289..1b5a03745c3 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getTemperatureCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getTemperatureCommandText.ts @@ -6,7 +6,7 @@ import type { HeaterShakerSetTargetTemperatureCreateCommand, RunTimeCommand, } from '@opentrons/shared-data' -import type { HandlesCommands } from './types' +import type { HandlesCommands } from '../types' export type TemperatureCreateCommand = | TemperatureModuleSetTargetTemperatureCreateCommand diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getUnknownCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getUnknownCommandText.ts similarity index 71% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getUnknownCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getUnknownCommandText.ts index 4f2346c7c01..17b69b84c6a 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getUnknownCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getUnknownCommandText.ts @@ -1,4 +1,4 @@ -import type { GetCommandText } from '..' +import type { GetCommandText } from '../..' export function getUnknownCommandText({ command }: GetCommandText): string { return JSON.stringify(command) diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getWaitForDurationCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getWaitForDurationCommandText.ts similarity index 87% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getWaitForDurationCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getWaitForDurationCommandText.ts index d3b3136be1f..18ccc55540a 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getWaitForDurationCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getWaitForDurationCommandText.ts @@ -1,5 +1,5 @@ import type { WaitForDurationRunTimeCommand } from '@opentrons/shared-data/command' -import type { HandlesCommands } from './types' +import type { HandlesCommands } from '../types' export function getWaitForDurationCommandText({ command, diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getWaitForResumeCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getWaitForResumeCommandText.ts similarity index 87% rename from app/src/local-resources/commands/hooks/useCommandTextString/utils/getWaitForResumeCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getWaitForResumeCommandText.ts index f1c7b7fcef6..a591504b244 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getWaitForResumeCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getWaitForResumeCommandText.ts @@ -1,5 +1,5 @@ import type { WaitForResumeRunTimeCommand } from '@opentrons/shared-data/command' -import type { HandlesCommands } from './types' +import type { HandlesCommands } from '../types' export function getWaitForResumeCommandText({ command, diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/index.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/index.ts new file mode 100644 index 00000000000..c2926c880c6 --- /dev/null +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/index.ts @@ -0,0 +1,26 @@ +export * from './getLoadCommandText' +export * from './getTemperatureCommandText' +export * from './getTCRunProfileCommandText' +export * from './getTCRunExtendedProfileCommandText' +export * from './getHSShakeSpeedCommandText' +export * from './getMoveToSlotCommandText' +export * from './getMoveRelativeCommandText' +export * from './getMoveToCoordinatesCommandText' +export * from './getMoveToWellCommandText' +export * from './getMoveLabwareCommandText' +export * from './getConfigureForVolumeCommandText' +export * from './getConfigureNozzleLayoutCommandText' +export * from './getPrepareToAspirateCommandText' +export * from './getMoveToAddressableAreaCommandText' +export * from './getMoveToAddressableAreaForDropTipCommandText' +export * from './getDirectTranslationCommandText' +export * from './getWaitForDurationCommandText' +export * from './getWaitForResumeCommandText' +export * from './getDelayCommandText' +export * from './getCommentCommandText' +export * from './getCustomCommandText' +export * from './getUnknownCommandText' +export * from './getPipettingCommandText' +export * from './getLiquidProbeCommandText' +export * from './getRailLightsCommandText' +export * from './getAbsorbanceReaderCommandText' diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getAddressableAreaDisplayName.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getAddressableAreaDisplayName.ts index 20d7c6cca07..c3160de6223 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getAddressableAreaDisplayName.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getAddressableAreaDisplayName.ts @@ -1,17 +1,16 @@ import type { AddressableAreaName, MoveToAddressableAreaParams, + RunTimeCommand, } from '@opentrons/shared-data' import type { TFunction } from 'i18next' -import type { CommandTextData } from '/app/local-resources/commands' - export function getAddressableAreaDisplayName( - commandTextData: CommandTextData, + commands: RunTimeCommand[] | undefined, commandId: string, t: TFunction ): string { - const addressableAreaCommand = (commandTextData?.commands ?? []).find( + const addressableAreaCommand = (commands ?? []).find( command => command.id === commandId ) @@ -30,8 +29,11 @@ export function getAddressableAreaDisplayName( return t('trash_bin_in_slot', { slot_name: slotName }) } else if (addressableAreaName.includes('WasteChute')) { return t('waste_chute') - } else if (addressableAreaName === 'fixedTrash') return t('fixed_trash') - else return addressableAreaName + } else if (addressableAreaName === 'fixedTrash') { + return t('fixed_trash') + } else { + return addressableAreaName + } } const getMovableTrashSlot = ( diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getFinalAddressableAreaCmd.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getFinalAddressableAreaCmd.ts new file mode 100644 index 00000000000..3471073a8b9 --- /dev/null +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getFinalAddressableAreaCmd.ts @@ -0,0 +1,21 @@ +import { findLastAt } from '/app/local-resources/commands/hooks/useCommandTextString/utils/helpers' + +import type { RunTimeCommand } from '@opentrons/shared-data' +/** + * given a list of commands and a labwareId, calculate the resulting location + * of the corresponding labware after all given commands are executed + * @param commands list of commands to search within + * @returns The last command related to addressable areas. + */ +export function getFinalMoveToAddressableAreaCmd( + commands: RunTimeCommand[] +): RunTimeCommand | null { + const [cmd] = findLastAt( + commands, + (c: RunTimeCommand) => + c.commandType === 'moveToAddressableArea' || + c.commandType === 'moveToAddressableAreaForDropTip' + ) + + return cmd ?? null +} diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getFinalLabwareLocation.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getFinalLabwareLocation.ts index 80cd4e26a4e..7e73770cbbd 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getFinalLabwareLocation.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getFinalLabwareLocation.ts @@ -1,4 +1,11 @@ -import type { LabwareLocation, RunTimeCommand } from '@opentrons/shared-data' +import { findLastAt } from './helpers' + +import type { + LabwareLocation, + RunTimeCommand, + LoadLabwareRunTimeCommand, + MoveLabwareRunTimeCommand, +} from '@opentrons/shared-data' /** * given a list of commands and a labwareId, calculate the resulting location @@ -11,15 +18,22 @@ export function getFinalLabwareLocation( labwareId: string, commands: RunTimeCommand[] ): LabwareLocation | null { - for (const c of commands.reverse()) { - if (c.commandType === 'loadLabware' && c.result?.labwareId === labwareId) { - return c.params.location - } else if ( - c.commandType === 'moveLabware' && - c.params.labwareId === labwareId - ) { - return c.params.newLocation - } + const [lastMove, lastMoveIndex] = findLastAt( + commands, + (c: RunTimeCommand): c is MoveLabwareRunTimeCommand => + c.commandType === 'moveLabware' && c.params.labwareId === labwareId + ) + + const [lastLoad, lastLoadIndex] = findLastAt( + commands, + (c: RunTimeCommand): c is LoadLabwareRunTimeCommand => + c.commandType === 'loadLabware' && c.result?.labwareId === labwareId + ) + if (lastMoveIndex > lastLoadIndex) { + return lastMove?.params?.newLocation ?? null + } else if (lastLoadIndex > lastMoveIndex) { + return lastLoad?.params?.location ?? null + } else { + return null } - return null } diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getWellRange.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getWellRange.ts index a0700357413..791ddf2f4c3 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getWellRange.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getWellRange.ts @@ -1,5 +1,42 @@ import { getPipetteNameSpecs } from '@opentrons/shared-data' -import type { PipetteName, RunTimeCommand } from '@opentrons/shared-data' +import type { + PipetteName, + RunTimeCommand, + ConfigureNozzleLayoutRunTimeCommand, +} from '@opentrons/shared-data' + +const usedChannelsFromCommand = ( + command: ConfigureNozzleLayoutRunTimeCommand | undefined, + defaultChannels: number +): number => + command?.params?.configurationParams?.style === 'SINGLE' + ? 1 + : command?.params?.configurationParams?.style === 'COLUMN' + ? 8 + : defaultChannels + +const usedChannelsForPipette = ( + pipetteId: string, + commands: RunTimeCommand[], + defaultChannels: number +): number => + usedChannelsFromCommand( + commands.findLast( + (c: RunTimeCommand): c is ConfigureNozzleLayoutRunTimeCommand => + c.commandType === 'configureNozzleLayout' && + c.params?.pipetteId === pipetteId + ), + defaultChannels + ) + +const usedChannels = ( + pipetteId: string, + commands: RunTimeCommand[], + pipetteChannels: number +): number => + pipetteChannels === 96 + ? usedChannelsForPipette(pipetteId, commands, pipetteChannels) + : pipetteChannels /** * @param pipetteName name of pipette being used @@ -16,26 +53,10 @@ export function getWellRange( const pipetteChannels = pipetteName ? getPipetteNameSpecs(pipetteName)?.channels ?? 1 : 1 - let usedChannels = pipetteChannels - if (pipetteChannels === 96) { - for (const c of commands.reverse()) { - if ( - c.commandType === 'configureNozzleLayout' && - c.params?.pipetteId === pipetteId - ) { - // TODO(sb, 11/9/23): add support for quadrant and row configurations when needed - if (c.params.configurationParams.style === 'SINGLE') { - usedChannels = 1 - } else if (c.params.configurationParams.style === 'COLUMN') { - usedChannels = 8 - } - break - } - } - } - if (usedChannels === 96) { + const channelCount = usedChannels(pipetteId, commands, pipetteChannels) + if (channelCount === 96) { return 'A1 - H12' - } else if (usedChannels === 8) { + } else if (channelCount === 8) { const column = wellName.substr(1) return `A${column} - H${column}` } diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/helpers.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/helpers.ts new file mode 100644 index 00000000000..5d28c41b82c --- /dev/null +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/helpers.ts @@ -0,0 +1,15 @@ +export const findLastAt = ( + arr: readonly T[], + pred: ((el: T) => boolean) | ((el: T) => el is U) +): [U, number] | [undefined, -1] => { + let arrayLoc = -1 + const lastEl = arr.findLast((el: T, idx: number): el is U => { + arrayLoc = idx + return pred(el) + }) + if (lastEl === undefined) { + return [undefined, -1] + } else { + return [lastEl, arrayLoc] + } +} diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/index.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/index.ts index 76659ca1222..44f99055f08 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/index.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/index.ts @@ -1,26 +1,2 @@ -export { getLoadCommandText } from './getLoadCommandText' -export { getTemperatureCommandText } from './getTemperatureCommandText' -export { getTCRunProfileCommandText } from './getTCRunProfileCommandText' -export { getTCRunExtendedProfileCommandText } from './getTCRunExtendedProfileCommandText' -export { getHSShakeSpeedCommandText } from './getHSShakeSpeedCommandText' -export { getMoveToSlotCommandText } from './getMoveToSlotCommandText' -export { getMoveRelativeCommandText } from './getMoveRelativeCommandText' -export { getMoveToCoordinatesCommandText } from './getMoveToCoordinatesCommandText' -export { getMoveToWellCommandText } from './getMoveToWellCommandText' -export { getMoveLabwareCommandText } from './getMoveLabwareCommandText' -export { getConfigureForVolumeCommandText } from './getConfigureForVolumeCommandText' -export { getConfigureNozzleLayoutCommandText } from './getConfigureNozzleLayoutCommandText' -export { getPrepareToAspirateCommandText } from './getPrepareToAspirateCommandText' -export { getMoveToAddressableAreaCommandText } from './getMoveToAddressableAreaCommandText' -export { getMoveToAddressableAreaForDropTipCommandText } from './getMoveToAddressabelAreaForDropTipCommandText' -export { getDirectTranslationCommandText } from './getDirectTranslationCommandText' -export { getWaitForDurationCommandText } from './getWaitForDurationCommandText' -export { getWaitForResumeCommandText } from './getWaitForResumeCommandText' -export { getDelayCommandText } from './getDelayCommandText' -export { getCommentCommandText } from './getCommentCommandText' -export { getCustomCommandText } from './getCustomCommandText' -export { getUnknownCommandText } from './getUnknownCommandText' -export { getPipettingCommandText } from './getPipettingCommandText' -export { getLiquidProbeCommandText } from './getLiquidProbeCommandText' -export { getRailLightsCommandText } from './getRailLightsCommandText' -export { getAbsorbanceReaderCommandText } from './getAbsorbanceReaderCommandText' +export * from './commandText' +export * from './types' diff --git a/app/src/local-resources/instruments/hooks.ts b/app/src/local-resources/instruments/hooks.ts deleted file mode 100644 index 713dd6f1c83..00000000000 --- a/app/src/local-resources/instruments/hooks.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { - getGripperDisplayName, - getPipetteModelSpecs, - getPipetteNameSpecs, - getPipetteSpecsV2, - GRIPPER_MODELS, -} from '@opentrons/shared-data' -import { useIsOEMMode } from '/app/resources/robot-settings/hooks' - -import type { - GripperModel, - PipetteModel, - PipetteModelSpecs, - PipetteName, - PipetteNameSpecs, - PipetteV2Specs, -} from '@opentrons/shared-data' - -export function usePipetteNameSpecs( - name: PipetteName -): PipetteNameSpecs | null { - const isOEMMode = useIsOEMMode() - const pipetteNameSpecs = getPipetteNameSpecs(name) - - if (pipetteNameSpecs == null) return null - - const brandedDisplayName = pipetteNameSpecs.displayName - const anonymizedDisplayName = pipetteNameSpecs.displayName.replace( - 'Flex ', - '' - ) - - const displayName = isOEMMode ? anonymizedDisplayName : brandedDisplayName - - return { ...pipetteNameSpecs, displayName } -} - -export function usePipetteModelSpecs( - model: PipetteModel -): PipetteModelSpecs | null { - const modelSpecificFields = getPipetteModelSpecs(model) - const pipetteNameSpecs = usePipetteNameSpecs( - modelSpecificFields?.name as PipetteName - ) - - if (modelSpecificFields == null || pipetteNameSpecs == null) return null - - return { ...modelSpecificFields, displayName: pipetteNameSpecs.displayName } -} - -export function usePipetteSpecsV2( - name?: PipetteName | PipetteModel -): PipetteV2Specs | null { - const isOEMMode = useIsOEMMode() - const pipetteSpecs = getPipetteSpecsV2(name) - - if (pipetteSpecs == null) return null - - const brandedDisplayName = pipetteSpecs.displayName - const anonymizedDisplayName = pipetteSpecs.displayName.replace('Flex ', '') - - const displayName = isOEMMode ? anonymizedDisplayName : brandedDisplayName - - return { ...pipetteSpecs, displayName } -} - -export function useGripperDisplayName(gripperModel: GripperModel): string { - const isOEMMode = useIsOEMMode() - - let brandedDisplayName = '' - - // check to only call display name helper for a gripper model - if (GRIPPER_MODELS.includes(gripperModel)) { - brandedDisplayName = getGripperDisplayName(gripperModel) - } - - const anonymizedDisplayName = brandedDisplayName.replace('Flex ', '') - - return isOEMMode ? anonymizedDisplayName : brandedDisplayName -} diff --git a/app/src/local-resources/instruments/hooks/index.ts b/app/src/local-resources/instruments/hooks/index.ts new file mode 100644 index 00000000000..6cfd0af2293 --- /dev/null +++ b/app/src/local-resources/instruments/hooks/index.ts @@ -0,0 +1,5 @@ +export * from './useGripperDisplayName' +export * from './useHomePipettes' +export * from './usePipetteModelSpecs' +export * from './usePipetteNameSpecs' +export * from './usePipetteSpecsv2' diff --git a/app/src/local-resources/instruments/hooks/useGripperDisplayName.ts b/app/src/local-resources/instruments/hooks/useGripperDisplayName.ts new file mode 100644 index 00000000000..fd1b8262a79 --- /dev/null +++ b/app/src/local-resources/instruments/hooks/useGripperDisplayName.ts @@ -0,0 +1,19 @@ +import { getGripperDisplayName, GRIPPER_MODELS } from '@opentrons/shared-data' +import { useIsOEMMode } from '/app/resources/robot-settings' + +import type { GripperModel } from '@opentrons/shared-data' + +export function useGripperDisplayName(gripperModel: GripperModel): string { + const isOEMMode = useIsOEMMode() + + let brandedDisplayName = '' + + // check to only call display name helper for a gripper model + if (GRIPPER_MODELS.includes(gripperModel)) { + brandedDisplayName = getGripperDisplayName(gripperModel) + } + + const anonymizedDisplayName = brandedDisplayName.replace('Flex ', '') + + return isOEMMode ? anonymizedDisplayName : brandedDisplayName +} diff --git a/app/src/organisms/DropTipWizardFlows/hooks/useHomePipettes.ts b/app/src/local-resources/instruments/hooks/useHomePipettes.ts similarity index 90% rename from app/src/organisms/DropTipWizardFlows/hooks/useHomePipettes.ts rename to app/src/local-resources/instruments/hooks/useHomePipettes.ts index c0e58ef5bb5..da139c14651 100644 --- a/app/src/organisms/DropTipWizardFlows/hooks/useHomePipettes.ts +++ b/app/src/local-resources/instruments/hooks/useHomePipettes.ts @@ -1,12 +1,13 @@ import { useRobotControlCommands } from '/app/resources/maintenance_runs' import type { CreateCommand } from '@opentrons/shared-data' + import type { UseRobotControlCommandsProps, UseRobotControlCommandsResult, } from '/app/resources/maintenance_runs' -interface UseHomePipettesResult { +export interface UseHomePipettesResult { isHoming: UseRobotControlCommandsResult['isExecuting'] homePipettes: UseRobotControlCommandsResult['executeCommands'] } @@ -15,7 +16,7 @@ export type UseHomePipettesProps = Pick< UseRobotControlCommandsProps, 'pipetteInfo' | 'onSettled' > -// TODO(jh, 09-12-24): Find a better place for this hook to live. + // Home pipettes except for plungers. export function useHomePipettes( props: UseHomePipettesProps diff --git a/app/src/local-resources/instruments/hooks/usePipetteModelSpecs.ts b/app/src/local-resources/instruments/hooks/usePipetteModelSpecs.ts new file mode 100644 index 00000000000..afbc2f205fa --- /dev/null +++ b/app/src/local-resources/instruments/hooks/usePipetteModelSpecs.ts @@ -0,0 +1,24 @@ +import { getPipetteModelSpecs } from '@opentrons/shared-data' + +import { usePipetteNameSpecs } from './usePipetteNameSpecs' + +import type { + PipetteModel, + PipetteModelSpecs, + PipetteName, +} from '@opentrons/shared-data' + +export function usePipetteModelSpecs( + model: PipetteModel +): PipetteModelSpecs | null { + const modelSpecificFields = getPipetteModelSpecs(model) + const pipetteNameSpecs = usePipetteNameSpecs( + modelSpecificFields?.name as PipetteName + ) + + if (modelSpecificFields == null || pipetteNameSpecs == null) { + return null + } + + return { ...modelSpecificFields, displayName: pipetteNameSpecs.displayName } +} diff --git a/app/src/local-resources/instruments/hooks/usePipetteNameSpecs.ts b/app/src/local-resources/instruments/hooks/usePipetteNameSpecs.ts new file mode 100644 index 00000000000..85a29b2fef7 --- /dev/null +++ b/app/src/local-resources/instruments/hooks/usePipetteNameSpecs.ts @@ -0,0 +1,26 @@ +import { getPipetteNameSpecs } from '@opentrons/shared-data' + +import { useIsOEMMode } from '/app/resources/robot-settings' + +import type { PipetteName, PipetteNameSpecs } from '@opentrons/shared-data' + +export function usePipetteNameSpecs( + name: PipetteName +): PipetteNameSpecs | null { + const isOEMMode = useIsOEMMode() + const pipetteNameSpecs = getPipetteNameSpecs(name) + + if (pipetteNameSpecs == null) { + return null + } + + const brandedDisplayName = pipetteNameSpecs.displayName + const anonymizedDisplayName = pipetteNameSpecs.displayName.replace( + 'Flex ', + '' + ) + + const displayName = isOEMMode ? anonymizedDisplayName : brandedDisplayName + + return { ...pipetteNameSpecs, displayName } +} diff --git a/app/src/local-resources/instruments/hooks/usePipetteSpecsv2.ts b/app/src/local-resources/instruments/hooks/usePipetteSpecsv2.ts new file mode 100644 index 00000000000..951c1d857f1 --- /dev/null +++ b/app/src/local-resources/instruments/hooks/usePipetteSpecsv2.ts @@ -0,0 +1,27 @@ +import { getPipetteSpecsV2 } from '@opentrons/shared-data' + +import { useIsOEMMode } from '/app/resources/robot-settings' + +import type { + PipetteModel, + PipetteName, + PipetteV2Specs, +} from '@opentrons/shared-data' + +export function usePipetteSpecsV2( + name?: PipetteName | PipetteModel +): PipetteV2Specs | null { + const isOEMMode = useIsOEMMode() + const pipetteSpecs = getPipetteSpecsV2(name) + + if (pipetteSpecs == null) { + return null + } + + const brandedDisplayName = pipetteSpecs.displayName + const anonymizedDisplayName = pipetteSpecs.displayName.replace('Flex ', '') + + const displayName = isOEMMode ? anonymizedDisplayName : brandedDisplayName + + return { ...pipetteSpecs, displayName } +} diff --git a/app/src/local-resources/labware/types.ts b/app/src/local-resources/labware/types.ts index da55c9d7004..3ab026b9603 100644 --- a/app/src/local-resources/labware/types.ts +++ b/app/src/local-resources/labware/types.ts @@ -21,6 +21,7 @@ export type LabwareFilter = | 'aluminumBlock' | 'customLabware' | 'adapter' + | 'lid' export type LabwareSort = 'alphabetical' | 'reverse' diff --git a/app/src/local-resources/labware/utils/__tests__/getLabwareDisplayLocation.test.tsx b/app/src/local-resources/labware/utils/__tests__/getLabwareDisplayLocation.test.tsx new file mode 100644 index 00000000000..22e02478ded --- /dev/null +++ b/app/src/local-resources/labware/utils/__tests__/getLabwareDisplayLocation.test.tsx @@ -0,0 +1,173 @@ +import { describe, it, expect, vi } from 'vitest' +import { screen } from '@testing-library/react' +import { useTranslation } from 'react-i18next' + +import { + FLEX_ROBOT_TYPE, + getModuleDisplayName, + getModuleType, + getOccludedSlotCountForModule, + getLabwareDefURI, + getLabwareDisplayName, +} from '@opentrons/shared-data' + +import { renderWithProviders } from '/app/__testing-utils__' +import { i18n } from '/app/i18n' +import { getLabwareDisplayLocation } from '/app/local-resources/labware' +import { + getModuleModel, + getModuleDisplayLocation, +} from '/app/local-resources/modules' + +import type { ComponentProps } from 'react' +import type { LabwareLocation } from '@opentrons/shared-data' + +vi.mock('@opentrons/shared-data', async () => { + const actual = await vi.importActual('@opentrons/shared-data') + return { + ...actual, + getModuleDisplayName: vi.fn(), + getModuleType: vi.fn(), + getOccludedSlotCountForModule: vi.fn(), + getLabwareDefURI: vi.fn(), + getLabwareDisplayName: vi.fn(), + } +}) + +vi.mock('/app/local-resources/modules', () => ({ + getModuleModel: vi.fn(), + getModuleDisplayLocation: vi.fn(), +})) + +const TestWrapper = ({ + location, + params, +}: { + location: LabwareLocation | null + params: any +}) => { + const { t } = useTranslation('protocol_command_text') + const displayLocation = getLabwareDisplayLocation({ ...params, location, t }) + return
{displayLocation}
+} + +const render = (props: ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +describe('getLabwareDisplayLocation with translations', () => { + const defaultParams = { + loadedLabwares: [], + loadedModules: [], + robotType: FLEX_ROBOT_TYPE, + allRunDefs: [], + } + + it('should return an empty string for null location', () => { + render({ location: null, params: defaultParams }) + expect(screen.queryByText(/.+/)).toBeNull() + }) + + it('should return "off deck" for offDeck location', () => { + render({ location: 'offDeck', params: defaultParams }) + + screen.getByText('off deck') + }) + + it('should return a slot name for slot location', () => { + render({ location: { slotName: 'A1' }, params: defaultParams }) + + screen.getByText('Slot A1') + }) + + it('should return an addressable area name for an addressable area location', () => { + render({ location: { addressableAreaName: 'B2' }, params: defaultParams }) + + screen.getByText('Slot B2') + }) + + it('should return a module location for a module location', () => { + const mockModuleModel = 'temperatureModuleV2' + vi.mocked(getModuleModel).mockReturnValue(mockModuleModel) + vi.mocked(getModuleDisplayLocation).mockReturnValue('3') + vi.mocked(getModuleDisplayName).mockReturnValue('Temperature Module') + vi.mocked(getModuleType).mockReturnValue('temperatureModuleType') + vi.mocked(getOccludedSlotCountForModule).mockReturnValue(1) + + render({ location: { moduleId: 'temp123' }, params: defaultParams }) + + screen.getByText('Temperature Module in Slot 3') + }) + + it('should return an adapter location for an adapter location', () => { + const mockLoadedLabwares = [ + { + id: 'adapter123', + definitionUri: 'adapter-uri', + location: { slotName: 'D1' }, + }, + ] + const mockAllRunDefs = [ + { uri: 'adapter-uri', metadata: { displayName: 'Mock Adapter' } }, + ] + vi.mocked(getLabwareDefURI).mockReturnValue('adapter-uri') + vi.mocked(getLabwareDisplayName).mockReturnValue('Mock Adapter') + + render({ + location: { labwareId: 'adapter123' }, + params: { + ...defaultParams, + loadedLabwares: mockLoadedLabwares, + allRunDefs: mockAllRunDefs, + detailLevel: 'full', + }, + }) + + screen.getByText('Mock Adapter in D1') + }) + + it('should return a slot-only location when detailLevel is "slot-only"', () => { + render({ + location: { slotName: 'C1' }, + params: { ...defaultParams, detailLevel: 'slot-only' }, + }) + + screen.getByText('Slot C1') + }) + + it('should handle an adapter on module location when the detail level is full', () => { + const mockLoadedLabwares = [ + { + id: 'adapter123', + definitionUri: 'adapter-uri', + location: { moduleId: 'temp123' }, + }, + ] + const mockLoadedModules = [{ id: 'temp123', model: 'temperatureModuleV2' }] + const mockAllRunDefs = [ + { uri: 'adapter-uri', metadata: { displayName: 'Mock Adapter' } }, + ] + + vi.mocked(getLabwareDefURI).mockReturnValue('adapter-uri') + vi.mocked(getLabwareDisplayName).mockReturnValue('Mock Adapter') + vi.mocked(getModuleDisplayLocation).mockReturnValue('2') + vi.mocked(getModuleDisplayName).mockReturnValue('Temperature Module') + vi.mocked(getModuleType).mockReturnValue('temperatureModuleType') + vi.mocked(getOccludedSlotCountForModule).mockReturnValue(1) + + render({ + location: { labwareId: 'adapter123' }, + params: { + ...defaultParams, + loadedLabwares: mockLoadedLabwares, + loadedModules: mockLoadedModules, + allRunDefs: mockAllRunDefs, + detailLevel: 'full', + }, + }) + + screen.getByText('Mock Adapter on Temperature Module in 2') + }) +}) diff --git a/app/src/local-resources/labware/utils/getLabwareDisplayLocation.ts b/app/src/local-resources/labware/utils/getLabwareDisplayLocation.ts index 3f56eb165b1..d70e6d19d42 100644 --- a/app/src/local-resources/labware/utils/getLabwareDisplayLocation.ts +++ b/app/src/local-resources/labware/utils/getLabwareDisplayLocation.ts @@ -13,37 +13,57 @@ import { import type { TFunction } from 'i18next' import type { - RobotType, - LabwareLocation, LabwareDefinition2, + LabwareLocation, + RobotType, } from '@opentrons/shared-data' -import type { LoadedModules } from '/app/local-resources/modules' import type { LoadedLabwares } from '/app/local-resources/labware' +import type { LoadedModules } from '/app/local-resources/modules' -export interface UseLabwareDisplayLocationProps { +interface LabwareDisplayLocationBaseParams { location: LabwareLocation | null loadedModules: LoadedModules loadedLabwares: LoadedLabwares - allRunDefs: LabwareDefinition2[] robotType: RobotType t: TFunction isOnDevice?: boolean } -export function getLabwareDisplayLocation({ - loadedLabwares, - loadedModules, - allRunDefs, - location, - robotType, - t, - isOnDevice = false, -}: UseLabwareDisplayLocationProps): string { +export interface LabwareDisplayLocationSlotOnly + extends LabwareDisplayLocationBaseParams { + detailLevel: 'slot-only' +} + +export interface LabwareDisplayLocationFull + extends LabwareDisplayLocationBaseParams { + detailLevel?: 'full' + allRunDefs: LabwareDefinition2[] +} + +export type LabwareDisplayLocationParams = + | LabwareDisplayLocationSlotOnly + | LabwareDisplayLocationFull + +// detailLevel applies to nested labware. If 'full', return copy that includes the actual peripheral that nests the +// labware, ex, "in module XYZ in slot C1". +// If 'slot-only', return only the slot name, ex "in slot C1". +export function getLabwareDisplayLocation( + params: LabwareDisplayLocationParams +): string { + const { + loadedLabwares, + loadedModules, + location, + robotType, + t, + isOnDevice = false, + detailLevel = 'full', + } = params + if (location == null) { - console.warn('Cannot get labware display location. No location provided.') + console.error('Cannot get labware display location. No location provided.') return '' - } - if (location === 'offDeck') { + } else if (location === 'offDeck') { return t('off_deck') } else if ('slotName' in location) { return isOnDevice @@ -56,88 +76,105 @@ export function getLabwareDisplayLocation({ } else if ('moduleId' in location) { const moduleModel = getModuleModel(loadedModules, location.moduleId) if (moduleModel == null) { - console.warn('labware is located on an unknown module model') + console.error('labware is located on an unknown module model') return '' - } else { - const slotName = getModuleDisplayLocation( - loadedModules, - location.moduleId - ) - return isOnDevice - ? `${getModuleDisplayName(moduleModel)}, ${slotName}` - : t('module_in_slot', { - count: getOccludedSlotCountForModule( - getModuleType(moduleModel), - robotType - ), - module: getModuleDisplayName(moduleModel), - slot_name: slotName, - }) } + const slotName = getModuleDisplayLocation(loadedModules, location.moduleId) + + if (detailLevel === 'slot-only') { + return t('slot', { slot_name: slotName }) + } + + return isOnDevice + ? `${getModuleDisplayName(moduleModel)}, ${slotName}` + : t('module_in_slot', { + count: getOccludedSlotCountForModule( + getModuleType(moduleModel), + robotType + ), + module: getModuleDisplayName(moduleModel), + slot_name: slotName, + }) } else if ('labwareId' in location) { if (!Array.isArray(loadedLabwares)) { - console.warn('Cannot get display location from loaded labwares object') + console.error('Cannot get display location from loaded labwares object') return '' } const adapter = loadedLabwares.find(lw => lw.id === location.labwareId) - const adapterDef = allRunDefs.find( - def => getLabwareDefURI(def) === adapter?.definitionUri - ) - const adapterDisplayName = - adapterDef != null ? getLabwareDisplayName(adapterDef) : '' if (adapter == null) { - console.warn('labware is located on an unknown adapter') + console.error('labware is located on an unknown adapter') return '' - } else if (adapter.location === 'offDeck') { - return t('off_deck') - } else if ('slotName' in adapter.location) { - return t('adapter_in_slot', { - adapter: adapterDisplayName, - slot: adapter.location.slotName, + } else if (detailLevel === 'slot-only') { + return getLabwareDisplayLocation({ + ...params, + location: adapter.location, }) - } else if ('addressableAreaName' in adapter.location) { - return t('adapter_in_slot', { - adapter: adapterDisplayName, - slot: adapter.location.addressableAreaName, - }) - } else if ('moduleId' in adapter.location) { - const moduleIdUnderAdapter = adapter.location.moduleId + } else if (detailLevel === 'full') { + const { allRunDefs } = params as LabwareDisplayLocationFull + const adapterDef = allRunDefs.find( + def => getLabwareDefURI(def) === adapter?.definitionUri + ) + const adapterDisplayName = + adapterDef != null ? getLabwareDisplayName(adapterDef) : '' - if (!Array.isArray(loadedModules)) { - console.warn('Cannot get display location from loaded labwares object') - return '' - } + if (adapter.location === 'offDeck') { + return t('off_deck') + } else if ( + 'slotName' in adapter.location || + 'addressableAreaName' in adapter.location + ) { + const slotName = + 'slotName' in adapter.location + ? adapter.location.slotName + : adapter.location.addressableAreaName + return t('adapter_in_slot', { + adapter: adapterDisplayName, + slot: slotName, + }) + } else if ('moduleId' in adapter.location) { + const moduleIdUnderAdapter = adapter.location.moduleId + + if (!Array.isArray(loadedModules)) { + console.error( + 'Cannot get display location from loaded modules object' + ) + return '' + } + + const moduleModel = loadedModules.find( + module => module.id === moduleIdUnderAdapter + )?.model + if (moduleModel == null) { + console.error('labware is located on an adapter on an unknown module') + return '' + } + const slotName = getModuleDisplayLocation( + loadedModules, + adapter.location.moduleId + ) - const moduleModel = loadedModules.find( - module => module.id === moduleIdUnderAdapter - )?.model - if (moduleModel == null) { - console.warn('labware is located on an adapter on an unknown module') + return t('adapter_in_mod_in_slot', { + count: getOccludedSlotCountForModule( + getModuleType(moduleModel), + robotType + ), + module: getModuleDisplayName(moduleModel), + adapter: adapterDisplayName, + slot: slotName, + }) + } else { + console.error( + 'Unhandled adapter location for determining display location.' + ) return '' } - const slotName = getModuleDisplayLocation( - loadedModules, - adapter.location.moduleId - ) - return t('adapter_in_mod_in_slot', { - count: getOccludedSlotCountForModule( - getModuleType(moduleModel), - robotType - ), - module: getModuleDisplayName(moduleModel), - adapter: adapterDisplayName, - slot: slotName, - }) } else { - console.warn( - 'display location on adapter could not be established: ', - location - ) + console.error('Unhandled detail level for determining display location.') return '' } } else { - console.warn('display location could not be established: ', location) + console.error('display location could not be established: ', location) return '' } } diff --git a/app/src/molecules/Command/__tests__/CommandText.test.tsx b/app/src/molecules/Command/__tests__/CommandText.test.tsx index 6999063be38..f2762b622d7 100644 --- a/app/src/molecules/Command/__tests__/CommandText.test.tsx +++ b/app/src/molecules/Command/__tests__/CommandText.test.tsx @@ -444,7 +444,7 @@ describe('CommandText', () => { />, { i18nInstance: i18n } ) - screen.getByText('Dropping tip in place') + screen.getByText('Dropping tip in D3') }) it('renders correct text for pickUpTip', () => { const command = mockCommandTextData.commands.find( diff --git a/app/src/molecules/LabwareStackModal/LabwareStackModal.tsx b/app/src/molecules/LabwareStackModal/LabwareStackModal.tsx index 9c6602023a8..83b588f4a6e 100644 --- a/app/src/molecules/LabwareStackModal/LabwareStackModal.tsx +++ b/app/src/molecules/LabwareStackModal/LabwareStackModal.tsx @@ -32,6 +32,8 @@ import { THERMOCYCLER_MODULE_TYPE, } from '@opentrons/shared-data' import tiprackAdapter from '/app/assets/images/labware/opentrons_flex_96_tiprack_adapter.png' +import tcLid from '/app/assets/images/labware/opentrons_tough_pcr_auto_sealing_lid.png' +import deckRiser from '/app/assets/images/labware/opentrons_flex_deck_riser.png' import type { RobotType, RunTimeCommand } from '@opentrons/shared-data' @@ -58,6 +60,13 @@ const LIST_ITEM_STYLE = css` justify-content: ${JUSTIFY_SPACE_BETWEEN}; ` +const ADAPTER_LOAD_NAMES_TO_SHOW_IMAGE: { [key: string]: string } = { + opentrons_flex_96_tiprack_adapter: tiprackAdapter, + opentrons_flex_deck_riser: deckRiser, +} +const LABWARE_LOAD_NAMES_TO_SHOW_IMAGE: { [key: string]: string } = { + opentrons_tough_pcr_auto_sealing_lid: tcLid, +} interface LabwareStackModalProps { labwareIdTop: string commands: RunTimeCommand[] | null @@ -87,6 +96,7 @@ export const LabwareStackModal = ( moduleModel, labwareName, labwareNickname, + labwareQuantity, } = getLocationInfoNames(labwareIdTop, commands) const topDefinition = getSlotLabwareDefinition(labwareIdTop, commands) @@ -106,7 +116,25 @@ export const LabwareStackModal = ( moduleModel != null ? getModuleDisplayName(moduleModel) : null ?? '' const isAdapterForTiprack = adapterDef?.parameters.loadName === 'opentrons_flex_96_tiprack_adapter' - const tiprackAdapterImg = + + const labwareImg = + topDefinition.parameters.loadName in LABWARE_LOAD_NAMES_TO_SHOW_IMAGE ? ( + + ) : null + + const adapterImg = + adapterDef != null && + adapterDef.parameters.loadName in ADAPTER_LOAD_NAMES_TO_SHOW_IMAGE ? ( + + ) : null const moduleImg = moduleModel != null ? ( @@ -139,25 +167,33 @@ export const LabwareStackModal = ( 1 + ? t('labware_quantity', { quantity: labwareQuantity }) + : labwareNickname + } /> - - - + {labwareImg != null ? ( + {labwareImg} + ) : ( + + + + )} - {adapterDef != null ? ( <> + - {isAdapterForTiprack ? ( - {tiprackAdapterImg} + {adapterImg != null ? ( + {adapterImg} ) : ( )} - {moduleModel != null ? ( - - ) : null} ) : null} {moduleModel != null ? ( - - - {moduleImg} - + <> + + + + {moduleImg} + + ) : null} @@ -200,24 +236,35 @@ export const LabwareStackModal = ( <> - - - - + 1 + ? t('labware_quantity', { quantity: labwareQuantity }) + : labwareNickname + } + /> + {labwareImg != null ? ( + {labwareImg} + ) : ( + + + + )} - {adapterDef != null ? ( <> + - {isAdapterForTiprack ? ( - {tiprackAdapterImg} + {adapterImg != null ? ( + {adapterImg} ) : ( )} - {moduleModel != null ? ( - - ) : null} ) : null} {moduleModel != null ? ( - - - {moduleImg} - + <> + + + + {moduleImg} + + ) : null} @@ -253,31 +300,23 @@ interface LabwareStackLabelProps { } function LabwareStackLabel(props: LabwareStackLabelProps): JSX.Element { const { text, subText, isOnDevice = false } = props - return isOnDevice ? ( - - {text} - {subText != null ? ( - - {subText} - - ) : null} - - ) : ( + return ( - {text} + + {text} + {subText != null ? ( - + {subText} ) : null} diff --git a/app/src/organisms/ApplyHistoricOffsets/hooks/useHistoricRunDetails.ts b/app/src/organisms/ApplyHistoricOffsets/hooks/useHistoricRunDetails.ts index 597f2129e42..39f6d5e59b8 100644 --- a/app/src/organisms/ApplyHistoricOffsets/hooks/useHistoricRunDetails.ts +++ b/app/src/organisms/ApplyHistoricOffsets/hooks/useHistoricRunDetails.ts @@ -6,11 +6,13 @@ export function useHistoricRunDetails( hostOverride?: HostConfig | null ): RunData[] { const { data: allHistoricRuns } = useNotifyAllRunsQuery({}, {}, hostOverride) - return allHistoricRuns == null ? [] - : allHistoricRuns.data.sort( - (a, b) => - new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() - ) + : // TODO(sf): figure out why .toSorted() doesn't work in vitest + allHistoricRuns.data + .map(t => t) + .sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ) } diff --git a/app/src/organisms/Desktop/Devices/ChangePipette/InstructionStep.tsx b/app/src/organisms/Desktop/Devices/ChangePipette/InstructionStep.tsx index 05d43fdd11c..5b6338be6a5 100644 --- a/app/src/organisms/Desktop/Devices/ChangePipette/InstructionStep.tsx +++ b/app/src/organisms/Desktop/Devices/ChangePipette/InstructionStep.tsx @@ -30,13 +30,13 @@ export function InstructionStep(props: Props): JSX.Element { const display = displayCategory === 'GEN2' ? new URL( - `/app/assets/images/change-pip/${direction}-${String( + `../../../../assets/images/change-pip/${direction}-${String( mount )}-${channelsKey}-GEN2-${diagram}@3x.png`, import.meta.url ).href : new URL( - `/app/assets/images/change-pip/${direction}-${String( + `../../../../assets/images/change-pip/${direction}-${String( mount )}-${channelsKey}-${diagram}@3x.png`, import.meta.url diff --git a/app/src/organisms/Desktop/Devices/ChangePipette/LevelPipette.tsx b/app/src/organisms/Desktop/Devices/ChangePipette/LevelPipette.tsx index db49a4d6861..fb1120daec7 100644 --- a/app/src/organisms/Desktop/Devices/ChangePipette/LevelPipette.tsx +++ b/app/src/organisms/Desktop/Devices/ChangePipette/LevelPipette.tsx @@ -26,6 +26,11 @@ export function LevelingVideo(props: { mount: Mount }): JSX.Element { const { pipetteName, mount } = props + const video = new URL( + `../../../../assets/videos/pip-leveling/${pipetteName}-${mount}.webm`, + import.meta.url + ).href + return ( ) } diff --git a/app/src/organisms/Desktop/Devices/HistoricalProtocolRunDrawer.tsx b/app/src/organisms/Desktop/Devices/HistoricalProtocolRunDrawer.tsx index 6533895bb1e..1570d560aac 100644 --- a/app/src/organisms/Desktop/Devices/HistoricalProtocolRunDrawer.tsx +++ b/app/src/organisms/Desktop/Devices/HistoricalProtocolRunDrawer.tsx @@ -56,6 +56,10 @@ export function HistoricalProtocolRunDrawer( return acc }, []) : [] + if ('outputFileIds' in run && run.outputFileIds.length > 0) { + runDataFileIds.push(...run.outputFileIds) + } + const uniqueLabwareOffsets = allLabwareOffsets?.filter( (offset, index, array) => { return ( diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderBannerContainer/index.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderBannerContainer/index.tsx index e05a11eb391..5c7c6e01621 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderBannerContainer/index.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderBannerContainer/index.tsx @@ -1,6 +1,19 @@ import { useTranslation } from 'react-i18next' +import { useNavigate } from 'react-router-dom' -import { Box, SPACING, Banner } from '@opentrons/components' +import { + Box, + StyledText, + Link, + SPACING, + Banner, + Flex, + DIRECTION_COLUMN, + JUSTIFY_SPACE_BETWEEN, + DIRECTION_ROW, + ALIGN_CENTER, + TEXT_DECORATION_UNDERLINE, +} from '@opentrons/components' import { ProtocolAnalysisErrorBanner } from './ProtocolAnalysisErrorBanner' import { @@ -21,17 +34,25 @@ export type RunHeaderBannerContainerProps = ProtocolRunHeaderProps & { isResetRunLoading: boolean runErrors: UseRunErrorsResult runHeaderModalContainerUtils: UseRunHeaderModalContainerResult + hasDownloadableFiles: boolean } // Holds all the various banners that render in ProtocolRunHeader. export function RunHeaderBannerContainer( props: RunHeaderBannerContainerProps ): JSX.Element | null { - const { runStatus, enteredER, runHeaderModalContainerUtils } = props + const navigate = useNavigate() + const { + runStatus, + enteredER, + runHeaderModalContainerUtils, + hasDownloadableFiles, + robotName, + } = props const { analysisErrorModalUtils } = runHeaderModalContainerUtils const { t } = useTranslation(['run_details', 'shared']) - const isDoorOpen = useIsDoorOpen(props.robotName) + const isDoorOpen = useIsDoorOpen(robotName) const { showRunCanceledBanner, @@ -73,6 +94,36 @@ export function RunHeaderBannerContainer( {...props} /> ) : null} + {hasDownloadableFiles ? ( + + + + + {t('download_files')} + + + {t('files_available_robot_details')} + + + { + navigate(`/devices/${robotName}`) + }} + > + {t('device_details')} + + + + ) : null} ) } diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderContent/ActionButton/hooks/useActionButtonProperties.ts b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderContent/ActionButton/hooks/useActionButtonProperties.ts index cd16d2467b6..4c5486eb6e7 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderContent/ActionButton/hooks/useActionButtonProperties.ts +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderContent/ActionButton/hooks/useActionButtonProperties.ts @@ -1,5 +1,6 @@ import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router-dom' +import { useSelector } from 'react-redux' import { RUN_STATUS_IDLE, @@ -14,6 +15,7 @@ import { useTrackEvent, } from '/app/redux/analytics' import { useTrackProtocolRunEvent } from '/app/redux-resources/analytics' +import { getMissingSetupSteps } from '/app/redux/protocol-runs' import { useIsHeaterShakerInProtocol } from '/app/organisms/ModuleCard/hooks' import { isAnyHeaterShakerShaking } from '../../../RunHeaderModalContainer/modals' import { @@ -24,6 +26,8 @@ import { import type { IconName } from '@opentrons/components' import type { BaseActionButtonProps } from '..' +import type { State } from '/app/redux/types' +import type { StepKey } from '/app/redux/protocol-runs' interface UseButtonPropertiesProps extends BaseActionButtonProps { isProtocolNotReady: boolean @@ -42,7 +46,6 @@ interface UseButtonPropertiesProps extends BaseActionButtonProps { export function useActionButtonProperties({ isProtocolNotReady, runStatus, - missingSetupSteps, robotName, runId, confirmAttachment, @@ -66,6 +69,9 @@ export function useActionButtonProperties({ const isHeaterShakerInProtocol = useIsHeaterShakerInProtocol() const isHeaterShakerShaking = isAnyHeaterShakerShaking(attachedModules) const trackEvent = useTrackEvent() + const missingSetupSteps = useSelector((state: State) => + getMissingSetupSteps(state, runId) + ) let buttonText = '' let handleButtonClick = (): void => {} diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/hooks/useMissingStepsModal.ts b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/hooks/useMissingStepsModal.ts index 4bf28bc049f..d0506c55153 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/hooks/useMissingStepsModal.ts +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/hooks/useMissingStepsModal.ts @@ -1,17 +1,21 @@ +import { useSelector } from 'react-redux' import { RUN_STATUS_IDLE, RUN_STATUS_STOPPED } from '@opentrons/api-client' import { useConditionalConfirm } from '@opentrons/components' import { useIsHeaterShakerInProtocol } from '/app/organisms/ModuleCard/hooks' import { isAnyHeaterShakerShaking } from '../modals' +import { getMissingSetupSteps } from '/app/redux/protocol-runs' import type { UseConditionalConfirmResult } from '@opentrons/components' import type { RunStatus, AttachedModule } from '@opentrons/api-client' import type { ConfirmMissingStepsModalProps } from '../modals' +import type { State } from '/app/redux/types' +import type { StepKey } from '/app/redux/protocol-runs' interface UseMissingStepsModalProps { runStatus: RunStatus | null attachedModules: AttachedModule[] - missingSetupSteps: string[] + runId: string handleProceedToRunClick: () => void } @@ -30,12 +34,14 @@ export type UseMissingStepsModalResult = export function useMissingStepsModal({ attachedModules, runStatus, - missingSetupSteps, + runId, handleProceedToRunClick, }: UseMissingStepsModalProps): UseMissingStepsModalResult { const isHeaterShakerInProtocol = useIsHeaterShakerInProtocol() const isHeaterShakerShaking = isAnyHeaterShakerShaking(attachedModules) - + const missingSetupSteps = useSelector((state: State) => + getMissingSetupSteps(state, runId) + ) const shouldShowHSConfirm = isHeaterShakerInProtocol && !isHeaterShakerShaking && diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/ConfirmMissingStepsModal.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/ConfirmMissingStepsModal.tsx index 978efdbab48..8203e126a2d 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/ConfirmMissingStepsModal.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/ConfirmMissingStepsModal.tsx @@ -12,11 +12,27 @@ import { TYPOGRAPHY, Modal, } from '@opentrons/components' +import { + LPC_STEP_KEY, + LABWARE_SETUP_STEP_KEY, + LIQUID_SETUP_STEP_KEY, + MODULE_SETUP_STEP_KEY, + ROBOT_CALIBRATION_STEP_KEY, +} from '/app/redux/protocol-runs' +import type { StepKey } from '/app/redux/protocol-runs' + +const STEP_KEY_TO_I18N_KEY = { + [LPC_STEP_KEY]: 'applied_labware_offsets', + [LABWARE_SETUP_STEP_KEY]: 'labware_placement', + [LIQUID_SETUP_STEP_KEY]: 'liquids', + [MODULE_SETUP_STEP_KEY]: 'module_setup', + [ROBOT_CALIBRATION_STEP_KEY]: 'robot_calibration', +} export interface ConfirmMissingStepsModalProps { onCloseClick: () => void onConfirmClick: () => void - missingSteps: string[] + missingSteps: StepKey[] } export const ConfirmMissingStepsModal = ( props: ConfirmMissingStepsModalProps @@ -41,7 +57,7 @@ export const ConfirmMissingStepsModal = ( missingSteps: new Intl.ListFormat('en', { style: 'short', type: 'conjunction', - }).format(missingSteps.map(step => t(step))), + }).format(missingSteps.map(step => t(STEP_KEY_TO_I18N_KEY[step]))), })} diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/ProtocolDropTipModal.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/ProtocolDropTipModal.tsx index 7d96803c4a6..e1f1be57d22 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/ProtocolDropTipModal.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/ProtocolDropTipModal.tsx @@ -16,14 +16,12 @@ import { } from '@opentrons/components' import { TextOnlyButton } from '/app/atoms/buttons' -import { useHomePipettes } from '/app/organisms/DropTipWizardFlows' +import { useHomePipettes } from '/app/local-resources/instruments' import type { PipetteData } from '@opentrons/api-client' import type { IconProps } from '@opentrons/components' -import type { - UseHomePipettesProps, - TipAttachmentStatusResult, -} from '/app/organisms/DropTipWizardFlows' +import type { UseHomePipettesProps } from '/app/local-resources/instruments' +import type { TipAttachmentStatusResult } from '/app/organisms/DropTipWizardFlows' type UseProtocolDropTipModalProps = Pick< UseHomePipettesProps, diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/__tests__/ProtocolDropTipModal.test.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/__tests__/ProtocolDropTipModal.test.tsx index 56a508b9666..0d95071a969 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/__tests__/ProtocolDropTipModal.test.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/__tests__/ProtocolDropTipModal.test.tsx @@ -4,7 +4,7 @@ import { renderHook, act, screen, fireEvent } from '@testing-library/react' import { renderWithProviders } from '/app/__testing-utils__' import { i18n } from '/app/i18n' -import { useHomePipettes } from '/app/organisms/DropTipWizardFlows' +import { useHomePipettes } from '/app/local-resources/instruments' import { useProtocolDropTipModal, ProtocolDropTipModal, @@ -12,7 +12,7 @@ import { import type { Mock } from 'vitest' -vi.mock('/app/organisms/DropTipWizardFlows') +vi.mock('/app/local-resources/instruments') describe('useProtocolDropTipModal', () => { let props: Parameters[0] diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/useRunHeaderModalContainer.ts b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/useRunHeaderModalContainer.ts index 17d81c1f18e..48eda0ebfa5 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/useRunHeaderModalContainer.ts +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/useRunHeaderModalContainer.ts @@ -62,7 +62,6 @@ export function useRunHeaderModalContainer({ runStatus, runRecord, attachedModules, - missingSetupSteps, protocolRunControls, runErrors, }: UseRunHeaderModalContainerProps): UseRunHeaderModalContainerResult { @@ -102,7 +101,7 @@ export function useRunHeaderModalContainer({ const missingStepsModalUtils = useMissingStepsModal({ attachedModules, runStatus, - missingSetupSteps, + runId, handleProceedToRunClick, }) diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/__tests__/ProtocolRunHeader.test.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/__tests__/ProtocolRunHeader.test.tsx index 9cc357d0565..e82d58cb75e 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/__tests__/ProtocolRunHeader.test.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/__tests__/ProtocolRunHeader.test.tsx @@ -30,6 +30,7 @@ vi.mock('react-router-dom') vi.mock('@opentrons/react-api-client') vi.mock('/app/redux-resources/robots') vi.mock('/app/resources/runs') +vi.mock('/app/redux/protocol-runs') vi.mock('../RunHeaderModalContainer') vi.mock('../RunHeaderBannerContainer') vi.mock('../RunHeaderContent') @@ -51,7 +52,6 @@ describe('ProtocolRunHeader', () => { robotName: MOCK_ROBOT, runId: MOCK_RUN_ID, makeHandleJumpToStep: vi.fn(), - missingSetupSteps: [], } vi.mocked(useNavigate).mockReturnValue(mockNavigate) diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/index.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/index.tsx index b9641fcc96b..40375135db9 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/index.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/index.tsx @@ -35,7 +35,6 @@ export interface ProtocolRunHeaderProps { robotName: string runId: string makeHandleJumpToStep: (index: number) => () => void - missingSetupSteps: string[] } export function ProtocolRunHeader( @@ -103,6 +102,11 @@ export function ProtocolRunHeader( isResetRunLoading={isResetRunLoadingRef.current} runErrors={runErrors} runHeaderModalContainerUtils={runHeaderModalContainerUtils} + hasDownloadableFiles={ + runRecord?.data != null && + 'outputFileIds' in runRecord.data && + runRecord.data.outputFileIds.length > 0 + } {...props} /> [ - 'applied_labware_offsets', - 'labware_placement', - 'liquids', -] +import type { Dispatch, State } from '/app/redux/types' +import type { StepKey } from '/app/redux/protocol-runs' interface ProtocolRunSetupProps { protocolRunHeaderRef: React.RefObject | null robotName: string runId: string - setMissingSteps: (missingSteps: MissingSteps) => void - missingSteps: MissingSteps } export function ProtocolRunSetup({ protocolRunHeaderRef, robotName, runId, - setMissingSteps, - missingSteps, }: ProtocolRunSetupProps): JSX.Element | null { const { t, i18n } = useTranslation('protocol_setup') + const dispatch = useDispatch() const robotProtocolAnalysis = useMostRecentCompletedAnalysis(runId) const storedProtocolAnalysis = useStoredProtocolAnalysis(runId) const protocolAnalysis = robotProtocolAnalysis ?? storedProtocolAnalysis + const { + orderedSteps, + orderedApplicableSteps, + } = useRequiredSetupStepsInOrder({ runId, protocolAnalysis }) const modules = parseAllRequiredModuleModels(protocolAnalysis?.commands ?? []) const robot = useRobot(robotName) @@ -130,39 +119,6 @@ export function ProtocolRunSetup({ const isMissingModule = missingModuleIds.length > 0 - const stepsKeysInOrder = - protocolAnalysis != null - ? [ - ROBOT_CALIBRATION_STEP_KEY, - MODULE_SETUP_KEY, - LPC_KEY, - LABWARE_SETUP_KEY, - LIQUID_SETUP_KEY, - ] - : [ROBOT_CALIBRATION_STEP_KEY, LPC_KEY, LABWARE_SETUP_KEY] - - const targetStepKeyInOrder = stepsKeysInOrder.filter((stepKey: StepKey) => { - if (protocolAnalysis == null) { - return stepKey !== MODULE_SETUP_KEY && stepKey !== LIQUID_SETUP_KEY - } - - if ( - protocolAnalysis.modules.length === 0 && - protocolAnalysis.liquids.length === 0 - ) { - return stepKey !== MODULE_SETUP_KEY && stepKey !== LIQUID_SETUP_KEY - } - - if (protocolAnalysis.modules.length === 0) { - return stepKey !== MODULE_SETUP_KEY - } - - if (protocolAnalysis.liquids.length === 0) { - return stepKey !== LIQUID_SETUP_KEY - } - return true - }) - const liquids = protocolAnalysis?.liquids ?? [] const hasLiquids = liquids.length > 0 const hasModules = protocolAnalysis != null && modules.length > 0 @@ -179,26 +135,10 @@ export function ProtocolRunSetup({ ? t('install_modules', { count: modules.length }) : t('no_deck_hardware_specified') - const [ - labwareSetupComplete, - setLabwareSetupComplete, - ] = React.useState(false) - const [liquidSetupComplete, setLiquidSetupComplete] = React.useState( - false + const missingSteps = useSelector( + (state: State): StepKey[] => getMissingSetupSteps(state, runId) ) - React.useEffect(() => { - if ((robotProtocolAnalysis || storedProtocolAnalysis) && !hasLiquids) { - setLiquidSetupComplete(true) - } - }, [robotProtocolAnalysis, storedProtocolAnalysis, hasLiquids]) - if ( - !hasLiquids && - protocolAnalysis != null && - missingSteps.includes('liquids') - ) { - setMissingSteps(missingSteps.filter(step => step !== 'liquids')) - } - const [lpcComplete, setLpcComplete] = React.useState(false) + if (robot == null) { return null } @@ -216,8 +156,8 @@ export function ProtocolRunSetup({ robotName={robotName} runId={runId} nextStep={ - targetStepKeyInOrder[ - targetStepKeyInOrder.findIndex( + orderedApplicableSteps[ + orderedApplicableSteps.findIndex( v => v === ROBOT_CALIBRATION_STEP_KEY ) + 1 ] @@ -240,11 +180,11 @@ export function ProtocolRunSetup({ incompleteElement: null, }, }, - [MODULE_SETUP_KEY]: { + [MODULE_SETUP_STEP_KEY]: { stepInternals: ( { - setExpandedStepKey(LPC_KEY) + setExpandedStepKey(LPC_STEP_KEY) }} robotName={robotName} runId={runId} @@ -256,7 +196,7 @@ export function ProtocolRunSetup({ ? flexDeckHardwareDescription : ot2DeckHardwareDescription, rightElProps: { - stepKey: MODULE_SETUP_KEY, + stepKey: MODULE_SETUP_STEP_KEY, complete: calibrationStatusModules.complete && !isMissingModule && @@ -272,84 +212,89 @@ export function ProtocolRunSetup({ incompleteElement: null, }, }, - [LPC_KEY]: { + [LPC_STEP_KEY]: { stepInternals: ( { - setLpcComplete(confirmed) + dispatch( + updateRunSetupStepsComplete(runId, { [LPC_STEP_KEY]: confirmed }) + ) if (confirmed) { - setExpandedStepKey(LABWARE_SETUP_KEY) - setMissingSteps( - missingSteps.filter(step => step !== 'applied_labware_offsets') - ) + setExpandedStepKey(LABWARE_SETUP_STEP_KEY) } }} - offsetsConfirmed={lpcComplete} + offsetsConfirmed={!missingSteps.includes(LPC_STEP_KEY)} /> ), description: t('labware_position_check_step_description'), rightElProps: { - stepKey: LPC_KEY, - complete: lpcComplete, + stepKey: LPC_STEP_KEY, + complete: !missingSteps.includes(LPC_STEP_KEY), completeText: t('offsets_ready'), incompleteText: null, incompleteElement: , }, }, - [LABWARE_SETUP_KEY]: { + [LABWARE_SETUP_STEP_KEY]: { stepInternals: ( { - setLabwareSetupComplete(confirmed) + dispatch( + updateRunSetupStepsComplete(runId, { + [LABWARE_SETUP_STEP_KEY]: confirmed, + }) + ) if (confirmed) { - setMissingSteps( - missingSteps.filter(step => step !== 'labware_placement') - ) const nextStep = - targetStepKeyInOrder.findIndex(v => v === LABWARE_SETUP_KEY) === - targetStepKeyInOrder.length - 1 + orderedApplicableSteps.findIndex( + v => v === LABWARE_SETUP_STEP_KEY + ) === + orderedApplicableSteps.length - 1 ? null - : LIQUID_SETUP_KEY + : LIQUID_SETUP_STEP_KEY setExpandedStepKey(nextStep) } }} /> ), - description: t(`${LABWARE_SETUP_KEY}_description`), + description: t(`${LABWARE_SETUP_STEP_KEY}_description`), rightElProps: { - stepKey: LABWARE_SETUP_KEY, - complete: labwareSetupComplete, + stepKey: LABWARE_SETUP_STEP_KEY, + complete: !missingSteps.includes(LABWARE_SETUP_STEP_KEY), completeText: t('placements_ready'), incompleteText: null, incompleteElement: null, }, }, - [LIQUID_SETUP_KEY]: { + [LIQUID_SETUP_STEP_KEY]: { stepInternals: ( { - setLiquidSetupComplete(confirmed) + dispatch( + updateRunSetupStepsComplete(runId, { + [LIQUID_SETUP_STEP_KEY]: confirmed, + }) + ) if (confirmed) { - setMissingSteps(missingSteps.filter(step => step !== 'liquids')) setExpandedStepKey(null) } }} /> ), description: hasLiquids - ? t(`${LIQUID_SETUP_KEY}_description`) + ? t(`${LIQUID_SETUP_STEP_KEY}_description`) : i18n.format(t('liquids_not_in_the_protocol'), 'capitalize'), rightElProps: { - stepKey: LIQUID_SETUP_KEY, - complete: liquidSetupComplete, + stepKey: LIQUID_SETUP_STEP_KEY, + complete: !missingSteps.includes(LIQUID_SETUP_STEP_KEY), completeText: t('liquids_ready'), incompleteText: null, incompleteElement: null, @@ -373,7 +318,7 @@ export function ProtocolRunSetup({ {t('protocol_analysis_failed')} ) : ( - stepsKeysInOrder.map((stepKey, index) => { + orderedSteps.map((stepKey, index) => { const setupStepTitle = t(`${stepKey}_title`) const showEmptySetupStep = (stepKey === 'liquid_setup_step' && !hasLiquids) || @@ -411,7 +356,7 @@ export function ProtocolRunSetup({ {StepDetailMap[stepKey].stepInternals} )} - {index !== stepsKeysInOrder.length - 1 ? ( + {index !== orderedSteps.length - 1 ? ( ) : null} @@ -431,7 +376,7 @@ export function ProtocolRunSetup({ interface NoHardwareRequiredStepCompletion { stepKey: Exclude< StepKey, - typeof ROBOT_CALIBRATION_STEP_KEY | typeof MODULE_SETUP_KEY + typeof ROBOT_CALIBRATION_STEP_KEY | typeof MODULE_SETUP_STEP_KEY > complete: boolean incompleteText: string | null @@ -440,7 +385,7 @@ interface NoHardwareRequiredStepCompletion { } interface HardwareRequiredStepCompletion { - stepKey: typeof ROBOT_CALIBRATION_STEP_KEY | typeof MODULE_SETUP_KEY + stepKey: typeof ROBOT_CALIBRATION_STEP_KEY | typeof MODULE_SETUP_STEP_KEY complete: boolean missingHardware: boolean incompleteText: string | null @@ -457,7 +402,7 @@ const stepRequiresHW = ( props: StepRightElementProps ): props is HardwareRequiredStepCompletion => props.stepKey === ROBOT_CALIBRATION_STEP_KEY || - props.stepKey === MODULE_SETUP_KEY + props.stepKey === MODULE_SETUP_STEP_KEY function StepRightElement(props: StepRightElementProps): JSX.Element | null { if (props.complete) { diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabware/LabwareListItem.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabware/LabwareListItem.tsx index 6269be78e83..f31a3bcf28d 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabware/LabwareListItem.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabware/LabwareListItem.tsx @@ -26,7 +26,7 @@ import { } from '@opentrons/components' import { useCreateLiveCommandMutation } from '@opentrons/react-api-client' import { - getLabwareDisplayName, + getTopLabwareInfo, getModuleDisplayName, getModuleType, HEATERSHAKER_MODULE_TYPE, @@ -37,6 +37,7 @@ import { THERMOCYCLER_MODULE_V2, } from '@opentrons/shared-data' +import { getLocationInfoNames } from '/app/transformations/commands' import { ToggleButton } from '/app/atoms/buttons' import { Divider } from '/app/atoms/structure' import { SecureLabwareModal } from './SecureLabwareModal' @@ -47,14 +48,10 @@ import type { RunTimeCommand, ModuleType, LabwareDefinition2, - LoadModuleRunTimeCommand, LoadLabwareRunTimeCommand, } from '@opentrons/shared-data' import type { ModuleRenderInfoForProtocol } from '/app/resources/runs' -import type { - LabwareSetupItem, - NestedLabwareInfo, -} from '/app/transformations/commands' +import type { LabwareSetupItem } from '/app/transformations/commands' import type { ModuleTypesThatRequireExtraAttention } from '../utils/getModuleTypesThatRequireExtraAttention' const LabwareRow = styled.div` @@ -73,7 +70,6 @@ interface LabwareListItemProps extends LabwareSetupItem { extraAttentionModules: ModuleTypesThatRequireExtraAttention[] isFlex: boolean commands: RunTimeCommand[] - nestedLabwareInfo: NestedLabwareInfo | null showLabwareSVG?: boolean } @@ -82,37 +78,48 @@ export function LabwareListItem( ): JSX.Element | null { const { attachedModuleInfo, - nickName, + nickName: bottomLabwareNickname, initialLocation, - definition, moduleModel, - moduleLocation, extraAttentionModules, isFlex, commands, - nestedLabwareInfo, showLabwareSVG, + labwareId: bottomLabwareId, } = props + const loadLabwareCommands = commands?.filter( + (command): command is LoadLabwareRunTimeCommand => + command.commandType === 'loadLabware' + ) + + const { topLabwareId, topLabwareDefinition } = getTopLabwareInfo( + bottomLabwareId ?? '', + loadLabwareCommands + ) + const { + slotName, + labwareName, + labwareNickname, + labwareQuantity, + adapterName: bottomLabwareName, + } = getLocationInfoNames(topLabwareId, commands) + + const isStacked = + labwareQuantity > 1 || + bottomLabwareId !== topLabwareId || + moduleModel != null + const { i18n, t } = useTranslation('protocol_setup') const [ secureLabwareModalType, setSecureLabwareModalType, ] = useState(null) - const labwareDisplayName = getLabwareDisplayName(definition) const { createLiveCommand } = useCreateLiveCommandMutation() const [isLatchLoading, setIsLatchLoading] = useState(false) const [isLatchClosed, setIsLatchClosed] = useState(false) - let slotInfo: string | null = null - - if (initialLocation !== 'offDeck' && 'slotName' in initialLocation) { - slotInfo = initialLocation.slotName - } else if ( - initialLocation !== 'offDeck' && - 'addressableAreaName' in initialLocation - ) { - slotInfo = initialLocation.addressableAreaName - } else if (initialLocation === 'offDeck') { + let slotInfo: string | null = slotName + if (initialLocation === 'offDeck') { slotInfo = i18n.format(t('off_deck'), 'upperCase') } @@ -126,50 +133,20 @@ export function LabwareListItem( | HeaterShakerOpenLatchCreateCommand | HeaterShakerCloseLatchCreateCommand - if (initialLocation !== 'offDeck' && 'labwareId' in initialLocation) { - const loadedAdapter = commands.find( - (command): command is LoadLabwareRunTimeCommand => - command.commandType === 'loadLabware' && - command.result?.labwareId === initialLocation.labwareId - ) - const loadedAdapterLocation = loadedAdapter?.params.location - - if (loadedAdapterLocation != null && loadedAdapterLocation !== 'offDeck') { - if ('slotName' in loadedAdapterLocation) { - slotInfo = loadedAdapterLocation.slotName - } else if ('moduleId' in loadedAdapterLocation) { - const module = commands.find( - (command): command is LoadModuleRunTimeCommand => - command.commandType === 'loadModule' && - command.result?.moduleId === loadedAdapterLocation.moduleId - ) - if (module != null) { - slotInfo = module.params.location.slotName - moduleDisplayName = getModuleDisplayName(module.params.model) - } - } - } - } - if ( - initialLocation !== 'offDeck' && - 'moduleId' in initialLocation && - moduleLocation != null && - moduleModel != null - ) { - const moduleName = getModuleDisplayName(moduleModel) + if (moduleModel != null) { moduleType = getModuleType(moduleModel) + moduleDisplayName = getModuleDisplayName(moduleModel) + const moduleTypeNeedsAttention = extraAttentionModules.find( extraAttentionModType => extraAttentionModType === moduleType ) - let moduleSlotName = moduleLocation.slotName - if (moduleType === THERMOCYCLER_MODULE_TYPE) { - moduleSlotName = isFlex ? TC_MODULE_LOCATION_OT3 : TC_MODULE_LOCATION_OT2 - } - slotInfo = moduleSlotName - moduleDisplayName = moduleName + switch (moduleTypeNeedsAttention) { case MAGNETIC_MODULE_TYPE: case THERMOCYCLER_MODULE_TYPE: + if (moduleType === THERMOCYCLER_MODULE_TYPE) { + slotInfo = isFlex ? TC_MODULE_LOCATION_OT3 : TC_MODULE_LOCATION_OT2 + } if (moduleModel !== THERMOCYCLER_MODULE_V2) { secureLabwareInstructions = ( )} - {nestedLabwareInfo != null || moduleDisplayName != null ? ( - - ) : null} + {isStacked ? : null} - {nestedLabwareInfo != null && - nestedLabwareInfo?.sharedSlotId === slotInfo ? ( - <> - - + + {showLabwareSVG && topLabwareDefinition != null ? ( + + ) : null} + + + {labwareName} + + - - {nestedLabwareInfo.nestedLabwareDisplayName} - - - {nestedLabwareInfo.nestedLabwareNickName} - - + {labwareQuantity > 1 + ? t('labware_quantity', { quantity: labwareQuantity }) + : labwareNickname} + + + + {bottomLabwareName != null ? ( + <> + + + {bottomLabwareName} + + + {bottomLabwareNickname} + + ) : null} - - {showLabwareSVG ? ( - - ) : null} - - - {labwareDisplayName} - - - {nickName} - - - {moduleDisplayName != null ? ( <> @@ -371,9 +352,7 @@ export function LabwareListItem( marginTop="3px" > ))} diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabware/SetupLabwareList.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabware/SetupLabwareList.tsx index 647f1543677..b71c84da0f8 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabware/SetupLabwareList.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabware/SetupLabwareList.tsx @@ -6,10 +6,7 @@ import { StyledText, COLORS, } from '@opentrons/components' -import { - getLabwareSetupItemGroups, - getNestedLabwareInfo, -} from '/app/transformations/commands' +import { getLabwareSetupItemGroups } from '/app/transformations/commands' import { LabwareListItem } from './LabwareListItem' import type { RunTimeCommand } from '@opentrons/shared-data' @@ -56,6 +53,7 @@ export function SetupLabwareList( {allItems.map((labwareItem, index) => { + // filtering out all labware that aren't on a module or the deck const labwareOnAdapter = allItems.find( item => labwareItem.initialLocation !== 'offDeck' && @@ -70,7 +68,6 @@ export function SetupLabwareList( extraAttentionModules={extraAttentionModules} {...labwareItem} isFlex={isFlex} - nestedLabwareInfo={getNestedLabwareInfo(labwareItem, commands)} /> ) })} diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabware/SetupLabwareMap.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabware/SetupLabwareMap.tsx index 0334496fd6b..c8bc460bbf4 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabware/SetupLabwareMap.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabware/SetupLabwareMap.tsx @@ -12,7 +12,7 @@ import { FLEX_ROBOT_TYPE, getDeckDefFromRobotType, getSimplestDeckConfigForProtocol, - parseInitialLoadedLabwareByAdapter, + getTopLabwareInfo, THERMOCYCLER_MODULE_V1, } from '@opentrons/shared-data' @@ -22,14 +22,17 @@ import { getProtocolModulesInfo, getLabwareRenderInfo, } from '/app/transformations/analysis' +import { LabwareStackModal } from '/app/molecules/LabwareStackModal' import { getStandardDeckViewLayerBlockList } from '/app/local-resources/deck_configuration' import { OffDeckLabwareList } from './OffDeckLabwareList' +import type { LabwareOnDeck } from '@opentrons/components' import type { CompletedProtocolAnalysis, ProtocolAnalysisOutput, + LoadLabwareRunTimeCommand, + RunTimeCommand, } from '@opentrons/shared-data' -import { LabwareStackModal } from '/app/molecules/LabwareStackModal' interface SetupLabwareMapProps { runId: string @@ -49,31 +52,25 @@ export function SetupLabwareMap({ if (protocolAnalysis == null) return null - const commands = protocolAnalysis.commands + const commands: RunTimeCommand[] = protocolAnalysis.commands + const loadLabwareCommands = commands?.filter( + (command): command is LoadLabwareRunTimeCommand => + command.commandType === 'loadLabware' + ) const robotType = protocolAnalysis.robotType ?? FLEX_ROBOT_TYPE const deckDef = getDeckDefFromRobotType(robotType) const protocolModulesInfo = getProtocolModulesInfo(protocolAnalysis, deckDef) - const initialLoadedLabwareByAdapter = parseInitialLoadedLabwareByAdapter( - commands - ) - const modulesOnDeck = protocolModulesInfo.map(module => { - const labwareInAdapterInMod = - module.nestedLabwareId != null - ? initialLoadedLabwareByAdapter[module.nestedLabwareId] - : null - // only rendering the labware on top most layer so - // either the adapter or the labware are rendered but not both - const topLabwareDefinition = - labwareInAdapterInMod?.result?.definition ?? module.nestedLabwareDef - const topLabwareId = - labwareInAdapterInMod?.result?.labwareId ?? module.nestedLabwareId - const topLabwareDisplayName = - labwareInAdapterInMod?.params.displayName ?? - module.nestedLabwareDisplayName + const isLabwareStacked = + module.nestedLabwareId != null && module.nestedLabwareDef != null + const { + topLabwareId, + topLabwareDefinition, + topLabwareDisplayName, + } = getTopLabwareInfo(module.nestedLabwareId ?? '', loadLabwareCommands) return { moduleModel: module.moduleDef.model, @@ -84,15 +81,9 @@ export function SetupLabwareMap({ : {}, nestedLabwareDef: topLabwareDefinition, - highlightLabware: - topLabwareDefinition != null && - topLabwareId != null && - hoverLabwareId === topLabwareId, - highlightShadowLabware: - topLabwareDefinition != null && - topLabwareId != null && - hoverLabwareId === topLabwareId, - stacked: topLabwareDefinition != null && topLabwareId != null, + highlightLabware: hoverLabwareId === topLabwareId, + highlightShadowLabware: hoverLabwareId === topLabwareId, + stacked: isLabwareStacked, moduleChildren: ( // open modal ) : null} @@ -130,59 +121,59 @@ export function SetupLabwareMap({ const labwareRenderInfo = getLabwareRenderInfo(protocolAnalysis, deckDef) - const labwareOnDeck = map( + const labwareOnDeck: Array = map( labwareRenderInfo, - ({ labwareDef, displayName, slotName }, labwareId) => { - const labwareInAdapter = initialLoadedLabwareByAdapter[labwareId] - // only rendering the labware on top most layer so - // either the adapter or the labware are rendered but not both - const topLabwareDefinition = - labwareInAdapter?.result?.definition ?? labwareDef - const topLabwareId = labwareInAdapter?.result?.labwareId ?? labwareId - const topLabwareDisplayName = - labwareInAdapter?.params.displayName ?? displayName - const isLabwareInStack = - topLabwareDefinition != null && - topLabwareId != null && - labwareInAdapter != null - - return { - labwareLocation: { slotName }, - definition: topLabwareDefinition, + ({ slotName }, labwareId) => { + const { topLabwareId, + topLabwareDefinition, topLabwareDisplayName, - highlight: isLabwareInStack && hoverLabwareId === topLabwareId, - highlightShadow: isLabwareInStack && hoverLabwareId === topLabwareId, - labwareChildren: ( - { - if (isLabwareInStack) { - setLabwareStackDetailsLabwareId(topLabwareId) - } - }} - onMouseEnter={() => { - if (topLabwareDefinition != null && topLabwareId != null) { - setHoverLabwareId(() => topLabwareId) - } - }} - onMouseLeave={() => { - setHoverLabwareId(null) - }} - > - - - ), - stacked: isLabwareInStack, - } + } = getTopLabwareInfo(labwareId, loadLabwareCommands) + const isLabwareInStack = labwareId !== topLabwareId + return topLabwareDefinition != null + ? { + labwareLocation: { slotName }, + definition: topLabwareDefinition, + highlight: isLabwareInStack && hoverLabwareId === topLabwareId, + highlightShadow: + isLabwareInStack && hoverLabwareId === topLabwareId, + stacked: isLabwareInStack, + labwareChildren: ( + { + if (isLabwareInStack) { + setLabwareStackDetailsLabwareId(topLabwareId) + } + }} + onMouseEnter={() => { + if (topLabwareDefinition != null && topLabwareId != null) { + setHoverLabwareId(() => topLabwareId) + } + }} + onMouseLeave={() => { + setHoverLabwareId(null) + }} + > + {topLabwareDefinition != null ? ( + + ) : null} + + ), + } + : null } ) + const labwareOnDeckFiltered: LabwareOnDeck[] = labwareOnDeck.filter( + (labware): labware is LabwareOnDeck => labware != null + ) + return ( @@ -191,7 +182,7 @@ export function SetupLabwareMap({ deckConfig={deckConfig} deckLayerBlocklist={getStandardDeckViewLayerBlockList(robotType)} robotType={robotType} - labwareOnDeck={labwareOnDeck} + labwareOnDeck={labwareOnDeckFiltered} modulesOnDeck={modulesOnDeck} /> diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabware/__tests__/LabwareListItem.test.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabware/__tests__/LabwareListItem.test.tsx index 904395f7c98..50afda3d92f 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabware/__tests__/LabwareListItem.test.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabware/__tests__/LabwareListItem.test.tsx @@ -3,7 +3,10 @@ import { fireEvent, screen } from '@testing-library/react' import { describe, it, beforeEach, vi, expect } from 'vitest' import { MemoryRouter } from 'react-router-dom' -import { opentrons96PcrAdapterV1 } from '@opentrons/shared-data' +import { + opentrons96PcrAdapterV1, + getTopLabwareInfo, +} from '@opentrons/shared-data' import { useCreateLiveCommandMutation } from '@opentrons/react-api-client' import { renderWithProviders } from '/app/__testing-utils__' @@ -14,6 +17,7 @@ import { mockTemperatureModule, mockThermocycler, } from '/app/redux/modules/__fixtures__' +import { getLocationInfoNames } from '/app/transformations/commands' import { mockLabwareDef } from '/app/organisms/LabwarePositionCheck/__fixtures__/mockLabwareDef' import { SecureLabwareModal } from '../SecureLabwareModal' import { LabwareListItem } from '../LabwareListItem' @@ -28,7 +32,15 @@ import type { AttachedModule } from '/app/redux/modules/types' import type { ModuleRenderInfoForProtocol } from '/app/resources/runs' vi.mock('../SecureLabwareModal') +vi.mock('/app/transformations/commands') vi.mock('@opentrons/react-api-client') +vi.mock('@opentrons/shared-data', async importOriginal => { + const actualSharedData = await importOriginal() + return { + ...actualSharedData, + getTopLabwareInfo: vi.fn(), + } +}) const mockAdapterDef = opentrons96PcrAdapterV1 as LabwareDefinition2 const mockAdapterId = 'mockAdapterId' @@ -87,12 +99,23 @@ describe('LabwareListItem', () => { vi.mocked(useCreateLiveCommandMutation).mockReturnValue({ createLiveCommand: mockCreateLiveCommand, } as any) + vi.mocked(getLocationInfoNames).mockReturnValue({ + slotName: '7', + labwareName: 'Mock Labware Definition', + labwareNickname: 'nickName', + labwareQuantity: 1, + }) + vi.mocked(getTopLabwareInfo).mockReturnValue({ + topLabwareId: '1', + topLabwareDefinition: mockLabwareDef, + }) }) it('renders the correct info for a thermocycler (OT2), clicking on secure labware instructions opens the modal', () => { render({ commands: [], nickName: mockNickName, + labwareId: '7', definition: mockLabwareDef, initialLocation: { moduleId: mockModuleId }, moduleModel: 'thermocyclerModuleV1' as ModuleModel, @@ -107,7 +130,6 @@ describe('LabwareListItem', () => { } as any) as ModuleRenderInfoForProtocol, }, isFlex: false, - nestedLabwareInfo: null, }) screen.getByText('Mock Labware Definition') screen.getByText('nickName') @@ -137,7 +159,6 @@ describe('LabwareListItem', () => { } as any) as ModuleRenderInfoForProtocol, }, isFlex: true, - nestedLabwareInfo: null, }) screen.getByText('Mock Labware Definition') screen.getByText('A1+B1') @@ -168,7 +189,6 @@ describe('LabwareListItem', () => { } as any) as ModuleRenderInfoForProtocol, }, isFlex: false, - nestedLabwareInfo: null, }) screen.getByText('Mock Labware Definition') screen.getByTestId('slot_info_7') @@ -203,7 +223,6 @@ describe('LabwareListItem', () => { } as any) as ModuleRenderInfoForProtocol, }, isFlex: false, - nestedLabwareInfo: null, }) screen.getByText('Mock Labware Definition') screen.getByTestId('slot_info_7') @@ -245,7 +264,7 @@ describe('LabwareListItem', () => { nickName: mockNickName, definition: mockLabwareDef, initialLocation: { labwareId: mockAdapterId }, - moduleModel: 'temperatureModuleV1' as ModuleModel, + moduleModel: 'temperatureModuleV2' as ModuleModel, moduleLocation: mockModuleSlot, extraAttentionModules: [], attachedModuleInfo: { @@ -262,18 +281,11 @@ describe('LabwareListItem', () => { } as any) as ModuleRenderInfoForProtocol, }, isFlex: false, - nestedLabwareInfo: { - nestedLabwareDisplayName: 'mock nested display name', - sharedSlotId: '7', - nestedLabwareNickName: 'nestedLabwareNickName', - nestedLabwareDefinition: mockLabwareDef, - }, }) screen.getByText('Mock Labware Definition') screen.getAllByText('7') screen.getByText('Temperature Module GEN2') - screen.getByText('mock nested display name') - screen.getByText('nestedLabwareNickName') + screen.getByText('Mock Labware Definition') screen.getByText('nickName') }) @@ -293,10 +305,17 @@ describe('LabwareListItem', () => { z: 1.2, }, } as any + vi.mocked(getLocationInfoNames).mockReturnValue({ + slotName: 'A2', + labwareName: 'Mock Labware Name', + labwareNickname: 'labware nick name', + labwareQuantity: 1, + adapterName: 'mock adapter name', + }) render({ commands: [mockAdapterLoadCommand], - nickName: mockNickName, + nickName: 'mock adapter nick name', definition: mockLabwareDef, initialLocation: { labwareId: mockAdapterId }, moduleModel: null, @@ -304,18 +323,13 @@ describe('LabwareListItem', () => { extraAttentionModules: [], attachedModuleInfo: {}, isFlex: false, - nestedLabwareInfo: { - nestedLabwareDisplayName: 'mock nested display name', - sharedSlotId: 'A2', - nestedLabwareNickName: 'nestedLabwareNickName', - nestedLabwareDefinition: mockLabwareDef, - }, + labwareId: '5', }) - screen.getByText('Mock Labware Definition') + screen.getByText('Mock Labware Name') + screen.getByText('labware nick name') screen.getByText('A2') - screen.getByText('mock nested display name') - screen.getByText('nestedLabwareNickName') - screen.getByText('nickName') + screen.getByText('mock adapter name') + screen.getByText('mock adapter nick name') }) it('renders the correct info for a labware on top of a heater shaker', () => { @@ -341,7 +355,6 @@ describe('LabwareListItem', () => { } as any) as ModuleRenderInfoForProtocol, }, isFlex: false, - nestedLabwareInfo: null, }) screen.getByText('Mock Labware Definition') screen.getByTestId('slot_info_7') @@ -363,6 +376,7 @@ describe('LabwareListItem', () => { }) it('renders the correct info for an off deck labware', () => { + vi.mocked(getTopLabwareInfo) render({ nickName: null, definition: mockLabwareDef, @@ -373,7 +387,6 @@ describe('LabwareListItem', () => { extraAttentionModules: [], attachedModuleInfo: {}, isFlex: false, - nestedLabwareInfo: null, }) screen.getByText('Mock Labware Definition') screen.getByTestId('slot_info_OFF DECK') diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLiquids/SetupLiquidsMap.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLiquids/SetupLiquidsMap.tsx index 5338a9ce055..1b556692f8d 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLiquids/SetupLiquidsMap.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLiquids/SetupLiquidsMap.tsx @@ -14,7 +14,7 @@ import { FLEX_ROBOT_TYPE, getDeckDefFromRobotType, getSimplestDeckConfigForProtocol, - parseInitialLoadedLabwareByAdapter, + getTopLabwareInfo, parseLabwareInfoByLiquidId, parseLiquidsInLoadOrder, THERMOCYCLER_MODULE_V1, @@ -32,6 +32,8 @@ import { import type { CompletedProtocolAnalysis, ProtocolAnalysisOutput, + RunTimeCommand, + LoadLabwareRunTimeCommand, } from '@opentrons/shared-data' interface SetupLiquidsMapProps { @@ -50,13 +52,16 @@ export function SetupLiquidsMap( if (protocolAnalysis == null) return null + const commands: RunTimeCommand[] = protocolAnalysis.commands + const loadLabwareCommands = commands?.filter( + (command): command is LoadLabwareRunTimeCommand => + command.commandType === 'loadLabware' + ) + const liquids = parseLiquidsInLoadOrder( protocolAnalysis.liquids != null ? protocolAnalysis.liquids : [], protocolAnalysis.commands ?? [] ) - const initialLoadedLabwareByAdapter = parseInitialLoadedLabwareByAdapter( - protocolAnalysis.commands ?? [] - ) const robotType = protocolAnalysis.robotType ?? FLEX_ROBOT_TYPE const deckDef = getDeckDefFromRobotType(robotType) const labwareRenderInfo = getLabwareRenderInfo(protocolAnalysis, deckDef) @@ -69,19 +74,11 @@ export function SetupLiquidsMap( const protocolModulesInfo = getProtocolModulesInfo(protocolAnalysis, deckDef) const modulesOnDeck = protocolModulesInfo.map(module => { - const labwareInAdapterInMod = - module.nestedLabwareId != null - ? initialLoadedLabwareByAdapter[module.nestedLabwareId] - : null - // only rendering the labware on top most layer so - // either the adapter or the labware are rendered but not both - const topLabwareDefinition = - labwareInAdapterInMod?.result?.definition ?? module.nestedLabwareDef - const topLabwareId = - labwareInAdapterInMod?.result?.labwareId ?? module.nestedLabwareId - const topLabwareDisplayName = - labwareInAdapterInMod?.params.displayName ?? - module.nestedLabwareDisplayName + const { + topLabwareId, + topLabwareDefinition, + topLabwareDisplayName, + } = getTopLabwareInfo(module.nestedLabwareId ?? '', loadLabwareCommands) const nestedLabwareWellFill = getWellFillFromLabwareId( topLabwareId ?? '', liquids, @@ -120,7 +117,7 @@ export function SetupLiquidsMap( hover={topLabwareId === hoverLabwareId && labwareHasLiquid} labwareHasLiquid={labwareHasLiquid} labwareId={topLabwareId} - displayName={topLabwareDisplayName} + displayName={topLabwareDisplayName ?? null} runId={runId} /> @@ -140,59 +137,52 @@ export function SetupLiquidsMap( labwareOnDeck={[]} modulesOnDeck={modulesOnDeck} > - {map( - labwareRenderInfo, - ({ x, y, labwareDef, displayName }, labwareId) => { - const labwareInAdapter = initialLoadedLabwareByAdapter[labwareId] - // only rendering the labware on top most layer so - // either the adapter or the labware are rendered but not both - const topLabwareDefinition = - labwareInAdapter?.result?.definition ?? labwareDef - const topLabwareId = - labwareInAdapter?.result?.labwareId ?? labwareId - const topLabwareDisplayName = - labwareInAdapter?.params.displayName ?? displayName - const wellFill = getWellFillFromLabwareId( - topLabwareId ?? '', - liquids, - labwareByLiquidId - ) - const labwareHasLiquid = !isEmpty(wellFill) - return ( - - { - setHoverLabwareId(topLabwareId) - }} - onMouseLeave={() => { - setHoverLabwareId('') - }} - onClick={() => { - if (labwareHasLiquid) { - setLiquidDetailsLabwareId(topLabwareId) - } - }} - cursor={labwareHasLiquid ? 'pointer' : ''} - > - - - - - ) - } - )} + {map(labwareRenderInfo, ({ x, y }, labwareId) => { + const { + topLabwareId, + topLabwareDefinition, + topLabwareDisplayName, + } = getTopLabwareInfo(labwareId, loadLabwareCommands) + const wellFill = getWellFillFromLabwareId( + topLabwareId ?? '', + liquids, + labwareByLiquidId + ) + const labwareHasLiquid = !isEmpty(wellFill) + return topLabwareDefinition != null ? ( + + { + setHoverLabwareId(topLabwareId) + }} + onMouseLeave={() => { + setHoverLabwareId('') + }} + onClick={() => { + if (labwareHasLiquid) { + setLiquidDetailsLabwareId(topLabwareId) + } + }} + cursor={labwareHasLiquid ? 'pointer' : ''} + > + + + + + ) : null + })} {liquidDetailsLabwareId != null && ( { vi.mocked(getLocationInfoNames).mockReturnValue({ labwareName: 'mock labware name', slotName: '4', + labwareQuantity: 1, }) mockTrackEvent = vi.fn() vi.mocked(useTrackEvent).mockReturnValue(mockTrackEvent) diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupModuleAndDeck/SetupModulesList.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupModuleAndDeck/SetupModulesList.tsx index 2cb4ced0207..e172b6ffb11 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupModuleAndDeck/SetupModulesList.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupModuleAndDeck/SetupModulesList.tsx @@ -22,6 +22,7 @@ import { } from '@opentrons/components' import { ABSORBANCE_READER_TYPE, + ABSORBANCE_READER_V1, FLEX_ROBOT_TYPE, getCutoutIdForSlotName, getDeckDefFromRobotType, @@ -160,7 +161,6 @@ export function ModulesListItem({ displayName, slotName, attachedModuleMatch, - heaterShakerModuleFromProtocol, isFlex, calibrationStatus, conflictedFixture, @@ -172,9 +172,9 @@ export function ModulesListItem({ attachedModuleMatch != null ? t('module_connected') : t('module_not_connected') - const [showModuleSetupModal, setShowModuleSetupModal] = useState( - false - ) + const [showModuleSetupModal, setShowModuleSetupModal] = useState< + string | null + >(null) const [ showLocationConflictModal, setShowLocationConflictModal, @@ -204,7 +204,10 @@ export function ModulesListItem({ }) let subText: JSX.Element | null = null - if (moduleModel === HEATERSHAKER_MODULE_V1) { + if ( + moduleModel === HEATERSHAKER_MODULE_V1 || + moduleModel === ABSORBANCE_READER_V1 + ) { subText = ( { - setShowModuleSetupModal(true) + setShowModuleSetupModal(displayName) }} > @@ -328,14 +331,13 @@ export function ModulesListItem({ padding={SPACING.spacing16} backgroundColor={COLORS.white} > - {showModuleSetupModal && heaterShakerModuleFromProtocol != null ? ( + {showModuleSetupModal != null ? ( { - setShowModuleSetupModal(false) + setShowModuleSetupModal(null) }} - moduleDisplayName={ - heaterShakerModuleFromProtocol.moduleDef.displayName - } + moduleDisplayName={showModuleSetupModal} + isAbsorbanceReader={moduleModel === ABSORBANCE_READER_V1} /> ) : null} { id: 'heatershaker_id', model: 'heaterShakerModuleV1', moduleType: 'heaterShakerModuleType', + displayName: 'mockHeaterShakerName', serialNumber: 'jkl123', hardwareRevision: 'heatershaker_v4.0', firmwareVersion: 'v2.0.0', diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupRobotCalibration.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupRobotCalibration.tsx index 90745500149..5202419e290 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupRobotCalibration.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupRobotCalibration.tsx @@ -23,7 +23,7 @@ import { RUN_STATUS_STOPPED } from '@opentrons/api-client' import { useIsFlex } from '/app/redux-resources/robots' import type { ProtocolCalibrationStatus } from '/app/redux/calibration/types' -import type { StepKey } from './ProtocolRunSetup' +import type { StepKey } from '/app/redux/protocol-runs' interface SetupRobotCalibrationProps { robotName: string diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/__tests__/ProtocolRunSetup.test.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/__tests__/ProtocolRunSetup.test.tsx index 64aa0d094ae..84e7fb82e65 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/__tests__/ProtocolRunSetup.test.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/__tests__/ProtocolRunSetup.test.tsx @@ -30,7 +30,9 @@ import { } from '/app/resources/runs' import { useDeckConfigurationCompatibility } from '/app/resources/deck_configuration/hooks' import { useRobot, useIsFlex } from '/app/redux-resources/robots' +import { useRequiredSetupStepsInOrder } from '/app/redux-resources/runs' import { useStoredProtocolAnalysis } from '/app/resources/analysis' +import { getMissingSetupSteps } from '/app/redux/protocol-runs' import { SetupLabware } from '../SetupLabware' import { SetupRobotCalibration } from '../SetupRobotCalibration' @@ -38,7 +40,9 @@ import { SetupLiquids } from '../SetupLiquids' import { SetupModuleAndDeck } from '../SetupModuleAndDeck' import { EmptySetupStep } from '../EmptySetupStep' import { ProtocolRunSetup } from '../ProtocolRunSetup' -import type { MissingSteps } from '../ProtocolRunSetup' +import * as ReduxRuns from '/app/redux/protocol-runs' + +import type { State } from '/app/redux/types' import type * as SharedData from '@opentrons/shared-data' @@ -56,9 +60,12 @@ vi.mock('/app/resources/runs/useUnmatchedModulesForProtocol') vi.mock('/app/resources/runs/useModuleCalibrationStatus') vi.mock('/app/resources/runs/useProtocolAnalysisErrors') vi.mock('/app/redux/config') +vi.mock('/app/redux/protocol-runs') +vi.mock('/app/resources/protocol-runs') vi.mock('/app/resources/deck_configuration/utils') vi.mock('/app/resources/deck_configuration/hooks') vi.mock('/app/redux-resources/robots') +vi.mock('/app/redux-resources/runs') vi.mock('/app/resources/analysis') vi.mock('@opentrons/shared-data', async importOriginal => { const actualSharedData = await importOriginal() @@ -74,20 +81,15 @@ vi.mock('@opentrons/shared-data', async importOriginal => { const ROBOT_NAME = 'otie' const RUN_ID = '1' const MOCK_PROTOCOL_LIQUID_KEY = { liquids: [] } -let mockMissingSteps: MissingSteps = [] -const mockSetMissingSteps = vi.fn((missingSteps: MissingSteps) => { - mockMissingSteps = missingSteps -}) const render = () => { - return renderWithProviders( + return renderWithProviders( , { + initialState: {} as State, i18nInstance: i18n, } )[0] @@ -95,7 +97,6 @@ const render = () => { describe('ProtocolRunSetup', () => { beforeEach(() => { - mockMissingSteps = [] when(vi.mocked(useIsFlex)).calledWith(ROBOT_NAME).thenReturn(false) when(vi.mocked(useMostRecentCompletedAnalysis)) .calledWith(RUN_ID) @@ -103,6 +104,9 @@ describe('ProtocolRunSetup', () => { ...noModulesProtocol, ...MOCK_PROTOCOL_LIQUID_KEY, } as any) + when(vi.mocked(getMissingSetupSteps)) + .calledWith(expect.any(Object), RUN_ID) + .thenReturn([]) when(vi.mocked(useProtocolAnalysisErrors)).calledWith(RUN_ID).thenReturn({ analysisErrors: null, }) @@ -112,6 +116,27 @@ describe('ProtocolRunSetup', () => { ...noModulesProtocol, ...MOCK_PROTOCOL_LIQUID_KEY, } as unknown) as SharedData.ProtocolAnalysisOutput) + when(vi.mocked(useRequiredSetupStepsInOrder)) + .calledWith({ + runId: RUN_ID, + protocolAnalysis: expect.any(Object), + }) + .thenReturn({ + orderedSteps: [ + ReduxRuns.ROBOT_CALIBRATION_STEP_KEY, + ReduxRuns.MODULE_SETUP_STEP_KEY, + ReduxRuns.LPC_STEP_KEY, + ReduxRuns.LABWARE_SETUP_STEP_KEY, + ReduxRuns.LIQUID_SETUP_STEP_KEY, + ], + orderedApplicableSteps: [ + ReduxRuns.ROBOT_CALIBRATION_STEP_KEY, + ReduxRuns.MODULE_SETUP_STEP_KEY, + ReduxRuns.LPC_STEP_KEY, + ReduxRuns.LABWARE_SETUP_STEP_KEY, + ReduxRuns.LIQUID_SETUP_STEP_KEY, + ], + }) vi.mocked(parseAllRequiredModuleModels).mockReturnValue([]) vi.mocked(parseLiquidsInLoadOrder).mockReturnValue([]) when(vi.mocked(useRobot)) @@ -179,6 +204,20 @@ describe('ProtocolRunSetup', () => { when(vi.mocked(useStoredProtocolAnalysis)) .calledWith(RUN_ID) .thenReturn(null) + when(vi.mocked(useRequiredSetupStepsInOrder)) + .calledWith({ runId: RUN_ID, protocolAnalysis: null }) + .thenReturn({ + orderedSteps: [ + ReduxRuns.ROBOT_CALIBRATION_STEP_KEY, + ReduxRuns.LPC_STEP_KEY, + ReduxRuns.LABWARE_SETUP_STEP_KEY, + ], + orderedApplicableSteps: [ + ReduxRuns.ROBOT_CALIBRATION_STEP_KEY, + ReduxRuns.LPC_STEP_KEY, + ReduxRuns.LABWARE_SETUP_STEP_KEY, + ], + }) render() screen.getByText('Loading data...') }) diff --git a/app/src/organisms/Desktop/Devices/__tests__/RecentProtocolRuns.test.tsx b/app/src/organisms/Desktop/Devices/__tests__/RecentProtocolRuns.test.tsx index 3b258d2c199..daa1fc10251 100644 --- a/app/src/organisms/Desktop/Devices/__tests__/RecentProtocolRuns.test.tsx +++ b/app/src/organisms/Desktop/Devices/__tests__/RecentProtocolRuns.test.tsx @@ -52,9 +52,9 @@ describe('RecentProtocolRuns', () => { }) it('renders table headers if there are runs', () => { vi.mocked(useIsRobotViewable).mockReturnValue(true) - vi.mocked(useNotifyAllRunsQuery).mockReturnValue({ + vi.mocked(useNotifyAllRunsQuery).mockReturnValue(({ data: { - data: [ + data: ([ { createdAt: '2022-05-04T18:24:40.833862+00:00', current: false, @@ -62,9 +62,9 @@ describe('RecentProtocolRuns', () => { protocolId: 'test_protocol_id', status: 'succeeded', }, - ], + ] as any) as Runs, }, - } as UseQueryResult) + } as any) as UseQueryResult) render() screen.getByText('Recent Protocol Runs') screen.getByText('Run') diff --git a/app/src/organisms/Desktop/SystemLanguagePreferenceModal/__tests__/SystemLanguagePreferenceModal.test.tsx b/app/src/organisms/Desktop/SystemLanguagePreferenceModal/__tests__/SystemLanguagePreferenceModal.test.tsx index 3a378d5f04f..a91d7389072 100644 --- a/app/src/organisms/Desktop/SystemLanguagePreferenceModal/__tests__/SystemLanguagePreferenceModal.test.tsx +++ b/app/src/organisms/Desktop/SystemLanguagePreferenceModal/__tests__/SystemLanguagePreferenceModal.test.tsx @@ -99,7 +99,7 @@ describe('SystemLanguagePreferenceModal', () => { it('should set a supported app language when system language is an unsupported locale of the same language', () => { vi.mocked(getAppLanguage).mockReturnValue(null) - vi.mocked(getSystemLanguage).mockReturnValue('en-UK') + vi.mocked(getSystemLanguage).mockReturnValue('en-GB') render() @@ -116,7 +116,7 @@ describe('SystemLanguagePreferenceModal', () => { 'language.appLanguage', MOCK_DEFAULT_LANGUAGE ) - expect(updateConfigValue).toBeCalledWith('language.systemLanguage', 'en-UK') + expect(updateConfigValue).toBeCalledWith('language.systemLanguage', 'en-GB') }) it('should render the correct header, description, and buttons when system language changes', () => { diff --git a/app/src/organisms/Desktop/SystemLanguagePreferenceModal/index.tsx b/app/src/organisms/Desktop/SystemLanguagePreferenceModal/index.tsx index 42ff74a0eb8..59ae8640f92 100644 --- a/app/src/organisms/Desktop/SystemLanguagePreferenceModal/index.tsx +++ b/app/src/organisms/Desktop/SystemLanguagePreferenceModal/index.tsx @@ -15,6 +15,7 @@ import { StyledText, } from '@opentrons/components' +import { LANGUAGES } from '/app/i18n' import { getAppLanguage, getStoredSystemLanguage, @@ -26,18 +27,12 @@ import { getSystemLanguage } from '/app/redux/shell' import type { DropdownOption } from '@opentrons/components' import type { Dispatch } from '/app/redux/types' -// these strings will not be translated so should not be localized -const languageOptions: DropdownOption[] = [ - { name: 'English (US)', value: 'en-US' }, - { name: '中文', value: 'zh-CN' }, -] - export function SystemLanguagePreferenceModal(): JSX.Element | null { const { i18n, t } = useTranslation(['app_settings', 'shared', 'branded']) const enableLocalization = useFeatureFlag('enableLocalization') const [currentOption, setCurrentOption] = useState( - languageOptions[0] + LANGUAGES[0] ) const dispatch = useDispatch() @@ -76,7 +71,7 @@ export function SystemLanguagePreferenceModal(): JSX.Element | null { } const handleDropdownClick = (value: string): void => { - const selectedOption = languageOptions.find(lng => lng.value === value) + const selectedOption = LANGUAGES.find(lng => lng.value === value) if (selectedOption != null) { setCurrentOption(selectedOption) @@ -89,8 +84,8 @@ export function SystemLanguagePreferenceModal(): JSX.Element | null { if (systemLanguage != null) { // prefer match entire locale, then match just language e.g. zh-Hant and zh-CN const matchedSystemLanguageOption = - languageOptions.find(lng => lng.value === systemLanguage) ?? - languageOptions.find( + LANGUAGES.find(lng => lng.value === systemLanguage) ?? + LANGUAGES.find( lng => new Intl.Locale(lng.value).language === new Intl.Locale(systemLanguage).language @@ -115,7 +110,7 @@ export function SystemLanguagePreferenceModal(): JSX.Element | null { {showBootModal ? ( & { diff --git a/app/src/organisms/DropTipWizardFlows/hooks/index.ts b/app/src/organisms/DropTipWizardFlows/hooks/index.ts index 09acf2b2a5d..3f3f531a9d8 100644 --- a/app/src/organisms/DropTipWizardFlows/hooks/index.ts +++ b/app/src/organisms/DropTipWizardFlows/hooks/index.ts @@ -1,6 +1,5 @@ export * from './errors' export * from './useDropTipWithType' -export * from './useHomePipettes' export * from './useTipAttachmentStatus' export * from './useDropTipLocations' export { useDropTipRouting } from './useDropTipRouting' diff --git a/app/src/organisms/DropTipWizardFlows/index.ts b/app/src/organisms/DropTipWizardFlows/index.ts index 0030fa29a5a..1b53f36e5c8 100644 --- a/app/src/organisms/DropTipWizardFlows/index.ts +++ b/app/src/organisms/DropTipWizardFlows/index.ts @@ -1,10 +1,6 @@ export * from './DropTipWizardFlows' -export { useTipAttachmentStatus, useHomePipettes } from './hooks' +export { useTipAttachmentStatus } from './hooks' export * from './TipsAttachedModal' -export type { - UseHomePipettesProps, - TipAttachmentStatusResult, - PipetteWithTip, -} from './hooks' +export type { TipAttachmentStatusResult, PipetteWithTip } from './hooks' export type { FixitCommandTypeUtils } from './types' diff --git a/app/src/organisms/EmergencyStop/EstopMissingModal.tsx b/app/src/organisms/EmergencyStop/EstopMissingModal.tsx index 3b862a94a9d..07fe453c932 100644 --- a/app/src/organisms/EmergencyStop/EstopMissingModal.tsx +++ b/app/src/organisms/EmergencyStop/EstopMissingModal.tsx @@ -42,7 +42,7 @@ export function EstopMissingModal({ ) : ( <> - {isDismissedModal === false ? ( + {!isDismissedModal ? ( - {t('estop_missing_description', { robotName: robotName })} + {t('estop_missing_description', { robotName })} @@ -121,7 +121,7 @@ function DesktopModal({ {t('connect_the_estop_to_continue')} - {t('estop_missing_description', { robotName: robotName })} + {t('estop_missing_description', { robotName })} diff --git a/app/src/organisms/EmergencyStop/EstopPressedModal.tsx b/app/src/organisms/EmergencyStop/EstopPressedModal.tsx index 8dc996c3374..7c78de6b8e2 100644 --- a/app/src/organisms/EmergencyStop/EstopPressedModal.tsx +++ b/app/src/organisms/EmergencyStop/EstopPressedModal.tsx @@ -24,6 +24,7 @@ import { import { useAcknowledgeEstopDisengageMutation } from '@opentrons/react-api-client' +import { usePlacePlateReaderLid } from '/app/resources/modules' import { getTopPortalEl } from '/app/App/portal' import { SmallButton } from '/app/atoms/buttons' import { OddModal } from '/app/molecules/OddModal' @@ -40,19 +41,15 @@ import type { ModalProps } from '@opentrons/components' interface EstopPressedModalProps { isEngaged: boolean closeModal: () => void - isDismissedModal?: boolean - setIsDismissedModal?: (isDismissedModal: boolean) => void - isWaitingForLogicalDisengage: boolean - setShouldSeeLogicalDisengage: () => void + isWaitingForResumeOperation: boolean + setIsWaitingForResumeOperation: () => void } export function EstopPressedModal({ isEngaged, closeModal, - isDismissedModal, - setIsDismissedModal, - isWaitingForLogicalDisengage, - setShouldSeeLogicalDisengage, + isWaitingForResumeOperation, + setIsWaitingForResumeOperation, }: EstopPressedModalProps): JSX.Element { const isOnDevice = useSelector(getIsOnDevice) return createPortal( @@ -60,20 +57,17 @@ export function EstopPressedModal({ ) : ( <> - {isDismissedModal === false ? ( - - ) : null} + ), getTopPortalEl() @@ -83,12 +77,19 @@ export function EstopPressedModal({ function TouchscreenModal({ isEngaged, closeModal, - isWaitingForLogicalDisengage, - setShouldSeeLogicalDisengage, + isWaitingForResumeOperation, + setIsWaitingForResumeOperation, }: EstopPressedModalProps): JSX.Element { const { t } = useTranslation(['device_settings', 'branded']) const [isResuming, setIsResuming] = React.useState(false) const { acknowledgeEstopDisengage } = useAcknowledgeEstopDisengageMutation() + + const { + handlePlaceReaderLid, + isValidPlateReaderMove, + } = usePlacePlateReaderLid({ + onSettled: closeModal, + }) const modalHeader: OddModalHeaderBaseProps = { title: t('estop_pressed'), iconName: 'ot-alert', @@ -100,9 +101,12 @@ function TouchscreenModal({ } const handleClick = (): void => { setIsResuming(true) + setIsWaitingForResumeOperation() acknowledgeEstopDisengage(null) - setShouldSeeLogicalDisengage() - closeModal() + handlePlaceReaderLid() + if (!isValidPlateReaderMove) { + closeModal() + } } return ( @@ -131,15 +135,13 @@ function TouchscreenModal({ data-testid="Estop_pressed_button" width="100%" iconName={ - isResuming || isWaitingForLogicalDisengage - ? 'ot-spinner' - : undefined + isResuming || isWaitingForResumeOperation ? 'ot-spinner' : undefined } iconPlacement={ - isResuming || isWaitingForLogicalDisengage ? 'startIcon' : undefined + isResuming || isWaitingForResumeOperation ? 'startIcon' : undefined } buttonText={t('resume_robot_operations')} - disabled={isEngaged || isResuming || isWaitingForLogicalDisengage} + disabled={isEngaged || isResuming || isWaitingForResumeOperation} onClick={handleClick} /> @@ -150,25 +152,23 @@ function TouchscreenModal({ function DesktopModal({ isEngaged, closeModal, - setIsDismissedModal, - isWaitingForLogicalDisengage, - setShouldSeeLogicalDisengage, + isWaitingForResumeOperation, + setIsWaitingForResumeOperation, }: EstopPressedModalProps): JSX.Element { const { t } = useTranslation('device_settings') const [isResuming, setIsResuming] = React.useState(false) const { acknowledgeEstopDisengage } = useAcknowledgeEstopDisengageMutation() - - const handleCloseModal = (): void => { - if (setIsDismissedModal != null) { - setIsDismissedModal(true) - } - closeModal() - } + const { + handlePlaceReaderLid, + isValidPlateReaderMove, + } = usePlacePlateReaderLid({ + onSettled: closeModal, + }) const modalProps: ModalProps = { type: 'error', title: t('estop_pressed'), - onClose: handleCloseModal, + onClose: closeModal, closeOnOutsideClick: false, childrenPadding: SPACING.spacing24, width: '47rem', @@ -177,19 +177,12 @@ function DesktopModal({ const handleClick: React.MouseEventHandler = (e): void => { e.preventDefault() setIsResuming(true) - acknowledgeEstopDisengage( - {}, - { - onSuccess: () => { - setShouldSeeLogicalDisengage() - closeModal() - }, - onError: (error: any) => { - setIsResuming(false) - console.error(error) - }, - } - ) + setIsWaitingForResumeOperation() + acknowledgeEstopDisengage(null) + handlePlaceReaderLid() + if (!isValidPlateReaderMove) { + closeModal() + } } return ( @@ -204,14 +197,14 @@ function DesktopModal({ - {isResuming || isWaitingForLogicalDisengage ? ( + {isResuming || isWaitingForResumeOperation ? ( ) : null} {t('resume_robot_operations')} diff --git a/app/src/organisms/EmergencyStop/EstopTakeover.tsx b/app/src/organisms/EmergencyStop/EstopTakeover.tsx index 5967edae75a..cbd9ba1a310 100644 --- a/app/src/organisms/EmergencyStop/EstopTakeover.tsx +++ b/app/src/organisms/EmergencyStop/EstopTakeover.tsx @@ -1,18 +1,13 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { useSelector } from 'react-redux' import { useEstopQuery } from '@opentrons/react-api-client' import { EstopPressedModal } from './EstopPressedModal' import { EstopMissingModal } from './EstopMissingModal' -import { useEstopContext } from './hooks' import { useIsUnboxingFlowOngoing } from '/app/redux-resources/config' import { getLocalRobot } from '/app/redux/discovery' -import { - PHYSICALLY_ENGAGED, - LOGICALLY_ENGAGED, - NOT_PRESENT, - DISENGAGED, -} from './constants' +import { PHYSICALLY_ENGAGED, NOT_PRESENT, DISENGAGED } from './constants' +import type { EstopState } from '@opentrons/api-client' const ESTOP_CURRENTLY_DISENGAGED_REFETCH_INTERVAL_MS = 10000 const ESTOP_CURRENTLY_ENGAGED_REFETCH_INTERVAL_MS = 1000 @@ -22,71 +17,62 @@ interface EstopTakeoverProps { } export function EstopTakeover({ robotName }: EstopTakeoverProps): JSX.Element { - const [estopEngaged, setEstopEngaged] = useState(false) + const [isDismissedModal, setIsDismissedModal] = useState(false) const [ - isWaitingForLogicalDisengage, - setIsWaitingForLogicalDisengage, + isWaitingForResumeOperation, + setIsWatingForResumeOperation, ] = useState(false) + + const [estopState, setEstopState] = useState() + const [showEmergencyStopModal, setShowEmergencyStopModal] = useState( + false + ) + + // TODO: (ba, 2024-10-24): Use notifications instead of polling const { data: estopStatus } = useEstopQuery({ - refetchInterval: estopEngaged + refetchInterval: showEmergencyStopModal ? ESTOP_CURRENTLY_ENGAGED_REFETCH_INTERVAL_MS : ESTOP_CURRENTLY_DISENGAGED_REFETCH_INTERVAL_MS, - onSuccess: response => { - setEstopEngaged( - [PHYSICALLY_ENGAGED || LOGICALLY_ENGAGED].includes( - response?.data.status - ) - ) - setIsWaitingForLogicalDisengage(false) - }, }) + useEffect(() => { + if (estopStatus) { + setEstopState(estopStatus.data.status) + setShowEmergencyStopModal( + estopStatus.data.status !== DISENGAGED || isWaitingForResumeOperation + ) + } + }, [estopStatus]) - const { - isEmergencyStopModalDismissed, - setIsEmergencyStopModalDismissed, - } = useEstopContext() const isUnboxingFlowOngoing = useIsUnboxingFlowOngoing() const closeModal = (): void => { - if (estopStatus?.data.status === DISENGAGED) { - setIsEmergencyStopModalDismissed(false) - } + setIsWatingForResumeOperation(false) } const localRobot = useSelector(getLocalRobot) const localRobotName = localRobot?.name ?? 'no name' const TargetEstopModal = (): JSX.Element | null => { - switch (estopStatus?.data.status) { - case PHYSICALLY_ENGAGED: - case LOGICALLY_ENGAGED: - return ( - { - setIsWaitingForLogicalDisengage(true) - }} - /> - ) - case NOT_PRESENT: - return ( - - ) - default: - return null - } + return estopState === NOT_PRESENT ? ( + + ) : estopState !== DISENGAGED || isWaitingForResumeOperation ? ( + { + setIsWatingForResumeOperation(true) + }} + /> + ) : null } return ( <> - {estopStatus?.data.status !== DISENGAGED && !isUnboxingFlowOngoing ? ( + {showEmergencyStopModal && !isUnboxingFlowOngoing ? ( ) : null} diff --git a/app/src/organisms/EmergencyStop/__tests__/EstopPressedModal.test.tsx b/app/src/organisms/EmergencyStop/__tests__/EstopPressedModal.test.tsx index 124ea72b3ed..067211c4c06 100644 --- a/app/src/organisms/EmergencyStop/__tests__/EstopPressedModal.test.tsx +++ b/app/src/organisms/EmergencyStop/__tests__/EstopPressedModal.test.tsx @@ -8,9 +8,11 @@ import { useAcknowledgeEstopDisengageMutation } from '@opentrons/react-api-clien import { i18n } from '/app/i18n' import { getIsOnDevice } from '/app/redux/config' import { EstopPressedModal } from '../EstopPressedModal' +import { usePlacePlateReaderLid } from '/app/resources/modules' vi.mock('@opentrons/react-api-client') vi.mock('/app/redux/config') +vi.mock('/app/resources/modules') const render = (props: React.ComponentProps) => { return renderWithProviders(, { @@ -25,13 +27,19 @@ describe('EstopPressedModal - Touchscreen', () => { props = { isEngaged: true, closeModal: vi.fn(), - isWaitingForLogicalDisengage: false, - setShouldSeeLogicalDisengage: vi.fn(), + isWaitingForResumeOperation: false, + setIsWaitingForResumeOperation: vi.fn(), } vi.mocked(getIsOnDevice).mockReturnValue(true) vi.mocked(useAcknowledgeEstopDisengageMutation).mockReturnValue({ setEstopPhysicalStatus: vi.fn(), } as any) + + vi.mocked(usePlacePlateReaderLid).mockReturnValue({ + handlePlaceReaderLid: vi.fn(), + isValidPlateReaderMove: false, + isExecuting: false, + }) }) it('should render text and button', () => { @@ -59,6 +67,20 @@ describe('EstopPressedModal - Touchscreen', () => { render(props) fireEvent.click(screen.getByText('Resume robot operations')) expect(useAcknowledgeEstopDisengageMutation).toHaveBeenCalled() + expect(usePlacePlateReaderLid).toHaveBeenCalled() + }) + + it('should call a mock function to place the labware to a slot', () => { + vi.mocked(usePlacePlateReaderLid).mockReturnValue({ + handlePlaceReaderLid: vi.fn(), + isValidPlateReaderMove: true, + isExecuting: true, + }) + + render(props) + fireEvent.click(screen.getByText('Resume robot operations')) + expect(useAcknowledgeEstopDisengageMutation).toHaveBeenCalled() + expect(usePlacePlateReaderLid).toHaveBeenCalled() }) }) @@ -69,15 +91,19 @@ describe('EstopPressedModal - Desktop', () => { props = { isEngaged: true, closeModal: vi.fn(), - isDismissedModal: false, - setIsDismissedModal: vi.fn(), - isWaitingForLogicalDisengage: false, - setShouldSeeLogicalDisengage: vi.fn(), + isWaitingForResumeOperation: false, + setIsWaitingForResumeOperation: vi.fn(), } vi.mocked(getIsOnDevice).mockReturnValue(false) vi.mocked(useAcknowledgeEstopDisengageMutation).mockReturnValue({ setEstopPhysicalStatus: vi.fn(), } as any) + + vi.mocked(usePlacePlateReaderLid).mockReturnValue({ + handlePlaceReaderLid: vi.fn(), + isValidPlateReaderMove: false, + isExecuting: false, + }) }) it('should render text and button', () => { render(props) @@ -99,10 +125,18 @@ describe('EstopPressedModal - Desktop', () => { ).not.toBeDisabled() }) + it('should resume robot operation button is disabled when waiting for labware plate to finish', () => { + props.isEngaged = false + props.isWaitingForResumeOperation = true + render(props) + expect( + screen.getByRole('button', { name: 'Resume robot operations' }) + ).toBeDisabled() + }) + it('should call a mock function when clicking close icon', () => { render(props) fireEvent.click(screen.getByTestId('ModalHeader_icon_close_E-stop pressed')) - expect(props.setIsDismissedModal).toHaveBeenCalled() expect(props.closeModal).toHaveBeenCalled() }) diff --git a/app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard.tsx b/app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard.tsx index 5f38dfabf48..e1dd7c5add2 100644 --- a/app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard.tsx @@ -29,7 +29,7 @@ import { import { RecoveryInProgress } from './RecoveryInProgress' import { getErrorKind } from './utils' import { RECOVERY_MAP } from './constants' -import { useHomeGripperZAxis } from './hooks' +import { useHomeGripper } from './hooks' import type { LabwareDefinition2, RobotType } from '@opentrons/shared-data' import type { RecoveryRoute, RouteStep, RecoveryContentProps } from './types' @@ -90,7 +90,7 @@ export function ErrorRecoveryWizard( routeUpdateActions, }) - useHomeGripperZAxis(props) + useHomeGripper(props) return } diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SelectRecoveryOption.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SelectRecoveryOption.tsx index 8acc69c8ab6..c44252e2da9 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SelectRecoveryOption.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SelectRecoveryOption.tsx @@ -1,8 +1,10 @@ import { useState, useEffect } from 'react' import head from 'lodash/head' import { useTranslation } from 'react-i18next' +import { css } from 'styled-components' import { + RESPONSIVENESS, DIRECTION_COLUMN, Flex, SPACING, @@ -108,11 +110,7 @@ export function RecoveryOptions({ isOnDevice, }: RecoveryOptionsProps): JSX.Element { return ( - + {validRecoveryOptions.map((recoveryOption: RecoveryRoute) => { const optionName = getRecoveryOptionCopy(recoveryOption, errorKind) return ( @@ -133,6 +131,16 @@ export function RecoveryOptions({ ) } +const RECOVERY_OPTION_CONTAINER_STYLE = css` + flex-direction: ${DIRECTION_COLUMN}; + grid-gap: ${SPACING.spacing4}; + width: 100%; + + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + grid-gap: ${SPACING.spacing8}; + } +` + // Pre-fetch tip attachment status. Users are not blocked from proceeding at this step. export function useCurrentTipStatus( determineTipStatus: () => Promise diff --git a/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx b/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx index a00335f6475..d73d402585d 100644 --- a/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx @@ -8,7 +8,7 @@ import { RUN_STATUS_RUNNING, RUN_STATUS_STOP_REQUESTED, } from '@opentrons/api-client' -import { getLabwareDefinitionsFromCommands } from '/app/local-resources/labware' +import { getLoadedLabwareDefinitionsByUri } from '@opentrons/shared-data' import { renderWithProviders } from '/app/__testing-utils__' import { i18n } from '/app/i18n' @@ -33,7 +33,13 @@ vi.mock('/app/redux/config') vi.mock('../RecoverySplash') vi.mock('/app/redux-resources/analytics') vi.mock('@opentrons/react-api-client') -vi.mock('/app/local-resources/labware') +vi.mock('@opentrons/shared-data', async () => { + const actual = await vi.importActual('@opentrons/shared-data') + return { + ...actual, + getLoadedLabwareDefinitionsByUri: vi.fn(), + } +}) vi.mock('react-redux', async () => { const actual = await vi.importActual('react-redux') return { @@ -45,7 +51,6 @@ vi.mock('react-redux', async () => { describe('useErrorRecoveryFlows', () => { beforeEach(() => { vi.mocked(useCurrentlyRecoveringFrom).mockReturnValue('mockCommand' as any) - vi.mocked(getLabwareDefinitionsFromCommands).mockReturnValue([]) }) it('should have initial state of isERActive as false', () => { @@ -143,7 +148,7 @@ describe('ErrorRecoveryFlows', () => { runStatus: RUN_STATUS_AWAITING_RECOVERY, failedCommandByRunRecord: mockFailedCommand, runId: 'MOCK_RUN_ID', - protocolAnalysis: {} as any, + protocolAnalysis: null, } vi.mocked(ErrorRecoveryWizard).mockReturnValue(
MOCK WIZARD
) vi.mocked(RecoverySplash).mockReturnValue(
MOCK RUN PAUSED SPLASH
) @@ -168,6 +173,7 @@ describe('ErrorRecoveryFlows', () => { intent: 'recovering', showTakeover: false, }) + vi.mocked(getLoadedLabwareDefinitionsByUri).mockReturnValue({}) }) it('renders the wizard when showERWizard is true', () => { diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.tsx similarity index 69% rename from app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.ts rename to app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.tsx index ea5ca7c365a..a24afb09b29 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.tsx @@ -1,13 +1,19 @@ import { describe, it, expect } from 'vitest' -import { renderHook } from '@testing-library/react' +import { screen, renderHook } from '@testing-library/react' +import { renderWithProviders } from '/app/__testing-utils__' +import { i18n } from '/app/i18n' import { getRelevantWellName, getRelevantFailedLabwareCmdFrom, useRelevantFailedLwLocations, + useInitialSelectedLocationsFrom, } from '../useFailedLabwareUtils' import { DEFINED_ERROR_TYPES } from '../../constants' +import type { ComponentProps } from 'react' +import type { GetRelevantLwLocationsParams } from '../useFailedLabwareUtils' + describe('getRelevantWellName', () => { const failedPipetteInfo = { data: { @@ -159,12 +165,26 @@ describe('getRelevantFailedLabwareCmdFrom', () => { }) }) -// TODO(jh 10-15-24): This testing will can more useful once translation is refactored out of this function. +const TestWrapper = (props: GetRelevantLwLocationsParams) => { + const displayLocation = useRelevantFailedLwLocations(props) + return ( + <> +
{`Current Loc: ${displayLocation.displayNameCurrentLoc}`}
+
{`New Loc: ${displayLocation.displayNameNewLoc}`}
+ + ) +} + +const render = (props: ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + describe('useRelevantFailedLwLocations', () => { - const mockProtocolAnalysis = {} as any - const mockAllRunDefs = [] as any + const mockRunRecord = { data: { modules: [], labware: [] } } as any const mockFailedLabware = { - location: { slot: 'D1' }, + location: { slotName: 'D1' }, } as any it('should return current location for non-moveLabware commands', () => { @@ -172,41 +192,72 @@ describe('useRelevantFailedLwLocations', () => { commandType: 'aspirate', } as any + render({ + failedLabware: mockFailedLabware, + failedCommandByRunRecord: mockFailedCommand, + runRecord: mockRunRecord, + }) + + screen.getByText('Current Loc: Slot D1') + screen.getByText('New Loc: null') + const { result } = renderHook(() => useRelevantFailedLwLocations({ failedLabware: mockFailedLabware, failedCommandByRunRecord: mockFailedCommand, - protocolAnalysis: mockProtocolAnalysis, - allRunDefs: mockAllRunDefs, + runRecord: mockRunRecord, }) ) - expect(result.current).toEqual({ - currentLoc: '', - newLoc: null, - }) + expect(result.current.currentLoc).toStrictEqual({ slotName: 'D1' }) + expect(result.current.newLoc).toBeNull() }) - it('should return current and new location for moveLabware commands', () => { + it('should return current and new locations for moveLabware commands', () => { const mockFailedCommand = { commandType: 'moveLabware', params: { - newLocation: { slot: 'C2' }, + newLocation: { slotName: 'C2' }, }, } as any + render({ + failedLabware: mockFailedLabware, + failedCommandByRunRecord: mockFailedCommand, + runRecord: mockRunRecord, + }) + + screen.getByText('Current Loc: Slot D1') + screen.getByText('New Loc: Slot C2') + const { result } = renderHook(() => useRelevantFailedLwLocations({ failedLabware: mockFailedLabware, failedCommandByRunRecord: mockFailedCommand, - protocolAnalysis: mockProtocolAnalysis, - allRunDefs: mockAllRunDefs, + runRecord: mockRunRecord, }) ) - expect(result.current).toEqual({ - currentLoc: '', - newLoc: null, - }) + expect(result.current.currentLoc).toStrictEqual({ slotName: 'D1' }) + expect(result.current.newLoc).toStrictEqual({ slotName: 'C2' }) + }) +}) + +describe('useInitialSelectedLocationsFrom', () => { + it('updates result if the relevant command changes', () => { + const cmd = { commandType: 'pickUpTip', params: { wellName: 'A1' } } as any + const cmd2 = { commandType: 'pickUpTip', params: { wellName: 'A2' } } as any + + const { result, rerender } = renderHook((cmd: any) => + useInitialSelectedLocationsFrom(cmd) + ) + + rerender(cmd) + + expect(result.current).toStrictEqual({ A1: null }) + + rerender(cmd2) + + expect(result.current).toStrictEqual({ A2: null }) }) }) diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useHomeGripperZAxis.test.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useHomeGripperZAxis.test.ts index 197dfbfd3e7..32f5d939eb8 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useHomeGripperZAxis.test.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useHomeGripperZAxis.test.ts @@ -1,12 +1,14 @@ import { renderHook, act } from '@testing-library/react' import { describe, it, expect, vi, beforeEach } from 'vitest' -import { useHomeGripperZAxis } from '../useHomeGripperZAxis' +import { useHomeGripper } from '../useHomeGripper' import { RECOVERY_MAP } from '/app/organisms/ErrorRecoveryFlows/constants' -describe('useHomeGripperZAxis', () => { +describe('useHomeGripper', () => { const mockRecoveryCommands = { - homeGripperZAxis: vi.fn().mockResolvedValue(undefined), + updatePositionEstimatorsAndHomeGripper: vi + .fn() + .mockResolvedValue(undefined), } const mockRouteUpdateActions = { @@ -28,7 +30,7 @@ describe('useHomeGripperZAxis', () => { it('should home gripper Z axis when in manual gripper step and door is closed', async () => { renderHook(() => { - useHomeGripperZAxis({ + useHomeGripper({ recoveryCommands: mockRecoveryCommands, routeUpdateActions: mockRouteUpdateActions, recoveryMap: mockRecoveryMap, @@ -43,7 +45,9 @@ describe('useHomeGripperZAxis', () => { expect(mockRouteUpdateActions.handleMotionRouting).toHaveBeenCalledWith( true ) - expect(mockRecoveryCommands.homeGripperZAxis).toHaveBeenCalled() + expect( + mockRecoveryCommands.updatePositionEstimatorsAndHomeGripper + ).toHaveBeenCalled() expect(mockRouteUpdateActions.handleMotionRouting).toHaveBeenCalledWith( false ) @@ -51,7 +55,7 @@ describe('useHomeGripperZAxis', () => { it('should go back to previous step when door is open', () => { renderHook(() => { - useHomeGripperZAxis({ + useHomeGripper({ recoveryCommands: mockRecoveryCommands, routeUpdateActions: mockRouteUpdateActions, recoveryMap: mockRecoveryMap, @@ -60,12 +64,14 @@ describe('useHomeGripperZAxis', () => { }) expect(mockRouteUpdateActions.goBackPrevStep).toHaveBeenCalled() - expect(mockRecoveryCommands.homeGripperZAxis).not.toHaveBeenCalled() + expect( + mockRecoveryCommands.updatePositionEstimatorsAndHomeGripper + ).not.toHaveBeenCalled() }) it('should not home again if already homed once', async () => { const { rerender } = renderHook(() => { - useHomeGripperZAxis({ + useHomeGripper({ recoveryCommands: mockRecoveryCommands, routeUpdateActions: mockRouteUpdateActions, recoveryMap: mockRecoveryMap, @@ -77,17 +83,21 @@ describe('useHomeGripperZAxis', () => { await new Promise(resolve => setTimeout(resolve, 0)) }) - expect(mockRecoveryCommands.homeGripperZAxis).toHaveBeenCalledTimes(1) + expect( + mockRecoveryCommands.updatePositionEstimatorsAndHomeGripper + ).toHaveBeenCalledTimes(1) rerender() - expect(mockRecoveryCommands.homeGripperZAxis).toHaveBeenCalledTimes(1) + expect( + mockRecoveryCommands.updatePositionEstimatorsAndHomeGripper + ).toHaveBeenCalledTimes(1) }) it('should reset hasHomedOnce when step changes to non-manual gripper step and back', async () => { const { rerender } = renderHook( ({ recoveryMap }) => { - useHomeGripperZAxis({ + useHomeGripper({ recoveryCommands: mockRecoveryCommands, routeUpdateActions: mockRouteUpdateActions, recoveryMap, @@ -103,7 +113,9 @@ describe('useHomeGripperZAxis', () => { await new Promise(resolve => setTimeout(resolve, 0)) }) - expect(mockRecoveryCommands.homeGripperZAxis).toHaveBeenCalledTimes(1) + expect( + mockRecoveryCommands.updatePositionEstimatorsAndHomeGripper + ).toHaveBeenCalledTimes(1) rerender({ recoveryMap: { step: 'SOME_OTHER_STEP' } as any }) @@ -117,6 +129,8 @@ describe('useHomeGripperZAxis', () => { await new Promise(resolve => setTimeout(resolve, 0)) }) - expect(mockRecoveryCommands.homeGripperZAxis).toHaveBeenCalledTimes(2) + expect( + mockRecoveryCommands.updatePositionEstimatorsAndHomeGripper + ).toHaveBeenCalledTimes(2) }) }) diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts index 11a15edfbfd..4079e8a8f1e 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts @@ -5,6 +5,7 @@ import { useResumeRunFromRecoveryMutation, useStopRunMutation, useUpdateErrorRecoveryPolicy, + useResumeRunFromRecoveryAssumingFalsePositiveMutation, } from '@opentrons/react-api-client' import { useChainRunCommands } from '/app/resources/runs' @@ -14,12 +15,16 @@ import { RELEASE_GRIPPER_JAW, buildPickUpTips, buildIgnorePolicyRules, - HOME_GRIPPER_Z_AXIS, + isAssumeFalsePositiveResumeKind, + UPDATE_ESTIMATORS_EXCEPT_PLUNGERS, + HOME_GRIPPER_Z, } from '../useRecoveryCommands' -import { RECOVERY_MAP } from '../../constants' +import { RECOVERY_MAP, ERROR_KINDS } from '../../constants' +import { getErrorKind } from '/app/organisms/ErrorRecoveryFlows/utils' vi.mock('@opentrons/react-api-client') vi.mock('/app/resources/runs') +vi.mock('/app/organisms/ErrorRecoveryFlows/utils') describe('useRecoveryCommands', () => { const mockFailedCommand = { @@ -40,6 +45,9 @@ describe('useRecoveryCommands', () => { const mockResumeRunFromRecovery = vi.fn(() => Promise.resolve(mockMakeSuccessToast()) ) + const mockResumeRunFromRecoveryAssumingFalsePositive = vi.fn(() => + Promise.resolve(mockMakeSuccessToast()) + ) const mockStopRun = vi.fn() const mockChainRunCommands = vi.fn().mockResolvedValue([]) const mockReportActionSelectedResult = vi.fn() @@ -72,6 +80,11 @@ describe('useRecoveryCommands', () => { vi.mocked(useUpdateErrorRecoveryPolicy).mockReturnValue({ mutateAsync: mockUpdateErrorRecoveryPolicy, } as any) + vi.mocked( + useResumeRunFromRecoveryAssumingFalsePositiveMutation + ).mockReturnValue({ + mutateAsync: mockResumeRunFromRecoveryAssumingFalsePositive, + } as any) }) it('should call chainRunRecoveryCommands with continuePastCommandFailure set to false', async () => { @@ -264,15 +277,15 @@ describe('useRecoveryCommands', () => { ) }) - it('should call homeGripperZAxis and resolve the promise', async () => { + it('should call useUpdatePositionEstimators and resolve the promise', async () => { const { result } = renderHook(() => useRecoveryCommands(props)) await act(async () => { - await result.current.homeGripperZAxis() + await result.current.updatePositionEstimatorsAndHomeGripper() }) expect(mockChainRunCommands).toHaveBeenCalledWith( - [HOME_GRIPPER_Z_AXIS], + [UPDATE_ESTIMATORS_EXCEPT_PLUNGERS, HOME_GRIPPER_Z], false ) }) @@ -316,7 +329,8 @@ describe('useRecoveryCommands', () => { const expectedPolicyRules = buildIgnorePolicyRules( 'aspirateInPlace', - 'mockErrorType' + 'mockErrorType', + 'ignoreAndContinue' ) expect(mockUpdateErrorRecoveryPolicy).toHaveBeenCalledWith( @@ -353,4 +367,54 @@ describe('useRecoveryCommands', () => { RECOVERY_MAP.ERROR_WHILE_RECOVERING.ROUTE ) }) + + describe('skipFailedCommand with false positive handling', () => { + it('should call resumeRunFromRecoveryAssumingFalsePositive for tip-related errors', async () => { + vi.mocked(getErrorKind).mockReturnValue(ERROR_KINDS.TIP_NOT_DETECTED) + + const { result } = renderHook(() => useRecoveryCommands(props)) + + await act(async () => { + await result.current.skipFailedCommand() + }) + + expect( + mockResumeRunFromRecoveryAssumingFalsePositive + ).toHaveBeenCalledWith(mockRunId) + expect(mockMakeSuccessToast).toHaveBeenCalled() + }) + + it('should call regular resumeRunFromRecovery for non-tip-related errors', async () => { + vi.mocked(getErrorKind).mockReturnValue(ERROR_KINDS.GRIPPER_ERROR) + + const { result } = renderHook(() => useRecoveryCommands(props)) + + await act(async () => { + await result.current.skipFailedCommand() + }) + + expect(mockResumeRunFromRecovery).toHaveBeenCalledWith(mockRunId) + expect(mockMakeSuccessToast).toHaveBeenCalled() + }) + }) +}) + +describe('isAssumeFalsePositiveResumeKind', () => { + it(`should return true for ${ERROR_KINDS.TIP_NOT_DETECTED} error kind`, () => { + vi.mocked(getErrorKind).mockReturnValue(ERROR_KINDS.TIP_NOT_DETECTED) + + expect(isAssumeFalsePositiveResumeKind({} as any)).toBe(true) + }) + + it(`should return true for ${ERROR_KINDS.TIP_DROP_FAILED} error kind`, () => { + vi.mocked(getErrorKind).mockReturnValue(ERROR_KINDS.TIP_DROP_FAILED) + + expect(isAssumeFalsePositiveResumeKind({} as any)).toBe(true) + }) + + it('should return false for other error kinds', () => { + vi.mocked(getErrorKind).mockReturnValue(ERROR_KINDS.GRIPPER_ERROR) + + expect(isAssumeFalsePositiveResumeKind({} as any)).toBe(false) + }) }) diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/index.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/index.ts index baa685c0dcc..75904a24966 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/index.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/index.ts @@ -5,7 +5,7 @@ export { useRouteUpdateActions } from './useRouteUpdateActions' export { useERUtils } from './useERUtils' export { useRecoveryTakeover } from './useRecoveryTakeover' export { useRetainedFailedCommandBySource } from './useRetainedFailedCommandBySource' -export { useHomeGripperZAxis } from './useHomeGripperZAxis' +export { useHomeGripper } from './useHomeGripper' export type { ERUtilsProps } from './useERUtils' export type { UseRouteUpdateActionsResult } from './useRouteUpdateActions' diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useDeckMapUtils.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useDeckMapUtils.ts index 95dac5abdb7..06453d06d08 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useDeckMapUtils.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useDeckMapUtils.ts @@ -2,7 +2,6 @@ import { useMemo } from 'react' import { getDeckDefFromRobotType, - getLoadedLabwareDefinitionsByUri, getFixedTrashLabwareDefinition, getModuleDef2, getPositionFromSlotId, @@ -11,6 +10,11 @@ import { THERMOCYCLER_MODULE_V1, } from '@opentrons/shared-data' +import { + getRunLabwareRenderInfo, + getRunModuleRenderInfo, +} from '/app/organisms/InterventionModal/utils' + import type { Run } from '@opentrons/api-client' import type { DeckDefinition, @@ -22,14 +26,21 @@ import type { LoadedLabware, RobotType, LabwareDefinitionsByUri, + LoadedModule, } from '@opentrons/shared-data' import type { ErrorRecoveryFlowsProps } from '..' import type { UseFailedLabwareUtilsResult } from './useFailedLabwareUtils' +import type { + RunLabwareInfo, + RunModuleInfo, +} from '/app/organisms/InterventionModal/utils' +import type { ERUtilsProps } from './useERUtils' interface UseDeckMapUtilsProps { runId: ErrorRecoveryFlowsProps['runId'] protocolAnalysis: ErrorRecoveryFlowsProps['protocolAnalysis'] failedLabwareUtils: UseFailedLabwareUtilsResult + labwareDefinitionsByUri: ERUtilsProps['labwareDefinitionsByUri'] runRecord?: Run } @@ -37,6 +48,11 @@ export interface UseDeckMapUtilsResult { deckConfig: CutoutConfigProtocolSpec[] modulesOnDeck: RunCurrentModulesOnDeck[] labwareOnDeck: RunCurrentLabwareOnDeck[] + loadedLabware: LoadedLabware[] + loadedModules: LoadedModule[] + movedLabwareDef: LabwareDefinition2 | null + moduleRenderInfo: RunModuleInfo[] + labwareRenderInfo: RunLabwareInfo[] highlightLabwareEventuallyIn: string[] kind: 'intervention' robotType: RobotType @@ -47,19 +63,12 @@ export function useDeckMapUtils({ runRecord, runId, failedLabwareUtils, + labwareDefinitionsByUri, }: UseDeckMapUtilsProps): UseDeckMapUtilsResult { const robotType = protocolAnalysis?.robotType ?? OT2_ROBOT_TYPE const deckConfig = getSimplestDeckConfigForProtocol(protocolAnalysis) const deckDef = getDeckDefFromRobotType(robotType) - const labwareDefinitionsByUri = useMemo( - () => - protocolAnalysis != null - ? getLoadedLabwareDefinitionsByUri(protocolAnalysis?.commands) - : null, - [protocolAnalysis] - ) - const currentModulesInfo = useMemo( () => getRunCurrentModulesInfo({ @@ -93,6 +102,35 @@ export function useDeckMapUtils({ [runId, protocolAnalysis, runRecord, deckDef, failedLabwareUtils] ) + const movedLabwareDef = + labwareDefinitionsByUri != null && failedLabwareUtils.failedLabware != null + ? labwareDefinitionsByUri[failedLabwareUtils.failedLabware.definitionUri] + : null + + const moduleRenderInfo = useMemo( + () => + runRecord != null && labwareDefinitionsByUri != null + ? getRunModuleRenderInfo( + runRecord.data, + deckDef, + labwareDefinitionsByUri + ) + : [], + [deckDef, labwareDefinitionsByUri, runRecord] + ) + + const labwareRenderInfo = useMemo( + () => + runRecord != null && labwareDefinitionsByUri != null + ? getRunLabwareRenderInfo( + runRecord.data, + labwareDefinitionsByUri, + deckDef + ) + : [], + [deckDef, labwareDefinitionsByUri, runRecord] + ) + return { deckConfig, modulesOnDeck: runCurrentModules.map( @@ -112,6 +150,11 @@ export function useDeckMapUtils({ .filter(maybeSlot => maybeSlot != null) as string[], kind: 'intervention', robotType, + loadedModules: runRecord?.data.modules ?? [], + loadedLabware: runRecord?.data.labware ?? [], + movedLabwareDef, + moduleRenderInfo, + labwareRenderInfo, } } diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts index 365bf01de36..57691a30e55 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts @@ -20,7 +20,11 @@ import { useShowDoorInfo } from './useShowDoorInfo' import { useCleanupRecoveryState } from './useCleanupRecoveryState' import { useFailedPipetteUtils } from './useFailedPipetteUtils' -import type { LabwareDefinition2, RobotType } from '@opentrons/shared-data' +import type { + LabwareDefinition2, + LabwareDefinitionsByUri, + RobotType, +} from '@opentrons/shared-data' import type { IRecoveryMap, RouteStep, RecoveryRoute } from '../types' import type { ErrorRecoveryFlowsProps } from '..' import type { UseRouteUpdateActionsResult } from './useRouteUpdateActions' @@ -48,6 +52,7 @@ export type ERUtilsProps = Omit & { failedCommand: ReturnType showTakeover: boolean allRunDefs: LabwareDefinition2[] + labwareDefinitionsByUri: LabwareDefinitionsByUri | null } export interface ERUtilsResults { @@ -82,6 +87,7 @@ export function useERUtils({ runStatus, showTakeover, allRunDefs, + labwareDefinitionsByUri, }: ERUtilsProps): ERUtilsResults { const { data: attachedInstruments } = useInstrumentsQuery() const { data: runRecord } = useNotifyRunQuery(runId) @@ -151,7 +157,6 @@ export function useERUtils({ failedPipetteInfo, runRecord, runCommands, - allRunDefs, }) const recoveryCommands = useRecoveryCommands({ @@ -169,6 +174,7 @@ export function useERUtils({ runRecord, protocolAnalysis, failedLabwareUtils, + labwareDefinitionsByUri, }) const recoveryActionMutationUtils = useRecoveryActionMutation( diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts index c1925a752c3..9ce04df1bdf 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts @@ -28,6 +28,7 @@ import type { MoveLabwareRunTimeCommand, LabwareLocation, } from '@opentrons/shared-data' +import type { LabwareDisplayLocationSlotOnly } from '/app/local-resources/labware' import type { ErrorRecoveryFlowsProps } from '..' import type { ERUtilsProps } from './useERUtils' @@ -35,14 +36,15 @@ interface UseFailedLabwareUtilsProps { failedCommandByRunRecord: ERUtilsProps['failedCommandByRunRecord'] protocolAnalysis: ErrorRecoveryFlowsProps['protocolAnalysis'] failedPipetteInfo: PipetteData | null - allRunDefs: LabwareDefinition2[] runCommands?: CommandsData runRecord?: Run } interface RelevantFailedLabwareLocations { - currentLoc: string - newLoc: string | null + displayNameCurrentLoc: string + displayNameNewLoc: string | null + currentLoc: LabwareLocation | null + newLoc: LabwareLocation | null } export type UseFailedLabwareUtilsResult = UseTipSelectionUtilsResult & { @@ -54,6 +56,7 @@ export type UseFailedLabwareUtilsResult = UseTipSelectionUtilsResult & { relevantWellName: string | null /* The user-content nickname of the failed labware, if any */ failedLabwareNickname: string | null + /* Details relating to the labware location. */ failedLabwareLocations: RelevantFailedLabwareLocations } @@ -69,7 +72,6 @@ export function useFailedLabwareUtils({ failedPipetteInfo, runCommands, runRecord, - allRunDefs, }: UseFailedLabwareUtilsProps): UseFailedLabwareUtilsResult { const recentRelevantFailedLabwareCmd = useMemo( () => @@ -105,8 +107,7 @@ export function useFailedLabwareUtils({ const failedLabwareLocations = useRelevantFailedLwLocations({ failedLabware, failedCommandByRunRecord, - protocolAnalysis, - allRunDefs, + runRecord, }) return { @@ -213,8 +214,9 @@ function useTipSelectionUtils( const initialLocs = useInitialSelectedLocationsFrom( recentRelevantFailedLabwareCmd ) - // Set the initial locs when they first become available. - if (selectedLocs == null && initialLocs != null) { + + // Set the initial locs when they first become available or update. + if (selectedLocs !== initialLocs) { setSelectedLocs(initialLocs) } @@ -252,17 +254,20 @@ function useTipSelectionUtils( } // Set the initial well selection to be the last pickup tip location for the pipette used in the failed command. -function useInitialSelectedLocationsFrom( +export function useInitialSelectedLocationsFrom( recentRelevantFailedLabwareCmd: FailedCommandRelevantLabware ): WellGroup | null { const [initialWells, setInitialWells] = useState(null) // Note that while other commands may have a wellName associated with them, // we are only interested in wells for the purposes of tip picking up. + // Support state updates if the underlying data changes, since this data is lazily loaded and may change shortly + // after Error Recovery launches. if ( recentRelevantFailedLabwareCmd != null && recentRelevantFailedLabwareCmd.commandType === 'pickUpTip' && - initialWells == null + (initialWells == null || + !(recentRelevantFailedLabwareCmd.params.wellName in initialWells)) ) { setInitialWells({ [recentRelevantFailedLabwareCmd.params.wellName]: null }) } @@ -337,9 +342,9 @@ export function getRelevantWellName( } } -type GetRelevantLwLocationsParams = Pick< +export type GetRelevantLwLocationsParams = Pick< UseFailedLabwareUtilsProps, - 'protocolAnalysis' | 'failedCommandByRunRecord' | 'allRunDefs' + 'runRecord' | 'failedCommandByRunRecord' > & { failedLabware: UseFailedLabwareUtilsResult['failedLabware'] } @@ -347,41 +352,51 @@ type GetRelevantLwLocationsParams = Pick< export function useRelevantFailedLwLocations({ failedLabware, failedCommandByRunRecord, - protocolAnalysis, - allRunDefs, + runRecord, }: GetRelevantLwLocationsParams): RelevantFailedLabwareLocations { const { t } = useTranslation('protocol_command_text') - const currentLocation = getLabwareDisplayLocation({ - loadedLabwares: protocolAnalysis?.labware ?? [], - loadedModules: protocolAnalysis?.modules ?? [], - location: failedLabware?.location ?? null, - allRunDefs, + const BASE_DISPLAY_PARAMS: Omit< + LabwareDisplayLocationSlotOnly, + 'location' + > = { + loadedLabwares: runRecord?.data?.labware ?? [], + loadedModules: runRecord?.data?.modules ?? [], robotType: FLEX_ROBOT_TYPE, t, + detailLevel: 'slot-only', + isOnDevice: false, // Always return the "slot XYZ" copy, which is the desktop copy. + } + + const displayNameCurrentLoc = getLabwareDisplayLocation({ + ...BASE_DISPLAY_PARAMS, + location: failedLabware?.location ?? null, }) - const getNewLocation = (): LabwareLocation | null => { + const getNewLocation = (): Pick< + RelevantFailedLabwareLocations, + 'displayNameNewLoc' | 'newLoc' + > => { switch (failedCommandByRunRecord?.commandType) { case 'moveLabware': - return failedCommandByRunRecord.params.newLocation + return { + displayNameNewLoc: getLabwareDisplayLocation({ + ...BASE_DISPLAY_PARAMS, + location: failedCommandByRunRecord.params.newLocation, + }), + newLoc: failedCommandByRunRecord.params.newLocation, + } default: - return null + return { + displayNameNewLoc: null, + newLoc: null, + } } } - const newLocationByDisplayName = getLabwareDisplayLocation({ - loadedLabwares: protocolAnalysis?.labware ?? [], - loadedModules: protocolAnalysis?.modules ?? [], - location: getNewLocation(), - allRunDefs, - robotType: FLEX_ROBOT_TYPE, - t, - }) - return { - currentLoc: currentLocation, - newLoc: - newLocationByDisplayName.length === 0 ? null : newLocationByDisplayName, + displayNameCurrentLoc, + currentLoc: failedLabware?.location ?? null, + ...getNewLocation(), } } diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useHomeGripperZAxis.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useHomeGripper.ts similarity index 82% rename from app/src/organisms/ErrorRecoveryFlows/hooks/useHomeGripperZAxis.ts rename to app/src/organisms/ErrorRecoveryFlows/hooks/useHomeGripper.ts index 649fb801d44..b165e59ebd4 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useHomeGripperZAxis.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useHomeGripper.ts @@ -3,8 +3,8 @@ import { RECOVERY_MAP } from '/app/organisms/ErrorRecoveryFlows/constants' import type { ErrorRecoveryWizardProps } from '/app/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard' -// Home the gripper z-axis implicitly. Because the z-home is not tied to a CTA, it must be handled here. -export function useHomeGripperZAxis({ +// Home the gripper implicitly. Because the home is not tied to a CTA, it must be handled here. +export function useHomeGripper({ recoveryCommands, routeUpdateActions, recoveryMap, @@ -20,7 +20,7 @@ export function useHomeGripperZAxis({ useLayoutEffect(() => { const { handleMotionRouting, goBackPrevStep } = routeUpdateActions - const { homeGripperZAxis } = recoveryCommands + const { updatePositionEstimatorsAndHomeGripper } = recoveryCommands if (!hasHomedOnce) { if (isManualGripperStep) { @@ -28,7 +28,7 @@ export function useHomeGripperZAxis({ void goBackPrevStep() } else { void handleMotionRouting(true) - .then(() => homeGripperZAxis()) + .then(() => updatePositionEstimatorsAndHomeGripper()) .then(() => { setHasHomedOnce(true) }) diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts index f0962b07693..7614dec4be3 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts @@ -5,10 +5,12 @@ import { useResumeRunFromRecoveryMutation, useStopRunMutation, useUpdateErrorRecoveryPolicy, + useResumeRunFromRecoveryAssumingFalsePositiveMutation, } from '@opentrons/react-api-client' import { useChainRunCommands } from '/app/resources/runs' -import { RECOVERY_MAP } from '../constants' +import { ERROR_KINDS, RECOVERY_MAP } from '../constants' +import { getErrorKind } from '/app/organisms/ErrorRecoveryFlows/utils' import type { CreateCommand, @@ -23,7 +25,9 @@ import type { } from '@opentrons/shared-data' import type { CommandData, + IfMatchType, RecoveryPolicyRulesParams, + RunAction, } from '@opentrons/api-client' import type { WellGroup } from '@opentrons/components' import type { FailedCommand, RecoveryRoute, RouteStep } from '../types' @@ -63,7 +67,7 @@ export interface UseRecoveryCommandsResult { /* A non-terminal recovery command */ releaseGripperJaws: () => Promise /* A non-terminal recovery command */ - homeGripperZAxis: () => Promise + updatePositionEstimatorsAndHomeGripper: () => Promise /* A non-terminal recovery command */ moveLabwareWithoutPause: () => Promise } @@ -89,6 +93,9 @@ export function useRecoveryCommands({ const { mutateAsync: resumeRunFromRecovery, } = useResumeRunFromRecoveryMutation() + const { + mutateAsync: resumeRunFromRecoveryAssumingFalsePositive, + } = useResumeRunFromRecoveryAssumingFalsePositiveMutation() const { stopRun } = useStopRunMutation() const { mutateAsync: updateErrorRecoveryPolicy, @@ -198,9 +205,16 @@ export function useRecoveryCommands({ const handleIgnoringErrorKind = useCallback((): Promise => { if (ignoreErrors) { if (failedCommandByRunRecord?.error != null) { + const ifMatch: IfMatchType = isAssumeFalsePositiveResumeKind( + failedCommandByRunRecord + ) + ? 'assumeFalsePositiveAndContinue' + : 'ignoreAndContinue' + const ignorePolicyRules = buildIgnorePolicyRules( failedCommandByRunRecord.commandType, - failedCommandByRunRecord.error.errorType + failedCommandByRunRecord.error.errorType, + ifMatch ) return updateErrorRecoveryPolicy(ignorePolicyRules) @@ -247,9 +261,17 @@ export function useRecoveryCommands({ stopRun(runId) }, [runId]) + const handleResumeAction = (): Promise => { + if (isAssumeFalsePositiveResumeKind(failedCommandByRunRecord)) { + return resumeRunFromRecoveryAssumingFalsePositive(runId) + } else { + return resumeRunFromRecovery(runId) + } + } + const skipFailedCommand = useCallback((): void => { void handleIgnoringErrorKind().then(() => - resumeRunFromRecovery(runId).then(() => { + handleResumeAction().then(() => { analytics.reportActionSelectedResult( selectedRecoveryOption, 'succeeded' @@ -269,8 +291,13 @@ export function useRecoveryCommands({ return chainRunRecoveryCommands([RELEASE_GRIPPER_JAW]) }, [chainRunRecoveryCommands]) - const homeGripperZAxis = useCallback((): Promise => { - return chainRunRecoveryCommands([HOME_GRIPPER_Z_AXIS]) + const updatePositionEstimatorsAndHomeGripper = useCallback((): Promise< + CommandData[] + > => { + return chainRunRecoveryCommands([ + UPDATE_ESTIMATORS_EXCEPT_PLUNGERS, + HOME_GRIPPER_Z, + ]) }, [chainRunRecoveryCommands]) const moveLabwareWithoutPause = useCallback((): Promise => { @@ -291,13 +318,27 @@ export function useRecoveryCommands({ homePipetteZAxes, pickUpTips, releaseGripperJaws, - homeGripperZAxis, + updatePositionEstimatorsAndHomeGripper, moveLabwareWithoutPause, skipFailedCommand, ignoreErrorKindThisRun, } } +export function isAssumeFalsePositiveResumeKind( + failedCommandByRunRecord: UseRecoveryCommandsParams['failedCommandByRunRecord'] +): boolean { + const errorKind = getErrorKind(failedCommandByRunRecord) + + switch (errorKind) { + case ERROR_KINDS.TIP_NOT_DETECTED: + case ERROR_KINDS.TIP_DROP_FAILED: + return true + default: + return false + } +} + export const HOME_PIPETTE_Z_AXES: CreateCommand = { commandType: 'home', params: { axes: ['leftZ', 'rightZ'] }, @@ -310,10 +351,15 @@ export const RELEASE_GRIPPER_JAW: CreateCommand = { intent: 'fixit', } -export const HOME_GRIPPER_Z_AXIS: CreateCommand = { +// in case the gripper does not know the position after a stall/collision we must update the position. +export const UPDATE_ESTIMATORS_EXCEPT_PLUNGERS: CreateCommand = { + commandType: 'unsafe/updatePositionEstimators', + params: { axes: ['x', 'y', 'extensionZ'] }, +} + +export const HOME_GRIPPER_Z: CreateCommand = { commandType: 'home', params: { axes: ['extensionZ'] }, - intent: 'fixit', } const buildMoveLabwareWithoutPause = ( @@ -362,13 +408,14 @@ export const buildPickUpTips = ( export const buildIgnorePolicyRules = ( commandType: FailedCommand['commandType'], - errorType: string + errorType: string, + ifMatch: IfMatchType ): RecoveryPolicyRulesParams => { return [ { commandType, errorType, - ifMatch: 'ignoreAndContinue', + ifMatch, }, ] } diff --git a/app/src/organisms/ErrorRecoveryFlows/index.tsx b/app/src/organisms/ErrorRecoveryFlows/index.tsx index 2bd26beb747..124c4fea65f 100644 --- a/app/src/organisms/ErrorRecoveryFlows/index.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/index.tsx @@ -13,11 +13,13 @@ import { RUN_STATUS_STOP_REQUESTED, RUN_STATUS_SUCCEEDED, } from '@opentrons/api-client' -import { OT2_ROBOT_TYPE } from '@opentrons/shared-data' +import { + getLoadedLabwareDefinitionsByUri, + OT2_ROBOT_TYPE, +} from '@opentrons/shared-data' import { useHost } from '@opentrons/react-api-client' import { getIsOnDevice } from '/app/redux/config' -import { getLabwareDefinitionsFromCommands } from '/app/local-resources/labware' import { ErrorRecoveryWizard, useERWizard } from './ErrorRecoveryWizard' import { RecoverySplash, useRecoverySplash } from './RecoverySplash' import { RecoveryTakeover } from './RecoveryTakeover' @@ -127,13 +129,19 @@ export function ErrorRecoveryFlows( const robotName = useHost()?.robotName ?? 'robot' const isValidRobotSideAnalysis = protocolAnalysis != null - const allRunDefs = useMemo( + + // TODO(jh, 10-22-24): EXEC-769. + const labwareDefinitionsByUri = useMemo( () => protocolAnalysis != null - ? getLabwareDefinitionsFromCommands(protocolAnalysis.commands) - : [], + ? getLoadedLabwareDefinitionsByUri(protocolAnalysis?.commands) + : null, [isValidRobotSideAnalysis] ) + const allRunDefs = + labwareDefinitionsByUri != null + ? Object.values(labwareDefinitionsByUri) + : [] const { showTakeover, @@ -151,6 +159,7 @@ export function ErrorRecoveryFlows( showTakeover, failedCommand: failedCommandBySource, allRunDefs, + labwareDefinitionsByUri, }) const renderWizard = diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/GripperReleaseLabware.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/GripperReleaseLabware.tsx index f57357f6451..3e0c24756d2 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/GripperReleaseLabware.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/GripperReleaseLabware.tsx @@ -7,6 +7,7 @@ import { RESPONSIVENESS, Flex, StyledText, + JUSTIFY_CENTER, } from '@opentrons/components' import { TwoColumn } from '/app/molecules/InterventionModal' @@ -15,6 +16,8 @@ import { RECOVERY_MAP } from '/app/organisms/ErrorRecoveryFlows/constants' import { RecoveryFooterButtons } from './RecoveryFooterButtons' import { RecoverySingleColumnContentWrapper } from './RecoveryContentWrapper' +import gripperReleaseAnimation from '/app/assets/videos/error-recovery/Gripper_Release.webm' + import type { JSX } from 'react' import type { RecoveryContentProps } from '../types' @@ -51,7 +54,20 @@ export function GripperReleaseLabware({ heading={t('labware_released_from_current_height')} />
-
ANIMATION GOES HERE
+ + + ['infoProps']['newLocationProps'] => - newLoc != null ? { deckLabel: newLoc.toUpperCase() } : undefined + displayNameNewLoc != null + ? { deckLabel: displayNameNewLoc.toUpperCase() } + : undefined return ( { + switch (selectedRecoveryOption) { + case MANUAL_MOVE_AND_SKIP.ROUTE: { + const { newLoc, currentLoc } = failedLabwareUtils.failedLabwareLocations + const { + movedLabwareDef, + moduleRenderInfo, + labwareRenderInfo, + ...restUtils + } = deckMapUtils + + const failedLwId = failedLabware?.id ?? '' + + const isValidDeck = + currentLoc != null && newLoc != null && movedLabwareDef != null + + return isValidDeck ? ( + + {moduleRenderInfo.map( + ({ + x, + y, + moduleId, + moduleDef, + nestedLabwareDef, + nestedLabwareId, + }) => ( + + {nestedLabwareDef != null && + nestedLabwareId !== failedLwId ? ( + + ) : null} + + ) + )} + {labwareRenderInfo + .filter(l => l.labwareId !== failedLwId) + .map(({ x, y, labwareDef, labwareId }) => ( + + {labwareDef != null && labwareId !== failedLwId ? ( + + ) : null} + + ))} + + } + /> + ) : ( + + ) + } + default: + return + } + } + return ( @@ -109,9 +187,7 @@ export function TwoColLwInfoAndDeck( type={buildType()} bannerText={buildBannerText()} /> - - - + {buildDeckView()} diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/GripperReleaseLabware.test.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/GripperReleaseLabware.test.tsx index 9eff4a09ba4..3bdd9f97819 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/GripperReleaseLabware.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/GripperReleaseLabware.test.tsx @@ -9,6 +9,10 @@ import { clickButtonLabeled } from '/app/organisms/ErrorRecoveryFlows/__tests__/ import type { Mock } from 'vitest' +vi.mock('/app/assets/videos/error-recovery/Gripper_Release.webm', () => ({ + default: 'mocked-animation-path.webm', +})) + const render = (props: React.ComponentProps) => { return renderWithProviders(, { i18nInstance: i18n, @@ -48,4 +52,14 @@ describe('GripperReleaseLabware', () => { expect(mockHandleMotionRouting).toHaveBeenCalled() }) + + it('renders gripper animation', () => { + render(props) + + screen.getByRole('presentation', { hidden: true }) + expect(screen.getByTestId('gripper-animation')).toHaveAttribute( + 'src', + 'mocked-animation-path.webm' + ) + }) }) diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/LeftColumnLabwareInfo.test.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/LeftColumnLabwareInfo.test.tsx index e2e6c268ef8..f38e1e06922 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/LeftColumnLabwareInfo.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/LeftColumnLabwareInfo.test.tsx @@ -27,8 +27,8 @@ describe('LeftColumnLabwareInfo', () => { failedLabwareName: 'MOCK_LW_NAME', failedLabwareNickname: 'MOCK_LW_NICKNAME', failedLabwareLocations: { - currentLoc: 'slot A1', - newLoc: 'slot B2', + displayNameCurrentLoc: 'slot A1', + displayNameNewLoc: 'slot B2', }, } as any, type: 'location', @@ -76,7 +76,7 @@ describe('LeftColumnLabwareInfo', () => { }) it('does not include newLocationProps when newLoc is not provided', () => { - props.failedLabwareUtils.failedLabwareLocations.newLoc = null + props.failedLabwareUtils.failedLabwareLocations.displayNameNewLoc = null render(props) expect(vi.mocked(InterventionContent)).toHaveBeenCalledWith( @@ -91,9 +91,12 @@ describe('LeftColumnLabwareInfo', () => { it('converts location labels to uppercase', () => { props.failedLabwareUtils.failedLabwareLocations = { - currentLoc: 'slot A1', - newLoc: 'slot B2', + displayNameCurrentLoc: 'slot A1', + displayNameNewLoc: 'slot B2', + newLoc: {} as any, + currentLoc: {} as any, } + render(props) expect(vi.mocked(InterventionContent)).toHaveBeenCalledWith( diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/SelectTips.test.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/SelectTips.test.tsx index 9a8fc10f5d6..08db6269c4d 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/SelectTips.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/SelectTips.test.tsx @@ -53,7 +53,10 @@ describe('SelectTips', () => { failedLabwareUtils: { selectedTipLocations: { A1: null }, areTipsSelected: true, - failedLabwareLocations: { newLoc: null, currentLoc: 'A1' }, + failedLabwareLocations: { + displayNameNewLoc: null, + displayNameCurrentLoc: 'A1', + }, } as any, } @@ -161,7 +164,10 @@ describe('SelectTips', () => { failedLabwareUtils: { selectedTipLocations: null, areTipsSelected: false, - failedLabwareLocations: { newLoc: null, currentLoc: '' }, + failedLabwareLocations: { + displayNameNewLoc: null, + displayNameCurrentLoc: '', + }, } as any, } diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/TwoColLwInfoAndDeck.test.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/TwoColLwInfoAndDeck.test.tsx index f2206c8f010..2f24fc0f3bb 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/TwoColLwInfoAndDeck.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/TwoColLwInfoAndDeck.test.tsx @@ -1,4 +1,7 @@ import { describe, it, vi, expect, beforeEach } from 'vitest' +import { screen } from '@testing-library/react' + +import { MoveLabwareOnDeck } from '@opentrons/components' import { renderWithProviders } from '/app/__testing-utils__' import { i18n } from '/app/i18n' @@ -11,6 +14,13 @@ import { getSlotNameAndLwLocFrom } from '../../hooks/useDeckMapUtils' import type * as React from 'react' import type { Mock } from 'vitest' +vi.mock('@opentrons/components', async () => { + const actual = await vi.importActual('@opentrons/components') + return { + ...actual, + MoveLabwareOnDeck: vi.fn(), + } +}) vi.mock('../LeftColumnLabwareInfo') vi.mock('../../hooks/useDeckMapUtils') @@ -39,11 +49,17 @@ describe('TwoColLwInfoAndDeck', () => { failedLabwareUtils: { relevantWellName: 'A1', failedLabware: { location: 'C1' }, + failedLabwareLocations: { newLoc: {}, currentLoc: {} }, + }, + deckMapUtils: { + movedLabwareDef: {}, + moduleRenderInfo: [], + labwareRenderInfo: [], }, - deckMapUtils: {}, currentRecoveryOptionUtils: { selectedRecoveryOption: RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE, }, + isOnDevice: true, } as any vi.mocked(LeftColumnLabwareInfo).mockReturnValue( @@ -131,4 +147,34 @@ describe('TwoColLwInfoAndDeck', () => { expect.anything() ) }) + + it(`renders a move labware on deck view if the selected recovery option is ${RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE} and props are valid`, () => { + vi.mocked(MoveLabwareOnDeck).mockReturnValue( +
MOCK_MOVE_LW_ON_DECK
+ ) + + props.currentRecoveryOptionUtils.selectedRecoveryOption = + RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE + render(props) + + screen.getByText('MOCK_MOVE_LW_ON_DECK') + }) + + it(`does not render a move labware on deck view if the selected recovery option is ${RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE} and props are invalid`, () => { + vi.mocked(MoveLabwareOnDeck).mockReturnValue( +
MOCK_MOVE_LW_ON_DECK
+ ) + + props.currentRecoveryOptionUtils.selectedRecoveryOption = + RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE + props.deckMapUtils = { + movedLabwareDef: null, + moduleRenderInfo: null, + labwareRenderInfo: null, + } as any + + render(props) + + expect(screen.queryByText('MOCK_MOVE_LW_ON_DECK')).not.toBeInTheDocument() + }) }) diff --git a/app/src/organisms/ErrorRecoveryFlows/utils/__tests__/getErrorKind.test.ts b/app/src/organisms/ErrorRecoveryFlows/utils/__tests__/getErrorKind.test.ts index 1aa7080a52a..fb9eea82c63 100644 --- a/app/src/organisms/ErrorRecoveryFlows/utils/__tests__/getErrorKind.test.ts +++ b/app/src/organisms/ErrorRecoveryFlows/utils/__tests__/getErrorKind.test.ts @@ -7,6 +7,11 @@ import type { RunCommandError, RunTimeCommand } from '@opentrons/shared-data' describe('getErrorKind', () => { it.each([ + { + commandType: 'prepareToAspirate', + errorType: DEFINED_ERROR_TYPES.OVERPRESSURE, + expectedError: ERROR_KINDS.OVERPRESSURE_PREPARE_TO_ASPIRATE, + }, { commandType: 'aspirate', errorType: DEFINED_ERROR_TYPES.OVERPRESSURE, diff --git a/app/src/organisms/ErrorRecoveryFlows/utils/getErrorKind.ts b/app/src/organisms/ErrorRecoveryFlows/utils/getErrorKind.ts index a537c3cf295..30fc4783473 100644 --- a/app/src/organisms/ErrorRecoveryFlows/utils/getErrorKind.ts +++ b/app/src/organisms/ErrorRecoveryFlows/utils/getErrorKind.ts @@ -13,9 +13,12 @@ export function getErrorKind(failedCommand: RunTimeCommand | null): ErrorKind { const errorType = failedCommand?.error?.errorType if (errorIsDefined) { - // todo(mm, 2024-07-02): Also handle aspirateInPlace and dispenseInPlace. - // https://opentrons.atlassian.net/browse/EXEC-593 if ( + commandType === 'prepareToAspirate' && + errorType === DEFINED_ERROR_TYPES.OVERPRESSURE + ) { + return ERROR_KINDS.OVERPRESSURE_PREPARE_TO_ASPIRATE + } else if ( (commandType === 'aspirate' || commandType === 'aspirateInPlace') && errorType === DEFINED_ERROR_TYPES.OVERPRESSURE ) { diff --git a/app/src/organisms/LiquidsLabwareDetailsModal/__tests__/LiquidsLabwareDetailsModal.test.tsx b/app/src/organisms/LiquidsLabwareDetailsModal/__tests__/LiquidsLabwareDetailsModal.test.tsx index 54b8239da47..967a840ee75 100644 --- a/app/src/organisms/LiquidsLabwareDetailsModal/__tests__/LiquidsLabwareDetailsModal.test.tsx +++ b/app/src/organisms/LiquidsLabwareDetailsModal/__tests__/LiquidsLabwareDetailsModal.test.tsx @@ -65,6 +65,7 @@ describe('LiquidsLabwareDetailsModal', () => { vi.mocked(getLocationInfoNames).mockReturnValue({ labwareName: 'mock labware name', slotName: '5', + labwareQuantity: 1, }) vi.mocked(getSlotLabwareDefinition).mockReturnValue(mockDefinition) vi.mocked(getLiquidsByIdForLabware).mockReturnValue({ diff --git a/app/src/organisms/ModuleCard/ModuleSetupModal.tsx b/app/src/organisms/ModuleCard/ModuleSetupModal.tsx index 16d3dec77d2..91696be776f 100644 --- a/app/src/organisms/ModuleCard/ModuleSetupModal.tsx +++ b/app/src/organisms/ModuleCard/ModuleSetupModal.tsx @@ -1,6 +1,7 @@ import { useTranslation } from 'react-i18next' import { createPortal } from 'react-dom' -import code from '/app/assets/images/module_instruction_code.png' +import helpCenterQRCode from '/app/assets/images/module_instruction_code.png' +import absorbanceReaderManualQRCode from '/app/assets/images/absorbance_reader_instruction_manual_code.png' import { ALIGN_FLEX_END, DIRECTION_COLUMN, @@ -17,14 +18,17 @@ import { import { getTopPortalEl } from '/app/App/portal' const MODULE_SETUP_URL = 'https://support.opentrons.com/s/modules' +const ABSORBANCE_READER_MANUAL_URL = + 'https://insights.opentrons.com/hubfs/Absorbance%20Plate%20Reader%20Instruction%20Manual.pdf' interface ModuleSetupModalProps { close: () => void moduleDisplayName: string + isAbsorbanceReader?: boolean } export const ModuleSetupModal = (props: ModuleSetupModalProps): JSX.Element => { - const { moduleDisplayName } = props + const { moduleDisplayName, isAbsorbanceReader } = props const { t, i18n } = useTranslation(['protocol_setup', 'shared', 'branded']) return createPortal( @@ -41,12 +45,18 @@ export const ModuleSetupModal = (props: ModuleSetupModalProps): JSX.Element => { width="50%" > - {t('branded:modal_instructions')} + {isAbsorbanceReader + ? t('module_instructions_manual') + : t('branded:modal_instructions')} { />
- +
{i18n.format(t('shared:close'), 'capitalize')} diff --git a/app/src/organisms/ModuleCard/__tests__/ModuleSetupModal.test.tsx b/app/src/organisms/ModuleCard/__tests__/ModuleSetupModal.test.tsx index 7f24a60bc7c..87f340b2845 100644 --- a/app/src/organisms/ModuleCard/__tests__/ModuleSetupModal.test.tsx +++ b/app/src/organisms/ModuleCard/__tests__/ModuleSetupModal.test.tsx @@ -47,4 +47,23 @@ describe('ModuleSetupModal', () => { fireEvent.click(closeButton) expect(props.close).toHaveBeenCalled() }) + it('should render variable copy and link if absorbance reader', () => { + props = { + ...props, + isAbsorbanceReader: true, + } + render(props) + screen.getByText( + 'For step-by-step instructions on setting up your module, consult the Quickstart Guide that came in its box. You can also click the link below or scan the QR code to read the module Instruction Manual.' + ) + expect( + screen + .getByRole('link', { + name: 'mockModuleDisplayName setup instructions', + }) + .getAttribute('href') + ).toBe( + 'https://insights.opentrons.com/hubfs/Absorbance%20Plate%20Reader%20Instruction%20Manual.pdf' + ) + }) }) diff --git a/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupLabware/LabwareMapView.tsx b/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupLabware/LabwareMapView.tsx index 21b6fb20854..339ad981daa 100644 --- a/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupLabware/LabwareMapView.tsx +++ b/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupLabware/LabwareMapView.tsx @@ -3,18 +3,22 @@ import { BaseDeck, Flex } from '@opentrons/components' import { FLEX_ROBOT_TYPE, getSimplestDeckConfigForProtocol, + getTopLabwareInfo, THERMOCYCLER_MODULE_V1, } from '@opentrons/shared-data' import { getStandardDeckViewLayerBlockList } from '/app/local-resources/deck_configuration' import { getLabwareRenderInfo } from '/app/transformations/analysis' +import type { LabwareOnDeck } from '@opentrons/components' import type { CompletedProtocolAnalysis, DeckDefinition, LabwareDefinition2, - LoadedLabwareByAdapter, + RunTimeCommand, + LoadLabwareRunTimeCommand, } from '@opentrons/shared-data' + import type { AttachedProtocolModuleMatch } from '/app/transformations/analysis' interface LabwareMapViewProps { @@ -23,7 +27,6 @@ interface LabwareMapViewProps { labwareDef: LabwareDefinition2, labwareId: string ) => void - initialLoadedLabwareByAdapter: LoadedLabwareByAdapter deckDef: DeckDefinition mostRecentAnalysis: CompletedProtocolAnalysis | null } @@ -32,11 +35,16 @@ export function LabwareMapView(props: LabwareMapViewProps): JSX.Element { const { handleLabwareClick, attachedProtocolModuleMatches, - initialLoadedLabwareByAdapter, deckDef, mostRecentAnalysis, } = props const deckConfig = getSimplestDeckConfigForProtocol(mostRecentAnalysis) + const commands: RunTimeCommand[] = mostRecentAnalysis?.commands ?? [] + const loadLabwareCommands = commands?.filter( + (command): command is LoadLabwareRunTimeCommand => + command.commandType === 'loadLabware' + ) + const labwareRenderInfo = mostRecentAnalysis != null ? getLabwareRenderInfo(mostRecentAnalysis, deckDef) @@ -44,16 +52,11 @@ export function LabwareMapView(props: LabwareMapViewProps): JSX.Element { const modulesOnDeck = attachedProtocolModuleMatches.map(module => { const { moduleDef, nestedLabwareDef, nestedLabwareId, slotName } = module - const labwareInAdapterInMod = - nestedLabwareId != null - ? initialLoadedLabwareByAdapter[nestedLabwareId] - : null - // only rendering the labware on top most layer so - // either the adapter or the labware are rendered but not both - const topLabwareDefinition = - labwareInAdapterInMod?.result?.definition ?? nestedLabwareDef - const topLabwareId = - labwareInAdapterInMod?.result?.labwareId ?? nestedLabwareId + const isLabwareStacked = nestedLabwareId != null && nestedLabwareDef != null + const { topLabwareId, topLabwareDefinition } = getTopLabwareInfo( + module.nestedLabwareId ?? '', + loadLabwareCommands + ) return { moduleModel: moduleDef.model, @@ -70,49 +73,48 @@ export function LabwareMapView(props: LabwareMapViewProps): JSX.Element { } : undefined, highlightLabware: true, - highlightShadowLabware: - topLabwareDefinition != null && topLabwareId != null, + highlightShadowLabware: isLabwareStacked, moduleChildren: null, - stacked: topLabwareDefinition != null && topLabwareId != null, + stacked: isLabwareStacked, } }) - const labwareLocations = map( + const labwareLocations: Array = map( labwareRenderInfo, - ({ labwareDef, slotName }, labwareId) => { - const labwareInAdapter = initialLoadedLabwareByAdapter[labwareId] - // only rendering the labware on top most layer so - // either the adapter or the labware are rendered but not both - const topLabwareDefinition = - labwareInAdapter?.result?.definition ?? labwareDef - const topLabwareId = labwareInAdapter?.result?.labwareId ?? labwareId - const isLabwareInStack = - topLabwareDefinition != null && - topLabwareId != null && - labwareInAdapter != null + ({ slotName }, labwareId) => { + const { topLabwareId, topLabwareDefinition } = getTopLabwareInfo( + labwareId, + loadLabwareCommands + ) + const isLabwareInStack = labwareId !== topLabwareId - return { - labwareLocation: { slotName }, - definition: topLabwareDefinition, - topLabwareId, - onLabwareClick: () => { - handleLabwareClick(topLabwareDefinition, topLabwareId) - }, - labwareChildren: null, - highlight: true, - highlightShadow: isLabwareInStack, - stacked: isLabwareInStack, - } + return topLabwareDefinition != null + ? { + labwareLocation: { slotName }, + definition: topLabwareDefinition, + onLabwareClick: () => { + handleLabwareClick(topLabwareDefinition, topLabwareId) + }, + highlight: true, + highlightShadow: isLabwareInStack, + stacked: isLabwareInStack, + } + : null } ) + const labwareLocationsFiltered: LabwareOnDeck[] = labwareLocations.filter( + (labwareLocation): labwareLocation is LabwareOnDeck => + labwareLocation != null + ) + return ( diff --git a/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupLabware/__tests__/LabwareMapView.test.tsx b/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupLabware/__tests__/LabwareMapView.test.tsx index 8729ae0f811..860d927578e 100644 --- a/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupLabware/__tests__/LabwareMapView.test.tsx +++ b/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupLabware/__tests__/LabwareMapView.test.tsx @@ -114,7 +114,6 @@ describe('LabwareMapView', () => { handleLabwareClick: vi.fn(), deckDef: (deckDefFixture as unknown) as DeckDefinition, mostRecentAnalysis: ({} as unknown) as CompletedProtocolAnalysis, - initialLoadedLabwareByAdapter: {}, attachedProtocolModuleMatches: [ { ...mockProtocolModuleInfo[0], diff --git a/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupLabware/index.tsx b/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupLabware/index.tsx index 1a54e2fc00d..2d440fc9516 100644 --- a/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupLabware/index.tsx +++ b/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupLabware/index.tsx @@ -25,10 +25,11 @@ import { FLEX_ROBOT_TYPE, getDeckDefFromRobotType, getLabwareDefURI, - getLabwareDisplayName, + getTopLabwareInfo, getModuleDisplayName, HEATERSHAKER_MODULE_TYPE, - parseInitialLoadedLabwareByAdapter, + TC_MODULE_LOCATION_OT3, + THERMOCYCLER_MODULE_TYPE, } from '@opentrons/shared-data' import { useCreateLiveCommandMutation, @@ -38,8 +39,8 @@ import { import { FloatingActionButton, SmallButton } from '/app/atoms/buttons' import { ODDBackButton } from '/app/molecules/ODDBackButton' import { + getLocationInfoNames, getLabwareSetupItemGroups, - getNestedLabwareInfo, } from '/app/transformations/commands' import { getAttachedProtocolModuleMatches, @@ -56,15 +57,12 @@ import type { HeaterShakerCloseLatchCreateCommand, HeaterShakerOpenLatchCreateCommand, LabwareDefinition2, - LabwareLocation, LoadLabwareRunTimeCommand, + LabwareLocation, RunTimeCommand, } from '@opentrons/shared-data' import type { HeaterShakerModule, Modules } from '@opentrons/api-client' -import type { - LabwareSetupItem, - NestedLabwareInfo, -} from '/app/transformations/commands' +import type { LabwareSetupItem } from '/app/transformations/commands' import type { SetupScreens } from '../types' import type { AttachedProtocolModuleMatch } from '/app/transformations/analysis' @@ -121,9 +119,6 @@ export function ProtocolSetupLabware({ protocolModulesInfo, deckConfig ) - const initialLoadedLabwareByAdapter = parseInitialLoadedLabwareByAdapter( - mostRecentAnalysis?.commands ?? [] - ) const handleLabwareClick = ( labwareDef: LabwareDefinition2, @@ -152,7 +147,7 @@ export function ProtocolSetupLabware({ } } } - const selectedLabwareIsTopOfStack = mostRecentAnalysis?.commands.some( + const selectedLabwareIsStacked = mostRecentAnalysis?.commands.some( command => command.commandType === 'loadLabware' && command.result?.labwareId === selectedLabware?.id && @@ -164,7 +159,7 @@ export function ProtocolSetupLabware({ return ( <> {showLabwareDetailsModal && - !selectedLabwareIsTopOfStack && + !selectedLabwareIsStacked && selectedLabware != null ? ( ) : ( <> @@ -239,17 +233,14 @@ export function ProtocolSetupLabware({ 'labwareId' in labware.initialLocation && item.labwareId === labware.initialLocation.labwareId ) - return mostRecentAnalysis != null && labwareOnAdapter == null ? ( + return mostRecentAnalysis?.commands != null && + labwareOnAdapter == null ? ( ) : null })} @@ -257,7 +248,7 @@ export function ProtocolSetupLabware({ )} {showLabwareDetailsModal && selectedLabware != null && - selectedLabwareIsTopOfStack ? ( + selectedLabwareIsStacked ? ( ['refetch'] - nestedLabwareInfo: NestedLabwareInfo | null - commands?: RunTimeCommand[] + commands: RunTimeCommand[] } function RowLabware({ labware, attachedProtocolModules, refetchModules, - nestedLabwareInfo, commands, }: RowLabwareProps): JSX.Element | null { - const { definition, initialLocation, nickName } = labware + const { + initialLocation, + nickName: bottomLabwareNickname, + labwareId: bottomLabwareId, + } = labware + const loadLabwareCommands = commands?.filter( + (command): command is LoadLabwareRunTimeCommand => + command.commandType === 'loadLabware' + ) + + const { topLabwareId } = getTopLabwareInfo( + bottomLabwareId ?? '', + loadLabwareCommands + ) + const { + slotName: slot, + labwareName: topLabwareName, + labwareNickname: topLabwareNickname, + labwareQuantity: topLabwareQuantity, + adapterName, + } = getLocationInfoNames(topLabwareId, commands) + const { t, i18n } = useTranslation([ 'protocol_command_text', 'protocol_setup', @@ -451,47 +461,21 @@ function RowLabware({ matchedModule.attachedModuleMatch.moduleType === HEATERSHAKER_MODULE_TYPE ? matchedModule.attachedModuleMatch : null + const isStacked = + topLabwareQuantity > 1 || adapterName != null || matchedModule != null - let slotName: string = '' - let location: JSX.Element | string | null = null + let slotName: string = slot + let location: JSX.Element = if (initialLocation === 'offDeck') { location = ( ) - } else if ('slotName' in initialLocation) { - slotName = initialLocation.slotName - location = - } else if ('addressableAreaName' in initialLocation) { - slotName = initialLocation.addressableAreaName - location = - } else if (labware.moduleLocation != null) { - location = ( - <> - - - ) - } else if ('labwareId' in initialLocation) { - const adapterId = initialLocation.labwareId - const adapterLocation = commands?.find( - (command): command is LoadLabwareRunTimeCommand => - command.commandType === 'loadLabware' && - command.result?.labwareId === adapterId - )?.params.location - - if (adapterLocation != null && adapterLocation !== 'offDeck') { - if ('slotName' in adapterLocation) { - slotName = adapterLocation.slotName - location = - } else if ('moduleId' in adapterLocation) { - const moduleUnderAdapter = attachedProtocolModules.find( - module => module.moduleId === adapterLocation.moduleId - ) - if (moduleUnderAdapter != null) { - slotName = moduleUnderAdapter.slotName - location = - } - } - } + } else if ( + matchedModule != null && + matchedModule.attachedModuleMatch?.moduleType === THERMOCYCLER_MODULE_TYPE + ) { + slotName = TC_MODULE_LOCATION_OT3 + location = } return ( {location} - {nestedLabwareInfo != null || matchedModule != null ? ( - - ) : null} + {isStacked ? : null} - {getLabwareDisplayName(definition)} + {topLabwareName} - {nickName} + {topLabwareQuantity > 1 + ? t('protocol_setup:labware_quantity', { + quantity: topLabwareQuantity, + }) + : topLabwareNickname} - {nestedLabwareInfo != null && - nestedLabwareInfo?.sharedSlotId === slotName ? ( + {adapterName != null ? ( <> - {nestedLabwareInfo.nestedLabwareDisplayName} + {adapterName} - {nestedLabwareInfo.nestedLabwareNickName} + {bottomLabwareNickname} diff --git a/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupLiquids/__tests__/LiquidDetails.test.tsx b/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupLiquids/__tests__/LiquidDetails.test.tsx index feeb3e863a4..720b6db7545 100644 --- a/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupLiquids/__tests__/LiquidDetails.test.tsx +++ b/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupLiquids/__tests__/LiquidDetails.test.tsx @@ -43,6 +43,7 @@ describe('LiquidDetails', () => { vi.mocked(getLocationInfoNames).mockReturnValue({ slotName: '4', labwareName: 'mock labware name', + labwareQuantity: 1, }) vi.mocked(LiquidsLabwareDetailsModal).mockReturnValue(
mock modal
) }) diff --git a/app/src/organisms/ODD/QuickTransferFlow/utils/generateCompatibleLabwareForPipette.ts b/app/src/organisms/ODD/QuickTransferFlow/utils/generateCompatibleLabwareForPipette.ts index 29e0847bf02..38d62455854 100644 --- a/app/src/organisms/ODD/QuickTransferFlow/utils/generateCompatibleLabwareForPipette.ts +++ b/app/src/organisms/ODD/QuickTransferFlow/utils/generateCompatibleLabwareForPipette.ts @@ -14,7 +14,8 @@ export function generateCompatibleLabwareForPipette( (acc, definition) => { if ( definition.allowedRoles != null && - definition.allowedRoles.includes('adapter') + (definition.allowedRoles.includes('adapter') || + definition.allowedRoles.includes('lid')) ) { return acc } else if (pipetteSpecs.channels === 1) { diff --git a/app/src/organisms/ODD/RobotSettingsDashboard/LanguageSetting.tsx b/app/src/organisms/ODD/RobotSettingsDashboard/LanguageSetting.tsx new file mode 100644 index 00000000000..a935a5571ad --- /dev/null +++ b/app/src/organisms/ODD/RobotSettingsDashboard/LanguageSetting.tsx @@ -0,0 +1,92 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch, useSelector } from 'react-redux' +import styled from 'styled-components' + +import { + BORDERS, + COLORS, + CURSOR_POINTER, + DIRECTION_COLUMN, + Flex, + SPACING, + StyledText, +} from '@opentrons/components' + +import { LANGUAGES } from '/app/i18n' +import { ChildNavigation } from '/app/organisms/ODD/ChildNavigation' +import { getAppLanguage, updateConfigValue } from '/app/redux/config' + +import type { Dispatch } from '/app/redux/types' +import type { SetSettingOption } from './types' + +interface LabelProps { + isSelected?: boolean +} + +const SettingButton = styled.input` + display: none; +` + +const SettingButtonLabel = styled.label` + padding: ${SPACING.spacing24}; + border-radius: ${BORDERS.borderRadius16}; + cursor: ${CURSOR_POINTER}; + background: ${({ isSelected }) => + isSelected === true ? COLORS.blue50 : COLORS.blue35}; + color: ${({ isSelected }) => isSelected === true && COLORS.white}; +` + +interface LanguageSettingProps { + setCurrentOption: SetSettingOption +} + +export function LanguageSetting({ + setCurrentOption, +}: LanguageSettingProps): JSX.Element { + const { t } = useTranslation('app_settings') + const dispatch = useDispatch() + + const appLanguage = useSelector(getAppLanguage) + + const handleChange = (event: React.ChangeEvent): void => { + dispatch(updateConfigValue('language.appLanguage', event.target.value)) + } + + return ( + + { + setCurrentOption(null) + }} + /> + + {LANGUAGES.map(lng => ( + + + + + {lng.name} + + + + ))} + + + ) +} diff --git a/app/src/organisms/ODD/RobotSettingsDashboard/__tests__/LanguageSetting.test.tsx b/app/src/organisms/ODD/RobotSettingsDashboard/__tests__/LanguageSetting.test.tsx new file mode 100644 index 00000000000..80d35ebea15 --- /dev/null +++ b/app/src/organisms/ODD/RobotSettingsDashboard/__tests__/LanguageSetting.test.tsx @@ -0,0 +1,60 @@ +import type * as React from 'react' +import { fireEvent, screen } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import '@testing-library/jest-dom/vitest' + +import { + i18n, + US_ENGLISH_DISPLAY_NAME, + US_ENGLISH, + SIMPLIFIED_CHINESE_DISPLAY_NAME, + SIMPLIFIED_CHINESE, +} from '/app/i18n' +import { getAppLanguage, updateConfigValue } from '/app/redux/config' +import { renderWithProviders } from '/app/__testing-utils__' + +import { LanguageSetting } from '../LanguageSetting' + +vi.mock('/app/redux/config') + +const mockSetCurrentOption = vi.fn() + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('LanguageSetting', () => { + let props: React.ComponentProps + beforeEach(() => { + props = { + setCurrentOption: mockSetCurrentOption, + } + vi.mocked(getAppLanguage).mockReturnValue(US_ENGLISH) + }) + + it('should render text and buttons', () => { + render(props) + screen.getByText('Language') + screen.getByText(US_ENGLISH_DISPLAY_NAME) + screen.getByText(SIMPLIFIED_CHINESE_DISPLAY_NAME) + }) + + it('should call mock function when tapping a language button', () => { + render(props) + const button = screen.getByText(SIMPLIFIED_CHINESE_DISPLAY_NAME) + fireEvent.click(button) + expect(updateConfigValue).toHaveBeenCalledWith( + 'language.appLanguage', + SIMPLIFIED_CHINESE + ) + }) + + it('should call mock function when tapping back button', () => { + render(props) + const button = screen.getByRole('button') + fireEvent.click(button) + expect(props.setCurrentOption).toHaveBeenCalled() + }) +}) diff --git a/app/src/organisms/ODD/RobotSettingsDashboard/index.ts b/app/src/organisms/ODD/RobotSettingsDashboard/index.ts index 30933095135..a468c86829b 100644 --- a/app/src/organisms/ODD/RobotSettingsDashboard/index.ts +++ b/app/src/organisms/ODD/RobotSettingsDashboard/index.ts @@ -1,4 +1,5 @@ export * from './DeviceReset' +export * from './LanguageSetting' export * from './NetworkSettings/RobotSettingsJoinOtherNetwork' export * from './NetworkSettings/RobotSettingsSelectAuthenticationType' export * from './NetworkSettings/RobotSettingsSetWifiCred' diff --git a/app/src/organisms/ODD/RobotSettingsDashboard/types.ts b/app/src/organisms/ODD/RobotSettingsDashboard/types.ts index 231d26c837b..78e1f552daa 100644 --- a/app/src/organisms/ODD/RobotSettingsDashboard/types.ts +++ b/app/src/organisms/ODD/RobotSettingsDashboard/types.ts @@ -17,5 +17,6 @@ export type SettingOption = | 'RobotSettingsSetWifiCred' | 'RobotSettingsWifi' | 'RobotSettingsWifiConnect' + | 'LanguageSetting' export type SetSettingOption = (option: SettingOption | null) => void diff --git a/app/src/organisms/PipetteWizardFlows/__tests__/UnskippableModal.test.tsx b/app/src/organisms/PipetteWizardFlows/__tests__/UnskippableModal.test.tsx index a290e689809..bc738d0caf3 100644 --- a/app/src/organisms/PipetteWizardFlows/__tests__/UnskippableModal.test.tsx +++ b/app/src/organisms/PipetteWizardFlows/__tests__/UnskippableModal.test.tsx @@ -24,7 +24,7 @@ describe('UnskippableModal', () => { render(props) screen.getByText('This is a critical step that should not be skipped') screen.getByText( - 'You must detach the mounting plate and reattach the z-axis carraige before using other pipettes. We do not recommend exiting this process before completion.' + 'You must detach the mounting plate and reattach the z-axis carriage before using other pipettes. We do not recommend exiting this process before completion.' ) fireEvent.click(screen.getByRole('button', { name: 'Go back' })) expect(props.goBack).toHaveBeenCalled() @@ -39,7 +39,7 @@ describe('UnskippableModal', () => { render(props) screen.getByText('This is a critical step that should not be skipped') screen.getByText( - 'You must detach the mounting plate and reattach the z-axis carraige before using other pipettes. We do not recommend exiting this process before completion.' + 'You must detach the mounting plate and reattach the z-axis carriage before using other pipettes. We do not recommend exiting this process before completion.' ) screen.getByText('Exit') screen.getByText('Go back') diff --git a/app/src/organisms/RunTimeControl/__tests__/hooks.test.tsx b/app/src/organisms/RunTimeControl/__tests__/hooks.test.tsx index 7450fb34e4e..2ba0d50ea3b 100644 --- a/app/src/organisms/RunTimeControl/__tests__/hooks.test.tsx +++ b/app/src/organisms/RunTimeControl/__tests__/hooks.test.tsx @@ -40,16 +40,19 @@ describe('useRunControls hook', () => { const mockStopRun = vi.fn() const mockCloneRun = vi.fn() const mockResumeRunFromRecovery = vi.fn() + const mockResumeRunFromRecoveryAssumingFalsePositive = vi.fn() when(useRunActionMutations).calledWith(mockPausedRun.id).thenReturn({ playRun: mockPlayRun, pauseRun: mockPauseRun, stopRun: mockStopRun, resumeRunFromRecovery: mockResumeRunFromRecovery, + resumeRunFromRecoveryAssumingFalsePositive: mockResumeRunFromRecoveryAssumingFalsePositive, isPlayRunActionLoading: false, isPauseRunActionLoading: false, isStopRunActionLoading: false, isResumeRunFromRecoveryActionLoading: false, + isResumeRunFromRecoveryAssumingFalsePositiveActionLoading: false, }) when(useCloneRun).calledWith(mockPausedRun.id, undefined, true).thenReturn({ cloneRun: mockCloneRun, diff --git a/app/src/pages/Desktop/AppSettings/AdvancedSettings.tsx b/app/src/pages/Desktop/AppSettings/AdvancedSettings.tsx index 4eda66f68e1..e8f3724299b 100644 --- a/app/src/pages/Desktop/AppSettings/AdvancedSettings.tsx +++ b/app/src/pages/Desktop/AppSettings/AdvancedSettings.tsx @@ -1,16 +1,4 @@ -import { useContext } from 'react' -import { I18nContext } from 'react-i18next' -import { useDispatch } from 'react-redux' -import { css } from 'styled-components' - -import { - Box, - DIRECTION_COLUMN, - Flex, - RadioGroup, - SPACING, - TYPOGRAPHY, -} from '@opentrons/components' +import { Box, SPACING } from '@opentrons/components' import { Divider } from '/app/atoms/structure' import { @@ -25,9 +13,6 @@ import { UpdatedChannel, AdditionalCustomLabwareSourceFolder, } from '/app/organisms/Desktop/AdvancedSettings' -import { updateConfigValue, useFeatureFlag } from '/app/redux/config' - -import type { Dispatch } from '/app/redux/types' export function AdvancedSettings(): JSX.Element { return ( @@ -52,44 +37,7 @@ export function AdvancedSettings(): JSX.Element { - {/* TODO(bh, 2024-09-23): remove when localization setting designs implemented */} - ) } - -function LocalizationSetting(): JSX.Element | null { - const enableLocalization = useFeatureFlag('enableLocalization') - const dispatch = useDispatch() - - const { i18n } = useContext(I18nContext) - - return enableLocalization ? ( - <> - - - ) => { - dispatch( - updateConfigValue( - 'language.appLanguage', - event.currentTarget.value - ) - ) - }} - options={[ - { name: 'EN', value: 'en' }, - { name: 'CN', value: 'zh' }, - ]} - /> - - - ) : null -} diff --git a/app/src/pages/Desktop/AppSettings/GeneralSettings.tsx b/app/src/pages/Desktop/AppSettings/GeneralSettings.tsx index 82960177c9b..db948403fd0 100644 --- a/app/src/pages/Desktop/AppSettings/GeneralSettings.tsx +++ b/app/src/pages/Desktop/AppSettings/GeneralSettings.tsx @@ -12,6 +12,7 @@ import { COLORS, DIRECTION_COLUMN, DIRECTION_ROW, + DropdownMenu, Flex, JUSTIFY_SPACE_BETWEEN, Link, @@ -25,6 +26,7 @@ import { import { TertiaryButton, ToggleButton } from '/app/atoms/buttons' import { ExternalLink } from '/app/atoms/Link/ExternalLink' import { Divider } from '/app/atoms/structure' +import { LANGUAGES } from '/app/i18n' import { CURRENT_VERSION, getAvailableShellUpdate, @@ -40,6 +42,11 @@ import { useTrackEvent, ANALYTICS_APP_UPDATE_NOTIFICATIONS_TOGGLED, } from '/app/redux/analytics' +import { + getAppLanguage, + updateConfigValue, + useFeatureFlag, +} from '/app/redux/config' import { UpdateAppModal } from '/app/organisms/Desktop/UpdateAppModal' import { PreviousVersionModal } from '/app/organisms/Desktop/AppSettings/PreviousVersionModal' import { ConnectRobotSlideout } from '/app/organisms/Desktop/AppSettings/ConnectRobotSlideout' @@ -62,6 +69,15 @@ export function GeneralSettings(): JSX.Element { setShowPreviousVersionModal, ] = useState(false) const updateAvailable = Boolean(useSelector(getAvailableShellUpdate)) + + const enableLocalization = useFeatureFlag('enableLocalization') + const appLanguage = useSelector(getAppLanguage) + const currentLanguageOption = LANGUAGES.find(lng => lng.value === appLanguage) + + const handleDropdownClick = (value: string): void => { + dispatch(updateConfigValue('language.appLanguage', value)) + } + const [showUpdateBanner, setShowUpdateBanner] = useState( updateAvailable ) @@ -260,6 +276,35 @@ export function GeneralSettings(): JSX.Element { {t('setup_connection')}
+ + {enableLocalization && currentLanguageOption != null ? ( + <> + + + + {t('app_language_preferences')} + + + {t('app_language_description')} + + + + + + + ) : null} {showUpdateModal ? createPortal( diff --git a/app/src/pages/Desktop/AppSettings/__test__/GeneralSettings.test.tsx b/app/src/pages/Desktop/AppSettings/__test__/GeneralSettings.test.tsx index 5cfd02e09a8..539c5899e8a 100644 --- a/app/src/pages/Desktop/AppSettings/__test__/GeneralSettings.test.tsx +++ b/app/src/pages/Desktop/AppSettings/__test__/GeneralSettings.test.tsx @@ -1,11 +1,23 @@ import { MemoryRouter } from 'react-router-dom' import { vi, it, describe, expect, beforeEach, afterEach } from 'vitest' -import { screen } from '@testing-library/react' +import { fireEvent, screen } from '@testing-library/react' +import { when } from 'vitest-when' import { renderWithProviders } from '/app/__testing-utils__' -import { i18n } from '/app/i18n' +import { + i18n, + SIMPLIFIED_CHINESE, + SIMPLIFIED_CHINESE_DISPLAY_NAME, + US_ENGLISH, + US_ENGLISH_DISPLAY_NAME, +} from '/app/i18n' import { getAlertIsPermanentlyIgnored } from '/app/redux/alerts' +import { + getAppLanguage, + updateConfigValue, + useFeatureFlag, +} from '/app/redux/config' import * as Shell from '/app/redux/shell' import { GeneralSettings } from '../GeneralSettings' @@ -29,34 +41,38 @@ describe('GeneralSettings', () => { beforeEach(() => { vi.mocked(Shell.getAvailableShellUpdate).mockReturnValue(null) vi.mocked(getAlertIsPermanentlyIgnored).mockReturnValue(false) + vi.mocked(getAppLanguage).mockReturnValue(US_ENGLISH) + when(vi.mocked(useFeatureFlag)) + .calledWith('enableLocalization') + .thenReturn(true) }) afterEach(() => { vi.resetAllMocks() }) it('renders correct titles', () => { - const [{ getByText }] = render() - getByText('App Software Version') - getByText('Software Update Alerts') - getByText('Connect to a Robot via IP Address') + render() + screen.getByText('App Software Version') + screen.getByText('Software Update Alerts') + screen.getByText('Connect to a Robot via IP Address') }) it('renders software version section with no update available', () => { - const [{ getByText, getByRole }] = render() - getByText('Up to date') - getByText('View latest release notes on') - expect(getByRole('link', { name: 'GitHub' })).toHaveAttribute( + render() + screen.getByText('Up to date') + screen.getByText('View latest release notes on') + expect(screen.getByRole('link', { name: 'GitHub' })).toHaveAttribute( 'href', 'https://github.com/Opentrons/opentrons/blob/edge/app-shell/build/release-notes.md' ) - getByRole('button', { + screen.getByRole('button', { name: 'See how to restore a previous software version', }) expect( 'It is very important for the robot and app software to be on the same version. Manage the robot software versions via Robot Settings > Advanced.' ).toBeTruthy() expect( - getByRole('link', { + screen.getByRole('link', { name: 'Learn more about keeping the Opentrons App and robot software in sync', }) @@ -65,8 +81,8 @@ describe('GeneralSettings', () => { it('renders correct info if there is update available', () => { vi.mocked(Shell.getAvailableShellUpdate).mockReturnValue('5.0.0-beta.8') - const [{ getByRole }] = render() - getByRole('button', { name: 'View software update' }) + render() + screen.getByRole('button', { name: 'View software update' }) }) it('renders correct info if there is no update available', () => { @@ -80,17 +96,35 @@ describe('GeneralSettings', () => { }) it('renders the text and toggle for update alert section', () => { - const [{ getByText, getByRole }] = render() - getByText( + render() + screen.getByText( 'Receive an alert when an Opentrons software update is available.' ) - getByRole('switch', { + screen.getByRole('switch', { name: 'Enable app update notifications', }) }) it('renders the ip address button', () => { - const [{ getByRole }] = render() - getByRole('button', { name: 'Set up connection' }) + render() + screen.getByRole('button', { name: 'Set up connection' }) + }) + + it('renders the text and dropdown for the app language preferences section', () => { + render() + screen.getByText('App Language Preferences') + screen.getByText( + 'All app features use this language. Protocols and other user content will not change language.' + ) + fireEvent.click(screen.getByText(US_ENGLISH_DISPLAY_NAME)) + fireEvent.click( + screen.getByRole('button', { + name: SIMPLIFIED_CHINESE_DISPLAY_NAME, + }) + ) + expect(updateConfigValue).toBeCalledWith( + 'language.appLanguage', + SIMPLIFIED_CHINESE + ) }) }) diff --git a/app/src/pages/Desktop/Devices/ProtocolRunDetails/index.tsx b/app/src/pages/Desktop/Devices/ProtocolRunDetails/index.tsx index 7ea4ceae7c6..4abb609c4fc 100644 --- a/app/src/pages/Desktop/Devices/ProtocolRunDetails/index.tsx +++ b/app/src/pages/Desktop/Devices/ProtocolRunDetails/index.tsx @@ -25,10 +25,7 @@ import { ApiHostProvider } from '@opentrons/react-api-client' import { useSyncRobotClock } from '/app/organisms/Desktop/Devices/hooks' import { ProtocolRunHeader } from '/app/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader' import { RunPreview } from '/app/organisms/Desktop/Devices/RunPreview' -import { - ProtocolRunSetup, - initialMissingSteps, -} from '/app/organisms/Desktop/Devices/ProtocolRun/ProtocolRunSetup' +import { ProtocolRunSetup } from '/app/organisms/Desktop/Devices/ProtocolRun/ProtocolRunSetup' import { BackToTopButton } from '/app/organisms/Desktop/Devices/ProtocolRun/BackToTopButton' import { ProtocolRunModuleControls } from '/app/organisms/Desktop/Devices/ProtocolRun/ProtocolRunModuleControls' import { ProtocolRunRuntimeParameters } from '/app/organisms/Desktop/Devices/ProtocolRun/ProtocolRunRunTimeParameters' @@ -187,10 +184,6 @@ function PageContents(props: PageContentsProps): JSX.Element { } }, [jumpedIndex]) - const [missingSteps, setMissingSteps] = useState< - ReturnType - >(initialMissingSteps()) - const makeHandleScrollToStep = (i: number) => () => { listRef.current?.scrollToIndex(i, true, -1 * JUMP_OFFSET_FROM_TOP_PX) } @@ -210,8 +203,6 @@ function PageContents(props: PageContentsProps): JSX.Element { protocolRunHeaderRef={protocolRunHeaderRef} robotName={robotName} runId={runId} - setMissingSteps={setMissingSteps} - missingSteps={missingSteps} /> ), backToTop: ( @@ -269,7 +260,6 @@ function PageContents(props: PageContentsProps): JSX.Element { robotName={robotName} runId={runId} makeHandleJumpToStep={makeHandleJumpToStep} - missingSetupSteps={missingSteps} /> { screen.getByRole('button', { name: 'Tube Rack' }) screen.getByRole('button', { name: 'Reservoir' }) screen.getByRole('button', { name: 'Aluminum Block' }) + screen.getByRole('button', { name: 'Adapter' }) + screen.getByRole('button', { name: 'Lid' }) + screen.getByRole('button', { name: 'Custom Labware' }) }) it('renders changes filter menu button when an option is selected', () => { render() diff --git a/app/src/pages/Desktop/Labware/index.tsx b/app/src/pages/Desktop/Labware/index.tsx index 159e57c306e..83f9dd94f3f 100644 --- a/app/src/pages/Desktop/Labware/index.tsx +++ b/app/src/pages/Desktop/Labware/index.tsx @@ -56,6 +56,7 @@ const labwareDisplayCategoryFilters: LabwareFilter[] = [ 'adapter', 'aluminumBlock', 'customLabware', + 'lid', 'reservoir', 'tipRack', 'tubeRack', diff --git a/app/src/pages/ODD/ChooseLanguage/__tests__/ChooseLanguage.test.tsx b/app/src/pages/ODD/ChooseLanguage/__tests__/ChooseLanguage.test.tsx new file mode 100644 index 00000000000..8508a7b4d08 --- /dev/null +++ b/app/src/pages/ODD/ChooseLanguage/__tests__/ChooseLanguage.test.tsx @@ -0,0 +1,59 @@ +import { vi, it, describe, expect } from 'vitest' +import { fireEvent, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' + +import { renderWithProviders } from '/app/__testing-utils__' +import { i18n } from '/app/i18n' +import { updateConfigValue } from '/app/redux/config' +import { ChooseLanguage } from '..' + +import type { NavigateFunction } from 'react-router-dom' + +const mockNavigate = vi.fn() +vi.mock('react-router-dom', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + useNavigate: () => mockNavigate, + } +}) +vi.mock('/app/redux/config') + +const render = () => { + return renderWithProviders( + + + , + { + i18nInstance: i18n, + } + ) +} + +describe('ChooseLanguage', () => { + it('should render text, language options, and continue button', () => { + render() + screen.getByText('Choose your language') + screen.getByText('Select a language to personalize your experience.') + screen.getByRole('label', { name: 'English (US)' }) + screen.getByRole('label', { name: '中文' }) + screen.getByRole('button', { name: 'Continue' }) + }) + + it('should initialize english', () => { + render() + expect(updateConfigValue).toBeCalledWith('language.appLanguage', 'en-US') + }) + + it('should change language when language option selected', () => { + render() + fireEvent.click(screen.getByRole('label', { name: '中文' })) + expect(updateConfigValue).toBeCalledWith('language.appLanguage', 'zh-CN') + }) + + it('should call mockNavigate when tapping continue', () => { + render() + fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + expect(mockNavigate).toHaveBeenCalledWith('/welcome') + }) +}) diff --git a/app/src/pages/ODD/ChooseLanguage/index.tsx b/app/src/pages/ODD/ChooseLanguage/index.tsx new file mode 100644 index 00000000000..d0110e68591 --- /dev/null +++ b/app/src/pages/ODD/ChooseLanguage/index.tsx @@ -0,0 +1,79 @@ +import { useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch, useSelector } from 'react-redux' +import { useNavigate } from 'react-router-dom' + +import { + DIRECTION_COLUMN, + Flex, + JUSTIFY_SPACE_BETWEEN, + RadioButton, + SPACING, + StyledText, + TYPOGRAPHY, +} from '@opentrons/components' + +import { MediumButton } from '/app/atoms/buttons' +import { LANGUAGES, US_ENGLISH } from '/app/i18n' +import { RobotSetupHeader } from '/app/organisms/ODD/RobotSetupHeader' +import { getAppLanguage, updateConfigValue } from '/app/redux/config' + +import type { Dispatch } from '/app/redux/types' + +export function ChooseLanguage(): JSX.Element { + const { i18n, t } = useTranslation(['app_settings', 'shared']) + const navigate = useNavigate() + const dispatch = useDispatch() + + const appLanguage = useSelector(getAppLanguage) + + useEffect(() => { + // initialize en-US language on mount + dispatch(updateConfigValue('language.appLanguage', US_ENGLISH)) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return ( + + + + + + {t('select_a_language')} + + + {LANGUAGES.map(lng => ( + { + dispatch(updateConfigValue('language.appLanguage', lng.value)) + }} + > + ))} + + + { + navigate('/welcome') + }} + width="100%" + /> + + + ) +} diff --git a/app/src/pages/ODD/ProtocolDashboard/index.tsx b/app/src/pages/ODD/ProtocolDashboard/index.tsx index ba2efa23949..de775795ded 100644 --- a/app/src/pages/ODD/ProtocolDashboard/index.tsx +++ b/app/src/pages/ODD/ProtocolDashboard/index.tsx @@ -98,7 +98,7 @@ export function ProtocolDashboard(): JSX.Element { } const runData = runs.data?.data != null ? runs.data?.data : [] - const allRunsNewestFirst = runData.sort( + const allRunsNewestFirst = runData.toSorted( (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() ) const sortedProtocols = sortProtocols( diff --git a/app/src/pages/ODD/RobotDashboard/index.tsx b/app/src/pages/ODD/RobotDashboard/index.tsx index b699f6ab569..aa255717388 100644 --- a/app/src/pages/ODD/RobotDashboard/index.tsx +++ b/app/src/pages/ODD/RobotDashboard/index.tsx @@ -41,8 +41,7 @@ export function RobotDashboard(): JSX.Element { ) const recentRunsOfUniqueProtocols = (allRunsQueryData?.data ?? []) - .reverse() // newest runs first - .reduce((acc, run) => { + .reduceRight((acc, run) => { if ( acc.some(collectedRun => collectedRun.protocolId === run.protocolId) ) { diff --git a/app/src/pages/ODD/RobotSettingsDashboard/RobotSettingsList.tsx b/app/src/pages/ODD/RobotSettingsDashboard/RobotSettingsList.tsx index a649a65e40c..043b2b8b843 100644 --- a/app/src/pages/ODD/RobotSettingsDashboard/RobotSettingsList.tsx +++ b/app/src/pages/ODD/RobotSettingsDashboard/RobotSettingsList.tsx @@ -1,6 +1,5 @@ -import { useContext } from 'react' import { useDispatch, useSelector } from 'react-redux' -import { I18nContext, useTranslation } from 'react-i18next' +import { useTranslation } from 'react-i18next' import { Link } from 'react-router-dom' import { @@ -21,18 +20,19 @@ import { TYPOGRAPHY, } from '@opentrons/components' +import { LANGUAGES } from '/app/i18n' import { getLocalRobot, getRobotApiVersion } from '/app/redux/discovery' import { getRobotUpdateAvailable } from '/app/redux/robot-update' import { useErrorRecoverySettingsToggle } from '/app/resources/errorRecovery' import { DEV_INTERNAL_FLAGS, + getAppLanguage, getApplyHistoricOffsets, getDevtoolsEnabled, getFeatureFlags, toggleDevInternalFlag, toggleDevtools, toggleHistoricOffsets, - updateConfigValue, useFeatureFlag, } from '/app/redux/config' import { InlineNotification } from '/app/atoms/InlineNotification' @@ -88,6 +88,10 @@ export function RobotSettingsList(props: RobotSettingsListProps): JSX.Element { const { lightsEnabled, toggleLights } = useLEDLights(robotName) const { toggleERSettings, isEREnabled } = useErrorRecoverySettingsToggle() + const appLanguage = useSelector(getAppLanguage) + const currentLanguageOption = LANGUAGES.find(lng => lng.value === appLanguage) + const enableLocalization = useFeatureFlag('enableLocalization') + return ( @@ -139,6 +143,18 @@ export function RobotSettingsList(props: RobotSettingsListProps): JSX.Element { } /> + {enableLocalization ? ( + { + setCurrentOption('LanguageSetting') + }} + iconName="language" + /> + ) : null} dispatch(toggleDevtools())} /> {devToolsOn ? : null} - {/* TODO(bh, 2024-09-23): remove when localization setting designs implemented */} -
) @@ -282,22 +296,3 @@ function FeatureFlags(): JSX.Element { ) } - -function LanguageToggle(): JSX.Element | null { - const enableLocalization = useFeatureFlag('enableLocalization') - const dispatch = useDispatch() - - const { i18n } = useContext(I18nContext) - - return enableLocalization ? ( - { - i18n.language === 'en' - ? dispatch(updateConfigValue('language.appLanguage', 'zh')) - : dispatch(updateConfigValue('language.appLanguage', 'en')) - }} - rightElement={<>} - /> - ) : null -} diff --git a/app/src/pages/ODD/RobotSettingsDashboard/__tests__/RobotSettingsDashboard.test.tsx b/app/src/pages/ODD/RobotSettingsDashboard/__tests__/RobotSettingsDashboard.test.tsx index 07fdb119ee4..00b70120809 100644 --- a/app/src/pages/ODD/RobotSettingsDashboard/__tests__/RobotSettingsDashboard.test.tsx +++ b/app/src/pages/ODD/RobotSettingsDashboard/__tests__/RobotSettingsDashboard.test.tsx @@ -1,19 +1,26 @@ import { vi, it, describe, expect, beforeEach, afterEach } from 'vitest' import { MemoryRouter } from 'react-router-dom' import { fireEvent, screen } from '@testing-library/react' +import { when } from 'vitest-when' import { renderWithProviders } from '/app/__testing-utils__' import { i18n } from '/app/i18n' import { getRobotSettings } from '/app/redux/robot-settings' import { getLocalRobot } from '/app/redux/discovery' -import { toggleDevtools, toggleHistoricOffsets } from '/app/redux/config' +import { + getAppLanguage, + toggleDevtools, + toggleHistoricOffsets, + useFeatureFlag, +} from '/app/redux/config' import { mockConnectedRobot } from '/app/redux/discovery/__fixtures__' import { Navigation } from '/app/organisms/ODD/Navigation' import { DeviceReset, TouchScreenSleep, TouchscreenBrightness, + LanguageSetting, NetworkSettings, Privacy, RobotSystemVersion, @@ -44,6 +51,7 @@ vi.mock('/app/organisms/ODD/RobotSettingsDashboard/RobotSystemVersion') vi.mock('/app/organisms/ODD/RobotSettingsDashboard/TouchscreenBrightness') vi.mock('/app/organisms/ODD/RobotSettingsDashboard/UpdateChannel') vi.mock('/app/organisms/ODD/RobotSettingsDashboard/Privacy') +vi.mock('/app/organisms/ODD/RobotSettingsDashboard/LanguageSetting') const mockToggleLights = vi.fn() const mockToggleER = vi.fn() @@ -59,6 +67,8 @@ const render = () => { ) } +const MOCK_DEFAULT_LANGUAGE = 'en-US' + // Note kj 01/25/2023 Currently test cases only check text since this PR is bare-bones for RobotSettings Dashboard describe('RobotSettingsDashboard', () => { beforeEach(() => { @@ -81,6 +91,10 @@ describe('RobotSettingsDashboard', () => { isEREnabled: true, toggleERSettings: mockToggleER, }) + vi.mocked(getAppLanguage).mockReturnValue(MOCK_DEFAULT_LANGUAGE) + when(vi.mocked(useFeatureFlag)) + .calledWith('enableLocalization') + .thenReturn(true) }) afterEach(() => { @@ -249,4 +263,13 @@ describe('RobotSettingsDashboard', () => { render() screen.getByText('Update available') }) + + it('should render component when tapping Language', () => { + render() + + screen.getByText('English (US)') + const button = screen.getByText('Language') + fireEvent.click(button) + expect(vi.mocked(LanguageSetting)).toHaveBeenCalled() + }) }) diff --git a/app/src/pages/ODD/RobotSettingsDashboard/index.tsx b/app/src/pages/ODD/RobotSettingsDashboard/index.tsx index 30925f1ae44..401c4815aac 100644 --- a/app/src/pages/ODD/RobotSettingsDashboard/index.tsx +++ b/app/src/pages/ODD/RobotSettingsDashboard/index.tsx @@ -8,6 +8,7 @@ import { DeviceReset, TouchscreenBrightness, TouchScreenSleep, + LanguageSetting, NetworkSettings, Privacy, RobotName, @@ -200,6 +201,9 @@ export function RobotSettingsDashboard(): JSX.Element { /> ) + case 'LanguageSetting': + return + // fallthrough option: render the robot settings list of buttons default: return diff --git a/app/src/pages/ODD/RunningProtocol/__tests__/RunningProtocol.test.tsx b/app/src/pages/ODD/RunningProtocol/__tests__/RunningProtocol.test.tsx index 2605c1bad5b..f98666d3cbd 100644 --- a/app/src/pages/ODD/RunningProtocol/__tests__/RunningProtocol.test.tsx +++ b/app/src/pages/ODD/RunningProtocol/__tests__/RunningProtocol.test.tsx @@ -76,6 +76,7 @@ const mockPlayRun = vi.fn() const mockPauseRun = vi.fn() const mockStopRun = vi.fn() const mockResumeRunFromRecovery = vi.fn() +const mockResumeRunFromRecoveryAssumingFalsePositive = vi.fn() const render = (path = '/') => { return renderWithProviders( @@ -133,10 +134,12 @@ describe('RunningProtocol', () => { pauseRun: mockPauseRun, stopRun: mockStopRun, resumeRunFromRecovery: mockResumeRunFromRecovery, + resumeRunFromRecoveryAssumingFalsePositive: mockResumeRunFromRecoveryAssumingFalsePositive, isPlayRunActionLoading: false, isPauseRunActionLoading: false, isStopRunActionLoading: false, isResumeRunFromRecoveryActionLoading: false, + isResumeRunFromRecoveryAssumingFalsePositiveActionLoading: false, }) when(vi.mocked(useMostRecentCompletedAnalysis)) .calledWith(RUN_ID) diff --git a/app/src/redux-resources/runs/hooks/index.ts b/app/src/redux-resources/runs/hooks/index.ts new file mode 100644 index 00000000000..7427ca864da --- /dev/null +++ b/app/src/redux-resources/runs/hooks/index.ts @@ -0,0 +1 @@ +export * from './useRequiredSetupStepsInOrder' diff --git a/app/src/redux-resources/runs/hooks/useRequiredSetupStepsInOrder.ts b/app/src/redux-resources/runs/hooks/useRequiredSetupStepsInOrder.ts new file mode 100644 index 00000000000..481a3622f05 --- /dev/null +++ b/app/src/redux-resources/runs/hooks/useRequiredSetupStepsInOrder.ts @@ -0,0 +1,112 @@ +import { useEffect } from 'react' +import { useDispatch, useSelector } from 'react-redux' + +import { + updateRunSetupStepsRequired, + getSetupStepsRequired, + ROBOT_CALIBRATION_STEP_KEY, + MODULE_SETUP_STEP_KEY, + LPC_STEP_KEY, + LABWARE_SETUP_STEP_KEY, + LIQUID_SETUP_STEP_KEY, +} from '/app/redux/protocol-runs' + +import type { + StepKey, + StepMap, + UpdateRunSetupStepsRequiredAction, +} from '/app/redux/protocol-runs' +import type { Dispatch, State } from '/app/redux/types' +import type { + CompletedProtocolAnalysis, + ProtocolAnalysisOutput, +} from '@opentrons/shared-data' + +export interface UseRequiredSetupStepsInOrderProps { + runId: string + protocolAnalysis: CompletedProtocolAnalysis | ProtocolAnalysisOutput | null +} + +export interface UseRequiredSetupStepsInOrderReturn { + orderedSteps: readonly StepKey[] + orderedApplicableSteps: readonly StepKey[] +} + +const ALL_STEPS_IN_ORDER = [ + ROBOT_CALIBRATION_STEP_KEY, + MODULE_SETUP_STEP_KEY, + LPC_STEP_KEY, + LABWARE_SETUP_STEP_KEY, + LIQUID_SETUP_STEP_KEY, +] as const + +const NO_ANALYSIS_STEPS_IN_ORDER = [ + ROBOT_CALIBRATION_STEP_KEY, + LPC_STEP_KEY, + LABWARE_SETUP_STEP_KEY, +] + +const keysInOrder = ( + protocolAnalysis: CompletedProtocolAnalysis | ProtocolAnalysisOutput | null +): UseRequiredSetupStepsInOrderReturn => { + const orderedSteps = + protocolAnalysis == null ? NO_ANALYSIS_STEPS_IN_ORDER : ALL_STEPS_IN_ORDER + + const orderedApplicableSteps = + protocolAnalysis == null + ? NO_ANALYSIS_STEPS_IN_ORDER + : ALL_STEPS_IN_ORDER.filter((stepKey: StepKey) => { + if (protocolAnalysis.modules.length === 0) { + return stepKey !== MODULE_SETUP_STEP_KEY + } + + if (protocolAnalysis.liquids.length === 0) { + return stepKey !== LIQUID_SETUP_STEP_KEY + } + return true + }) + return { orderedSteps: orderedSteps as StepKey[], orderedApplicableSteps } +} + +const keyFor = ( + analysis: CompletedProtocolAnalysis | ProtocolAnalysisOutput | null + // @ts-expect-error(sf, 2024-10-23): purposeful weak object typing +): string | null => analysis?.id ?? analysis?.metadata?.id ?? null + +export function useRequiredSetupStepsInOrder({ + runId, + protocolAnalysis, +}: UseRequiredSetupStepsInOrderProps): UseRequiredSetupStepsInOrderReturn { + const dispatch = useDispatch() + const requiredSteps = useSelector(state => + getSetupStepsRequired(state, runId) + ) + + useEffect(() => { + const applicable = keysInOrder(protocolAnalysis) + dispatch( + updateRunSetupStepsRequired(runId, { + ...ALL_STEPS_IN_ORDER.reduce< + UpdateRunSetupStepsRequiredAction['payload']['required'] + >( + (acc, thiskey) => ({ + ...acc, + [thiskey]: applicable.orderedApplicableSteps.includes(thiskey), + }), + {} + ), + }) + ) + }, [runId, keyFor(protocolAnalysis), dispatch]) + return protocolAnalysis == null + ? { + orderedSteps: NO_ANALYSIS_STEPS_IN_ORDER, + orderedApplicableSteps: NO_ANALYSIS_STEPS_IN_ORDER, + } + : { + orderedSteps: ALL_STEPS_IN_ORDER, + orderedApplicableSteps: ALL_STEPS_IN_ORDER.filter( + step => (requiredSteps as Required> | null)?.[step] + ), + } +} diff --git a/app/src/redux-resources/runs/index.ts b/app/src/redux-resources/runs/index.ts new file mode 100644 index 00000000000..fc78d35129c --- /dev/null +++ b/app/src/redux-resources/runs/index.ts @@ -0,0 +1 @@ +export * from './hooks' diff --git a/app/src/redux/analytics/__tests__/make-event.test.ts b/app/src/redux/analytics/__tests__/make-event.test.ts index bd938292d5a..70506dc162a 100644 --- a/app/src/redux/analytics/__tests__/make-event.test.ts +++ b/app/src/redux/analytics/__tests__/make-event.test.ts @@ -121,4 +121,23 @@ describe('analytics events map', () => { }) }) }) + + describe('events with calibration data', () => { + it('analytics:RESOURCE_MONITOR_REPORT -> resourceMonitorReport event', () => { + const state = {} as any + const action = { + type: 'analytics:RESOURCE_MONITOR_REPORT', + payload: { + systemAvailMemMb: '500', + systemUptimeHrs: '111', + processesDetails: [], + }, + } as any + + return expect(makeEvent(action, state)).resolves.toEqual({ + name: 'resourceMonitorReport', + properties: { ...action.payload }, + }) + }) + }) }) diff --git a/app/src/redux/analytics/constants.ts b/app/src/redux/analytics/constants.ts index cf99bfad9ea..cde9b0a1d59 100644 --- a/app/src/redux/analytics/constants.ts +++ b/app/src/redux/analytics/constants.ts @@ -97,3 +97,9 @@ export const ANALYTICS_QUICK_TRANSFER_DETAILS_PAGE = 'quickTransferDetailsPage' export const ANALYTICS_QUICK_TRANSFER_RUN_FROM_DETAILS = 'quickTransferRunFromDetails' export const ANALYTICS_QUICK_TRANSFER_RERUN = 'quickTransferReRunFromSummary' + +/** + * Resource Monitor Analytics + */ +export const ANALYTICS_RESOURCE_MONITOR_REPORT: 'analytics:RESOURCE_MONITOR_REPORT' = + 'analytics:RESOURCE_MONITOR_REPORT' diff --git a/app/src/redux/analytics/make-event.ts b/app/src/redux/analytics/make-event.ts index da3a812fbdc..bc5c8955104 100644 --- a/app/src/redux/analytics/make-event.ts +++ b/app/src/redux/analytics/make-event.ts @@ -247,6 +247,15 @@ export function makeEvent( }) } + case Constants.ANALYTICS_RESOURCE_MONITOR_REPORT: { + return Promise.resolve({ + name: 'resourceMonitorReport', + properties: { + ...action.payload, + }, + }) + } + case RobotAdmin.RESET_CONFIG: { const { resets } = action.payload return Promise.resolve({ diff --git a/app/src/redux/analytics/mixpanel.ts b/app/src/redux/analytics/mixpanel.ts index 20a5a2ed170..aa5ad5a7893 100644 --- a/app/src/redux/analytics/mixpanel.ts +++ b/app/src/redux/analytics/mixpanel.ts @@ -43,9 +43,12 @@ export function trackEvent( log.debug('Trackable event', { event, optedIn }) if (MIXPANEL_ID != null && optedIn) { - if (event.superProperties != null) mixpanel.register(event.superProperties) - if ('name' in event && event.name != null) + if (event.superProperties != null) { + mixpanel.register(event.superProperties) + } + if ('name' in event && event.name != null) { mixpanel.track(event.name, event.properties) + } } } diff --git a/app/src/redux/analytics/types.ts b/app/src/redux/analytics/types.ts index dfb3e374ad9..d27c2955fe2 100644 --- a/app/src/redux/analytics/types.ts +++ b/app/src/redux/analytics/types.ts @@ -5,6 +5,7 @@ import type { Config } from '../config/types' import type { ANALYTICS_PIPETTE_OFFSET_STARTED, ANALYTICS_TIP_LENGTH_STARTED, + ANALYTICS_RESOURCE_MONITOR_REPORT, } from './constants' export type AnalyticsConfig = Config['analytics'] @@ -118,9 +119,19 @@ export interface TipLengthStartedAnalyticsAction { } } +export interface ResourceMonitorAnalyticsAction { + type: typeof ANALYTICS_RESOURCE_MONITOR_REPORT + payload: { + systemAvailMemMb: string + systemUptimeHrs: string + processesDetails: Array> + } +} + export type AnalyticsTriggerAction = | PipetteOffsetStartedAnalyticsAction | TipLengthStartedAnalyticsAction + | ResourceMonitorAnalyticsAction export interface SessionInstrumentAnalyticsData { sessionType: string diff --git a/app/src/redux/config/__tests__/config.test.ts b/app/src/redux/config/__tests__/config.test.ts index d99eb95c36e..bf5b4e98004 100644 --- a/app/src/redux/config/__tests__/config.test.ts +++ b/app/src/redux/config/__tests__/config.test.ts @@ -28,6 +28,7 @@ describe('config', () => { expect(Cfg.configInitialized(state.config as any)).toEqual({ type: 'config:INITIALIZED', payload: { config: state.config }, + meta: { shell: true }, }) }) @@ -35,6 +36,7 @@ describe('config', () => { expect(Cfg.configValueUpdated('foo.bar', false)).toEqual({ type: 'config:VALUE_UPDATED', payload: { path: 'foo.bar', value: false }, + meta: { shell: true }, }) }) diff --git a/app/src/redux/config/actions.ts b/app/src/redux/config/actions.ts index e0a6906b17f..915fce0a8f0 100644 --- a/app/src/redux/config/actions.ts +++ b/app/src/redux/config/actions.ts @@ -55,6 +55,7 @@ export const configInitialized = ( ): Types.ConfigInitializedAction => ({ type: Constants.INITIALIZED, payload: { config }, + meta: { shell: true }, }) // config value has been updated @@ -64,6 +65,7 @@ export const configValueUpdated = ( ): Types.ConfigValueUpdatedAction => ({ type: Constants.VALUE_UPDATED, payload: { path, value }, + meta: { shell: true }, }) export function toggleDevtools(): Types.ToggleConfigValueAction { diff --git a/app/src/redux/config/constants.ts b/app/src/redux/config/constants.ts index 4cd981093fc..cc39206a191 100644 --- a/app/src/redux/config/constants.ts +++ b/app/src/redux/config/constants.ts @@ -7,6 +7,7 @@ export const DEV_INTERNAL_FLAGS: DevInternalFlag[] = [ 'protocolTimeline', 'enableLabwareCreator', 'enableLocalization', + 'reactQueryDevtools', ] // action type constants diff --git a/app/src/redux/config/schema-types.ts b/app/src/redux/config/schema-types.ts index ce4af4ddaeb..ac87cd07576 100644 --- a/app/src/redux/config/schema-types.ts +++ b/app/src/redux/config/schema-types.ts @@ -1,4 +1,5 @@ import type { LogLevel } from '../../logger' +import type { Language } from '/app/i18n' import type { ProtocolSort } from '/app/redux/protocol-storage' export type UrlProtocol = 'file:' | 'http:' @@ -14,6 +15,7 @@ export type DevInternalFlag = | 'protocolTimeline' | 'enableLabwareCreator' | 'enableLocalization' + | 'reactQueryDevtools' export type FeatureFlags = Partial> @@ -31,8 +33,6 @@ export type QuickTransfersOnDeviceSortKey = | 'recentCreated' | 'oldCreated' -export type Language = 'en-US' | 'zh-CN' - export interface OnDeviceDisplaySettings { sleepMs: number brightness: number diff --git a/app/src/redux/config/selectors.ts b/app/src/redux/config/selectors.ts index 4069aa7320b..dbac2cd3c05 100644 --- a/app/src/redux/config/selectors.ts +++ b/app/src/redux/config/selectors.ts @@ -8,8 +8,8 @@ import type { ProtocolsOnDeviceSortKey, QuickTransfersOnDeviceSortKey, OnDeviceDisplaySettings, - Language, } from './types' +import type { Language } from '/app/i18n' import type { ProtocolSort } from '/app/redux/protocol-storage' export interface SelectOption { diff --git a/app/src/redux/config/types.ts b/app/src/redux/config/types.ts index b408a2204e2..5d6b4b83ac9 100644 --- a/app/src/redux/config/types.ts +++ b/app/src/redux/config/types.ts @@ -16,11 +16,13 @@ export type ConfigState = Config | null export interface ConfigInitializedAction { type: typeof INITIALIZED payload: { config: Config } + meta: { shell: true } } export interface ConfigValueUpdatedAction { type: typeof VALUE_UPDATED payload: { path: string; value: any } + meta: { shell: true } } export interface UpdateConfigValueAction { diff --git a/app/src/redux/protocol-runs/__tests__/reducer.test.ts b/app/src/redux/protocol-runs/__tests__/reducer.test.ts new file mode 100644 index 00000000000..e10ce306f7d --- /dev/null +++ b/app/src/redux/protocol-runs/__tests__/reducer.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest' + +import { protocolRunReducer } from '../reducer' +import { + updateRunSetupStepsComplete, + updateRunSetupStepsRequired, +} from '../actions' +import * as Constants from '../constants' + +describe('protocol runs reducer', () => { + const INITIAL = { + [Constants.ROBOT_CALIBRATION_STEP_KEY]: { + required: true, + complete: false, + }, + [Constants.MODULE_SETUP_STEP_KEY]: { required: true, complete: false }, + [Constants.LPC_STEP_KEY]: { required: true, complete: false }, + [Constants.LABWARE_SETUP_STEP_KEY]: { + required: true, + complete: false, + }, + [Constants.LIQUID_SETUP_STEP_KEY]: { required: true, complete: false }, + } + it('establishes an empty state if you tell it one', () => { + const nextState = protocolRunReducer( + undefined, + updateRunSetupStepsComplete('some-run-id', {}) + ) + expect(nextState['some-run-id']?.setup).toEqual(INITIAL) + }) + it('updates complete based on an action', () => { + const nextState = protocolRunReducer( + { + 'some-run-id': { + setup: { + ...INITIAL, + [Constants.LIQUID_SETUP_STEP_KEY]: { + complete: true, + required: true, + }, + }, + }, + }, + updateRunSetupStepsComplete('some-run-id', { + [Constants.LPC_STEP_KEY]: true, + }) + ) + expect(nextState['some-run-id']?.setup).toEqual({ + ...INITIAL, + [Constants.LIQUID_SETUP_STEP_KEY]: { + required: true, + complete: true, + }, + [Constants.LPC_STEP_KEY]: { required: true, complete: true }, + }) + }) + it('updates required based on an action', () => { + const nextState = protocolRunReducer( + { + 'some-run-id': { + setup: INITIAL, + }, + }, + updateRunSetupStepsRequired('some-run-id', { + [Constants.LIQUID_SETUP_STEP_KEY]: false, + }) + ) + expect(nextState['some-run-id']?.setup).toEqual({ + ...INITIAL, + [Constants.LIQUID_SETUP_STEP_KEY]: { + required: false, + complete: false, + }, + }) + }) +}) diff --git a/app/src/redux/protocol-runs/actions.ts b/app/src/redux/protocol-runs/actions.ts new file mode 100644 index 00000000000..378ee297ed2 --- /dev/null +++ b/app/src/redux/protocol-runs/actions.ts @@ -0,0 +1,18 @@ +import * as Constants from './constants' +import type * as Types from './types' + +export const updateRunSetupStepsComplete = ( + runId: string, + complete: Types.UpdateRunSetupStepsCompleteAction['payload']['complete'] +): Types.UpdateRunSetupStepsCompleteAction => ({ + type: Constants.UPDATE_RUN_SETUP_STEPS_COMPLETE, + payload: { runId, complete }, +}) + +export const updateRunSetupStepsRequired = ( + runId: string, + required: Types.UpdateRunSetupStepsRequiredAction['payload']['required'] +): Types.UpdateRunSetupStepsRequiredAction => ({ + type: Constants.UPDATE_RUN_SETUP_STEPS_REQUIRED, + payload: { runId, required }, +}) diff --git a/app/src/redux/protocol-runs/constants.ts b/app/src/redux/protocol-runs/constants.ts new file mode 100644 index 00000000000..04f28f760d3 --- /dev/null +++ b/app/src/redux/protocol-runs/constants.ts @@ -0,0 +1,18 @@ +export const ROBOT_CALIBRATION_STEP_KEY: 'robot_calibration_step' = + 'robot_calibration_step' +export const MODULE_SETUP_STEP_KEY: 'module_setup_step' = 'module_setup_step' +export const LPC_STEP_KEY: 'labware_position_check_step' = + 'labware_position_check_step' +export const LABWARE_SETUP_STEP_KEY: 'labware_setup_step' = 'labware_setup_step' +export const LIQUID_SETUP_STEP_KEY: 'liquid_setup_step' = 'liquid_setup_step' + +export const SETUP_STEP_KEYS = [ + ROBOT_CALIBRATION_STEP_KEY, + MODULE_SETUP_STEP_KEY, + LPC_STEP_KEY, + LABWARE_SETUP_STEP_KEY, + LIQUID_SETUP_STEP_KEY, +] as const + +export const UPDATE_RUN_SETUP_STEPS_COMPLETE = 'protocolRuns:UPDATE_RUN_SETUP_STEPS_COMPLETE' as const +export const UPDATE_RUN_SETUP_STEPS_REQUIRED = 'protocolRuns:UPDATE_RUN_SETUP_STEPS_REQUIRED' as const diff --git a/app/src/redux/protocol-runs/index.ts b/app/src/redux/protocol-runs/index.ts new file mode 100644 index 00000000000..9f709c0dbcb --- /dev/null +++ b/app/src/redux/protocol-runs/index.ts @@ -0,0 +1,7 @@ +// runs constants, actions, selectors, and types + +export * from './actions' +export * from './constants' +export * from './selectors' + +export type * from './types' diff --git a/app/src/redux/protocol-runs/reducer.ts b/app/src/redux/protocol-runs/reducer.ts new file mode 100644 index 00000000000..0b2d8378a67 --- /dev/null +++ b/app/src/redux/protocol-runs/reducer.ts @@ -0,0 +1,63 @@ +import * as Constants from './constants' + +import type { Reducer } from 'redux' +import type { Action } from '../types' + +import type { ProtocolRunState, RunSetupStatus } from './types' + +const INITIAL_STATE: ProtocolRunState = {} + +const INITIAL_SETUP_STEP_STATE = { complete: false, required: true } + +const INITIAL_RUN_SETUP_STATE: RunSetupStatus = { + [Constants.ROBOT_CALIBRATION_STEP_KEY]: INITIAL_SETUP_STEP_STATE, + [Constants.MODULE_SETUP_STEP_KEY]: INITIAL_SETUP_STEP_STATE, + [Constants.LPC_STEP_KEY]: INITIAL_SETUP_STEP_STATE, + [Constants.LABWARE_SETUP_STEP_KEY]: INITIAL_SETUP_STEP_STATE, + [Constants.LIQUID_SETUP_STEP_KEY]: INITIAL_SETUP_STEP_STATE, +} + +export const protocolRunReducer: Reducer = ( + state = INITIAL_STATE, + action +) => { + switch (action.type) { + case Constants.UPDATE_RUN_SETUP_STEPS_COMPLETE: { + return { + ...state, + [action.payload.runId]: { + setup: Constants.SETUP_STEP_KEYS.reduce( + (currentState, step) => ({ + ...currentState, + [step]: { + complete: + action.payload.complete[step] ?? currentState[step].complete, + required: currentState[step].required, + }, + }), + state[action.payload.runId]?.setup ?? INITIAL_RUN_SETUP_STATE + ), + }, + } + } + case Constants.UPDATE_RUN_SETUP_STEPS_REQUIRED: { + return { + ...state, + [action.payload.runId]: { + setup: Constants.SETUP_STEP_KEYS.reduce( + (currentState, step) => ({ + ...currentState, + [step]: { + required: + action.payload.required[step] ?? currentState[step].required, + complete: currentState[step].complete, + }, + }), + state[action.payload.runId]?.setup ?? INITIAL_RUN_SETUP_STATE + ), + }, + } + } + } + return state +} diff --git a/app/src/redux/protocol-runs/selectors.ts b/app/src/redux/protocol-runs/selectors.ts new file mode 100644 index 00000000000..ca91c7a71ab --- /dev/null +++ b/app/src/redux/protocol-runs/selectors.ts @@ -0,0 +1,91 @@ +import type { State } from '../types' +import type * as Types from './types' + +export const getSetupStepComplete: ( + state: State, + runId: string, + step: Types.StepKey +) => boolean | null = (state, runId, step) => + getSetupStepsComplete(state, runId)?.[step] ?? null + +export const getSetupStepsComplete: ( + state: State, + runId: string +) => Types.StepMap | null = (state, runId) => { + const setup = state.protocolRuns[runId]?.setup + if (setup == null) { + return null + } + return (Object.entries(setup) as Array< + [Types.StepKey, Types.StepState] + >).reduce>>( + (acc, [step, state]) => ({ + ...acc, + [step]: state.complete, + }), + {} + ) as Types.StepMap +} + +export const getSetupStepRequired: ( + state: State, + runId: string, + step: Types.StepKey +) => boolean | null = (state, runId, step) => + getSetupStepsRequired(state, runId)?.[step] ?? null + +export const getSetupStepsRequired: ( + state: State, + runId: string +) => Types.StepMap | null = (state, runId) => { + const setup = state.protocolRuns[runId]?.setup + if (setup == null) { + return null + } + return (Object.entries(setup) as Array< + [Types.StepKey, Types.StepState] + >).reduce>>( + (acc, [step, state]) => ({ ...acc, [step]: state.required }), + {} + ) as Types.StepMap +} + +export const getSetupStepMissing: ( + state: State, + runId: string, + step: Types.StepKey +) => boolean | null = (state, runId, step) => + getSetupStepsMissing(state, runId)?.[step] || null + +export const getSetupStepsMissing: ( + state: State, + runId: string +) => Types.StepMap | null = (state, runId) => { + const setup = state.protocolRuns[runId]?.setup + if (setup == null) { + return null + } + return (Object.entries(setup) as Array< + [Types.StepKey, Types.StepState] + >).reduce>>( + (acc, [step, state]) => ({ + ...acc, + [step]: state.required && !state.complete, + }), + {} + ) as Types.StepMap +} + +export const getMissingSetupSteps: ( + state: State, + runId: string +) => Types.StepKey[] = (state, runId) => { + const missingStepMap = getSetupStepsMissing(state, runId) + if (missingStepMap == null) return [] + const missingStepList = (Object.entries(missingStepMap) as Array< + [Types.StepKey, boolean] + >) + .map(([step, missing]) => (missing ? step : null)) + .filter(stepName => stepName != null) + return missingStepList as Types.StepKey[] +} diff --git a/app/src/redux/protocol-runs/types.ts b/app/src/redux/protocol-runs/types.ts new file mode 100644 index 00000000000..c14d556d495 --- /dev/null +++ b/app/src/redux/protocol-runs/types.ts @@ -0,0 +1,61 @@ +import type { + ROBOT_CALIBRATION_STEP_KEY, + MODULE_SETUP_STEP_KEY, + LPC_STEP_KEY, + LABWARE_SETUP_STEP_KEY, + LIQUID_SETUP_STEP_KEY, + UPDATE_RUN_SETUP_STEPS_COMPLETE, + UPDATE_RUN_SETUP_STEPS_REQUIRED, +} from './constants' + +export type RobotCalibrationStepKey = typeof ROBOT_CALIBRATION_STEP_KEY +export type ModuleSetupStepKey = typeof MODULE_SETUP_STEP_KEY +export type LPCStepKey = typeof LPC_STEP_KEY +export type LabwareSetupStepKey = typeof LABWARE_SETUP_STEP_KEY +export type LiquidSetupStepKey = typeof LIQUID_SETUP_STEP_KEY + +export type StepKey = + | RobotCalibrationStepKey + | ModuleSetupStepKey + | LPCStepKey + | LabwareSetupStepKey + | LiquidSetupStepKey + +export interface StepState { + required: boolean + complete: boolean +} + +export type StepMap = { [Step in StepKey]: V } + +export type RunSetupStatus = { + [Step in StepKey]: StepState +} + +export interface PerRunUIState { + setup: RunSetupStatus +} + +export type ProtocolRunState = Partial<{ + readonly [runId: string]: PerRunUIState +}> + +export interface UpdateRunSetupStepsCompleteAction { + type: typeof UPDATE_RUN_SETUP_STEPS_COMPLETE + payload: { + runId: string + complete: Partial<{ [Step in StepKey]: boolean }> + } +} + +export interface UpdateRunSetupStepsRequiredAction { + type: typeof UPDATE_RUN_SETUP_STEPS_REQUIRED + payload: { + runId: string + required: Partial<{ [Step in StepKey]: boolean }> + } +} + +export type ProtocolRunAction = + | UpdateRunSetupStepsCompleteAction + | UpdateRunSetupStepsRequiredAction diff --git a/app/src/redux/reducer.ts b/app/src/redux/reducer.ts index 44831b0d70e..e21dbded781 100644 --- a/app/src/redux/reducer.ts +++ b/app/src/redux/reducer.ts @@ -48,6 +48,9 @@ import { calibrationReducer } from './calibration/reducer' // local protocol storage from file system state import { protocolStorageReducer } from './protocol-storage/reducer' +// local protocol run state +import { protocolRunReducer } from './protocol-runs/reducer' + import type { Reducer } from 'redux' import type { State, Action } from './types' @@ -68,4 +71,5 @@ export const rootReducer: Reducer = combineReducers({ sessions: sessionReducer, calibration: calibrationReducer, protocolStorage: protocolStorageReducer, + protocolRuns: protocolRunReducer, }) diff --git a/app/src/redux/shell/update.ts b/app/src/redux/shell/update.ts index 7c9e3be1f58..aa5fb601840 100644 --- a/app/src/redux/shell/update.ts +++ b/app/src/redux/shell/update.ts @@ -3,11 +3,7 @@ import { createSelector } from 'reselect' import type { State } from '../types' -import type { - ShellUpdateAction, - ShellUpdateState, - RobotMassStorageDeviceEnumerated, -} from './types' +import type { ShellUpdateAction, ShellUpdateState } from './types' // command sent to app-shell via meta.shell === true export function checkShellUpdate(): ShellUpdateAction { @@ -37,16 +33,3 @@ export const getAvailableShellUpdate: ( ) => string | null = createSelector(getShellUpdateState, state => state.available && state.info ? state.info.version : null ) - -export function checkMassStorage( - state: State -): RobotMassStorageDeviceEnumerated { - return { - type: 'shell:ROBOT_MASS_STORAGE_DEVICE_ENUMERATED', - payload: { - rootPath: '', - filePaths: state.shell.filePaths, - }, - meta: { shell: true }, - } -} diff --git a/app/src/redux/types.ts b/app/src/redux/types.ts index 9ed69c3e71f..d3f502cdc40 100644 --- a/app/src/redux/types.ts +++ b/app/src/redux/types.ts @@ -37,6 +37,8 @@ import type { AlertsState, AlertsAction } from './alerts/types' import type { SessionState, SessionsAction } from './sessions/types' import type { AnalyticsTriggerAction } from './analytics/types' +import type { ProtocolRunState, ProtocolRunAction } from './protocol-runs/types' + export interface State { readonly robotApi: RobotApiState readonly robotAdmin: RobotAdminState @@ -54,6 +56,7 @@ export interface State { readonly sessions: SessionState readonly calibration: CalibrationState readonly protocolStorage: ProtocolStorageState + readonly protocolRuns: ProtocolRunState } export type Action = @@ -78,6 +81,7 @@ export type Action = | CalibrationAction | AnalyticsTriggerAction | AddCustomLabwareFromCreatorAction + | ProtocolRunAction export type GetState = () => State diff --git a/app/src/resources/modules/hooks/index.ts b/app/src/resources/modules/hooks/index.ts index c38e5f46140..c26e43c8bfc 100644 --- a/app/src/resources/modules/hooks/index.ts +++ b/app/src/resources/modules/hooks/index.ts @@ -1 +1,2 @@ export * from './useAttachedModules' +export * from './usePlacePlateReaderLid' diff --git a/app/src/resources/modules/hooks/usePlacePlateReaderLid.ts b/app/src/resources/modules/hooks/usePlacePlateReaderLid.ts new file mode 100644 index 00000000000..0e4dabcb660 --- /dev/null +++ b/app/src/resources/modules/hooks/usePlacePlateReaderLid.ts @@ -0,0 +1,82 @@ +import { useRunCurrentState } from '@opentrons/react-api-client' +import { useCurrentRunId } from '../../runs' +import { useRobotControlCommands } from '/app/resources/maintenance_runs' + +import type { + CreateCommand, + OnDeckLabwareLocation, + ModuleLocation, +} from '@opentrons/shared-data' +import type { UseRobotControlCommandsProps } from '/app/resources/maintenance_runs' + +interface UsePlacePlateReaderLidResult { + handlePlaceReaderLid: () => Promise + isExecuting: boolean + isValidPlateReaderMove: boolean +} + +type UsePlacePlateReaderLidProps = Pick< + UseRobotControlCommandsProps, + 'onSettled' +> + +export function usePlacePlateReaderLid( + props: UsePlacePlateReaderLidProps +): UsePlacePlateReaderLidResult { + const runId = useCurrentRunId() + const { data: runCurrentState } = useRunCurrentState(runId) + + const placeLabware = runCurrentState?.data.placeLabwareState ?? null + const isValidPlateReaderMove = + placeLabware !== null && placeLabware.shouldPlaceDown + + // TODO eventually load module support for useRobotControlCommands + let commandsToExecute: CreateCommand[] = [] + if (isValidPlateReaderMove) { + const location = placeLabware.location + const loadModuleCommand = buildLoadModuleCommand(location as ModuleLocation) + const placeLabwareCommand = buildPlaceLabwareCommand( + placeLabware.labwareId as string, + location + ) + commandsToExecute = [loadModuleCommand, placeLabwareCommand] + } + + const { executeCommands, isExecuting } = useRobotControlCommands({ + ...props, + pipetteInfo: null, + commands: commandsToExecute, + continuePastCommandFailure: true, + }) + + const handlePlaceReaderLid = (): Promise => { + if (isValidPlateReaderMove) { + return executeCommands().then(() => Promise.resolve()) + } else { + return Promise.resolve() + } + } + + return { + handlePlaceReaderLid, + isExecuting, + isValidPlateReaderMove, + } +} + +const buildLoadModuleCommand = (location: ModuleLocation): CreateCommand => { + return { + commandType: 'loadModule' as const, + params: { model: 'absorbanceReaderV1', location }, + } +} + +const buildPlaceLabwareCommand = ( + labwareId: string, + location: OnDeckLabwareLocation +): CreateCommand => { + return { + commandType: 'unsafe/placeLabware' as const, + params: { labwareId, location }, + } +} diff --git a/app/src/transformations/commands/transformations/__tests__/getLocationInfoNames.test.ts b/app/src/transformations/commands/transformations/__tests__/getLocationInfoNames.test.ts index d0b3551972f..f722caeb076 100644 --- a/app/src/transformations/commands/transformations/__tests__/getLocationInfoNames.test.ts +++ b/app/src/transformations/commands/transformations/__tests__/getLocationInfoNames.test.ts @@ -1,5 +1,8 @@ import { describe, it, vi, expect, beforeEach } from 'vitest' -import { getLabwareDisplayName } from '@opentrons/shared-data' +import { + getLabwareDisplayName, + getLabwareStackCountAndLocation, +} from '@opentrons/shared-data' import { getLocationInfoNames } from '../getLocationInfoNames' import type { ModuleModel } from '@opentrons/shared-data' @@ -154,11 +157,16 @@ vi.mock('@opentrons/shared-data') describe('getLocationInfoNames', () => { beforeEach(() => { vi.mocked(getLabwareDisplayName).mockReturnValue(LABWARE_DISPLAY_NAME) + vi.mocked(getLabwareStackCountAndLocation).mockReturnValue({ + labwareLocation: { slotName: SLOT }, + labwareQuantity: 1, + }) }) it('returns labware name and slot number for labware id on the deck', () => { const expected = { slotName: SLOT, labwareName: LABWARE_DISPLAY_NAME, + labwareQuantity: 1, } expect( getLocationInfoNames(LABWARE_ID, MOCK_LOAD_LABWARE_COMMANDS as any) @@ -169,7 +177,12 @@ describe('getLocationInfoNames', () => { slotName: SLOT, labwareName: LABWARE_DISPLAY_NAME, moduleModel: MOCK_MODEL, + labwareQuantity: 1, } + vi.mocked(getLabwareStackCountAndLocation).mockReturnValue({ + labwareLocation: { moduleId: '12345' }, + labwareQuantity: 1, + }) expect(getLocationInfoNames(LABWARE_ID, MOCK_MOD_COMMANDS as any)).toEqual( expected ) @@ -181,7 +194,12 @@ describe('getLocationInfoNames', () => { moduleModel: MOCK_MODEL, adapterName: ADAPTER_DISPLAY_NAME, adapterId: ADAPTER_ID, + labwareQuantity: 1, } + vi.mocked(getLabwareStackCountAndLocation).mockReturnValue({ + labwareLocation: { labwareId: ADAPTER_ID }, + labwareQuantity: 1, + }) expect( getLocationInfoNames(LABWARE_ID, MOCK_ADAPTER_MOD_COMMANDS as any) ).toEqual(expected) @@ -192,7 +210,12 @@ describe('getLocationInfoNames', () => { labwareName: LABWARE_DISPLAY_NAME, adapterName: ADAPTER_DISPLAY_NAME, adapterId: ADAPTER_ID, + labwareQuantity: 1, } + vi.mocked(getLabwareStackCountAndLocation).mockReturnValue({ + labwareLocation: { labwareId: ADAPTER_ID }, + labwareQuantity: 1, + }) expect( getLocationInfoNames(LABWARE_ID, MOCK_ADAPTER_COMMANDS as any) ).toEqual(expected) @@ -203,7 +226,12 @@ describe('getLocationInfoNames', () => { labwareName: LABWARE_DISPLAY_NAME, adapterName: ADAPTER_DISPLAY_NAME, adapterId: ADAPTER_ID, + labwareQuantity: 1, } + vi.mocked(getLabwareStackCountAndLocation).mockReturnValue({ + labwareLocation: { labwareId: ADAPTER_ID }, + labwareQuantity: 1, + }) expect( getLocationInfoNames(LABWARE_ID, MOCK_ADAPTER_EXTENSION_COMMANDS as any) ).toEqual(expected) diff --git a/app/src/transformations/commands/transformations/getLocationInfoNames.ts b/app/src/transformations/commands/transformations/getLocationInfoNames.ts index 26d618859f9..e87f5d68ba8 100644 --- a/app/src/transformations/commands/transformations/getLocationInfoNames.ts +++ b/app/src/transformations/commands/transformations/getLocationInfoNames.ts @@ -1,4 +1,8 @@ -import { getLabwareDisplayName } from '@opentrons/shared-data' +import { + getLabwareDisplayName, + getLabwareStackCountAndLocation, +} from '@opentrons/shared-data' + import type { LoadLabwareRunTimeCommand, RunTimeCommand, @@ -10,6 +14,7 @@ export interface LocationInfoNames { slotName: string labwareName: string labwareNickname?: string + labwareQuantity: number adapterName?: string moduleModel?: ModuleModel adapterId?: string @@ -30,11 +35,11 @@ export function getLocationInfoNames( (command): command is LoadModuleRunTimeCommand => command.commandType === 'loadModule' ) - if (loadLabwareCommand == null) { + if (loadLabwareCommands == null || loadLabwareCommand == null) { console.warn( `could not find the load labware command assosciated with thie labwareId: ${labwareId}` ) - return { slotName: '', labwareName: '' } + return { slotName: '', labwareName: '', labwareQuantity: 0 } } const labwareName = @@ -43,14 +48,21 @@ export function getLocationInfoNames( : '' const labwareNickname = loadLabwareCommand.params.displayName - const labwareLocation = loadLabwareCommand.params.location + const { labwareLocation, labwareQuantity } = getLabwareStackCountAndLocation( + labwareId, + loadLabwareCommands + ) if (labwareLocation === 'offDeck') { - return { slotName: 'Off deck', labwareName } + return { slotName: 'Off deck', labwareName, labwareQuantity } } else if ('slotName' in labwareLocation) { - return { slotName: labwareLocation.slotName, labwareName } + return { slotName: labwareLocation.slotName, labwareName, labwareQuantity } } else if ('addressableAreaName' in labwareLocation) { - return { slotName: labwareLocation.addressableAreaName, labwareName } + return { + slotName: labwareLocation.addressableAreaName, + labwareName, + labwareQuantity, + } } else if ('moduleId' in labwareLocation) { const loadModuleCommandUnderLabware = loadModuleCommands?.find( command => command.result?.moduleId === labwareLocation.moduleId @@ -62,9 +74,11 @@ export function getLocationInfoNames( loadModuleCommandUnderLabware?.params.location.slotName ?? '', labwareName, moduleModel: loadModuleCommandUnderLabware?.params.model, + labwareQuantity, } - : { slotName: '', labwareName: '' } + : { slotName: '', labwareName: '', labwareQuantity } } else { + // adapt this to return the adapter only if the role of this labware is adapter -- otherwise, keep parsing through until you find out how many identical labware there are const loadedAdapterCommand = loadLabwareCommands?.find(command => command.result != null ? command.result?.labwareId === labwareLocation.labwareId @@ -74,7 +88,7 @@ export function getLocationInfoNames( console.warn( `expected to find an adapter under the labware but could not with labwareId ${labwareLocation.labwareId}` ) - return { slotName: '', labwareName: labwareName } + return { slotName: '', labwareName: labwareName, labwareQuantity } } else if ( loadedAdapterCommand?.params.location !== 'offDeck' && 'slotName' in loadedAdapterCommand?.params.location @@ -86,6 +100,7 @@ export function getLocationInfoNames( adapterName: loadedAdapterCommand?.result?.definition.metadata.displayName, adapterId: loadedAdapterCommand?.result?.labwareId, + labwareQuantity, } } else if ( loadedAdapterCommand?.params.location !== 'offDeck' && @@ -98,6 +113,7 @@ export function getLocationInfoNames( adapterName: loadedAdapterCommand?.result?.definition.metadata.displayName, adapterId: loadedAdapterCommand?.result?.labwareId, + labwareQuantity, } } else if ( loadedAdapterCommand?.params.location !== 'offDeck' && @@ -118,11 +134,12 @@ export function getLocationInfoNames( loadedAdapterCommand.result?.definition.metadata.displayName, adapterId: loadedAdapterCommand?.result?.labwareId, moduleModel: loadModuleCommandUnderAdapter.params.model, + labwareQuantity, } - : { slotName: '', labwareName } + : { slotName: '', labwareName, labwareQuantity } } else { // shouldn't hit this - return { slotName: '', labwareName } + return { slotName: '', labwareName, labwareQuantity } } } } diff --git a/components/src/atoms/InputField/index.tsx b/components/src/atoms/InputField/index.tsx index 8933ca5345c..c6d8eff68ea 100644 --- a/components/src/atoms/InputField/index.tsx +++ b/components/src/atoms/InputField/index.tsx @@ -2,7 +2,12 @@ import * as React from 'react' import styled, { css } from 'styled-components' import { Flex } from '../../primitives' -import { ALIGN_CENTER, DIRECTION_COLUMN, TEXT_ALIGN_RIGHT } from '../../styles' +import { + ALIGN_CENTER, + DIRECTION_COLUMN, + DIRECTION_ROW, + TEXT_ALIGN_RIGHT, +} from '../../styles' import { BORDERS, COLORS } from '../../helix-design-system' import { Icon } from '../../icons' import { RESPONSIVENESS, SPACING, TYPOGRAPHY } from '../../ui-style-constants' @@ -71,6 +76,7 @@ export interface InputFieldProps { leftIcon?: IconName showDeleteIcon?: boolean onDelete?: () => void + hasBackgroundError?: boolean } export const InputField = React.forwardRef( @@ -83,6 +89,8 @@ export const InputField = React.forwardRef( tooltipText, tabIndex = 0, showDeleteIcon = false, + hasBackgroundError = false, + onDelete, ...inputProps } = props const hasError = props.error != null @@ -103,11 +111,13 @@ export const InputField = React.forwardRef( const INPUT_FIELD = css` display: flex; - background-color: ${COLORS.white}; + background-color: ${hasBackgroundError ? COLORS.red30 : COLORS.white}; border-radius: ${BORDERS.borderRadius4}; padding: ${SPACING.spacing8}; - border: 1px ${BORDERS.styleSolid} - ${hasError ? COLORS.red50 : COLORS.grey50}; + border: ${hasBackgroundError + ? 'none' + : `1px ${BORDERS.styleSolid} + ${hasError ? COLORS.red50 : COLORS.grey50}`}; font-size: ${TYPOGRAPHY.fontSizeP}; width: 100%; height: ${size === 'small' ? '2rem' : '2.75rem'}; @@ -244,7 +254,11 @@ export const InputField = React.forwardRef( > {title != null ? ( - + ( {showDeleteIcon ? ( @@ -321,10 +335,7 @@ export const InputField = React.forwardRef( ) : null} {hasError ? ( - + {props.error} ) : null} @@ -335,6 +346,7 @@ export const InputField = React.forwardRef( ) const StyledInput = styled.input` + background-color: transparent; &::placeholder { color: ${COLORS.grey40}; } diff --git a/components/src/atoms/ListButton/ListButtonChildren/ListButtonRadioButton.tsx b/components/src/atoms/ListButton/ListButtonChildren/ListButtonRadioButton.tsx index 52a58e5f4ec..366075df05e 100644 --- a/components/src/atoms/ListButton/ListButtonChildren/ListButtonRadioButton.tsx +++ b/components/src/atoms/ListButton/ListButtonChildren/ListButtonRadioButton.tsx @@ -1,4 +1,3 @@ -import type * as React from 'react' import styled, { css } from 'styled-components' import { SPACING } from '../../../ui-style-constants' import { BORDERS, COLORS } from '../../../helix-design-system' @@ -6,12 +5,13 @@ import { Flex } from '../../../primitives' import { StyledText } from '../../StyledText' import { CURSOR_POINTER } from '../../../styles' +import type { ChangeEventHandler, MouseEvent } from 'react' import type { StyleProps } from '../../../primitives' interface ListButtonRadioButtonProps extends StyleProps { buttonText: string buttonValue: string | number - onChange: React.ChangeEventHandler + onChange: ChangeEventHandler setNoHover?: () => void setHovered?: () => void disabled?: boolean @@ -34,48 +34,11 @@ export function ListButtonRadioButton( id = buttonText, } = props - const SettingButton = styled.input` - display: none; - ` - - const AVAILABLE_BUTTON_STYLE = css` - background: ${COLORS.white}; - color: ${COLORS.black90}; - - &:hover { - background-color: ${COLORS.grey10}; - } - ` - - const SELECTED_BUTTON_STYLE = css` - background: ${COLORS.blue50}; - color: ${COLORS.white}; - - &:active { - background-color: ${COLORS.blue60}; - } - ` - - const DISABLED_STYLE = css` - color: ${COLORS.grey40}; - background-color: ${COLORS.grey10}; - ` - - const SettingButtonLabel = styled.label` - border-radius: ${BORDERS.borderRadius8}; - cursor: ${CURSOR_POINTER}; - padding: 14px ${SPACING.spacing12}; - width: 100%; - - ${isSelected ? SELECTED_BUTTON_STYLE : AVAILABLE_BUTTON_STYLE} - ${disabled && DISABLED_STYLE} - ` - return ( { + onClick={(e: MouseEvent) => { e.stopPropagation() }} > @@ -89,6 +52,8 @@ export function ListButtonRadioButton( /> ) } + +const SettingButton = styled.input` + display: none; +` + +const AVAILABLE_BUTTON_STYLE = css` + background: ${COLORS.white}; + color: ${COLORS.black90}; + + &:hover { + background-color: ${COLORS.grey10}; + } +` + +const SELECTED_BUTTON_STYLE = css` + background: ${COLORS.blue50}; + color: ${COLORS.white}; + + &:active { + background-color: ${COLORS.blue60}; + } +` + +const DISABLED_STYLE = css` + color: ${COLORS.grey40}; + background-color: ${COLORS.grey10}; +` + +interface ButtonLabelProps { + isSelected: boolean + disabled: boolean +} + +const SettingButtonLabel = styled.label` + border-radius: ${BORDERS.borderRadius8}; + cursor: ${CURSOR_POINTER}; + padding: 14px ${SPACING.spacing12}; + width: 100%; + + ${({ isSelected }) => + isSelected ? SELECTED_BUTTON_STYLE : AVAILABLE_BUTTON_STYLE} + ${({ disabled }) => disabled && DISABLED_STYLE} +` diff --git a/components/src/atoms/ListItem/ListItem.stories.tsx b/components/src/atoms/ListItem/ListItem.stories.tsx index 0738b583cde..dbe4739249d 100644 --- a/components/src/atoms/ListItem/ListItem.stories.tsx +++ b/components/src/atoms/ListItem/ListItem.stories.tsx @@ -50,7 +50,7 @@ export const ListItemDescriptorDefault: Story = { type: 'noActive', children: ( mock content} description={
mock description
} /> @@ -63,7 +63,7 @@ export const ListItemDescriptorMini: Story = { type: 'noActive', children: ( mock content} description={
mock description
} /> diff --git a/components/src/atoms/ListItem/ListItemChildren/ListItemDescriptor.tsx b/components/src/atoms/ListItem/ListItemChildren/ListItemDescriptor.tsx index 51d9ca9e181..dcedecaa9f8 100644 --- a/components/src/atoms/ListItem/ListItemChildren/ListItemDescriptor.tsx +++ b/components/src/atoms/ListItem/ListItemChildren/ListItemDescriptor.tsx @@ -2,15 +2,14 @@ import { Flex } from '../../../primitives' import { ALIGN_FLEX_START, DIRECTION_ROW, - FLEX_AUTO, JUSTIFY_SPACE_BETWEEN, } from '../../../styles' import { SPACING } from '../../../ui-style-constants' interface ListItemDescriptorProps { - type: 'default' | 'mini' - description: JSX.Element | string - content: JSX.Element | string + type: 'default' | 'large' + description: JSX.Element + content: JSX.Element } export const ListItemDescriptor = ( @@ -23,22 +22,11 @@ export const ListItemDescriptor = ( gridGap={SPACING.spacing8} width="100%" alignItems={ALIGN_FLEX_START} - justifyContent={type === 'mini' ? JUSTIFY_SPACE_BETWEEN : 'none'} - padding={ - type === 'mini' - ? `${SPACING.spacing4} ${SPACING.spacing8}` - : SPACING.spacing12 - } + justifyContent={type === 'default' ? JUSTIFY_SPACE_BETWEEN : 'none'} + padding={type === 'default' ? SPACING.spacing4 : SPACING.spacing12} > - - {description} - - - {content} - + {description} + {content}
) } diff --git a/components/src/atoms/ListItem/__tests__/ListItem.test.tsx b/components/src/atoms/ListItem/__tests__/ListItem.test.tsx index 2f25b883fae..9cb34e3524f 100644 --- a/components/src/atoms/ListItem/__tests__/ListItem.test.tsx +++ b/components/src/atoms/ListItem/__tests__/ListItem.test.tsx @@ -26,7 +26,6 @@ describe('ListItem', () => { screen.getByText('mock listitem content') const listItem = screen.getByTestId('ListItem_error') expect(listItem).toHaveStyle(`backgroundColor: ${COLORS.red35}`) - expect(listItem).toHaveStyle(`padding: 0`) expect(listItem).toHaveStyle(`borderRadius: ${BORDERS.borderRadius4}`) }) it('should render correct style - noActive', () => { @@ -35,7 +34,6 @@ describe('ListItem', () => { screen.getByText('mock listitem content') const listItem = screen.getByTestId('ListItem_noActive') expect(listItem).toHaveStyle(`backgroundColor: ${COLORS.grey30}`) - expect(listItem).toHaveStyle(`padding: 0`) expect(listItem).toHaveStyle(`borderRadius: ${BORDERS.borderRadius4}`) }) it('should render correct style - success', () => { @@ -44,7 +42,6 @@ describe('ListItem', () => { screen.getByText('mock listitem content') const listItem = screen.getByTestId('ListItem_success') expect(listItem).toHaveStyle(`backgroundColor: ${COLORS.green35}`) - expect(listItem).toHaveStyle(`padding: 0`) expect(listItem).toHaveStyle(`borderRadius: ${BORDERS.borderRadius4}`) }) it('should render correct style - warning', () => { @@ -53,7 +50,6 @@ describe('ListItem', () => { screen.getByText('mock listitem content') const listItem = screen.getByTestId('ListItem_warning') expect(listItem).toHaveStyle(`backgroundColor: ${COLORS.yellow35}`) - expect(listItem).toHaveStyle(`padding: 0`) expect(listItem).toHaveStyle(`borderRadius: ${BORDERS.borderRadius4}`) }) it('should call on click when pressed', () => { diff --git a/components/src/atoms/ListItem/index.tsx b/components/src/atoms/ListItem/index.tsx index 39367f935c1..cb61f0a4d3c 100644 --- a/components/src/atoms/ListItem/index.tsx +++ b/components/src/atoms/ListItem/index.tsx @@ -56,7 +56,6 @@ export function ListItem(props: ListItemProps): JSX.Element { background-color: ${listItemProps.backgroundColor}; width: 100%; height: ${FLEX_MAX_CONTENT}; - padding: 0; border-radius: ${BORDERS.borderRadius4}; @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { diff --git a/components/src/atoms/MenuList/OverflowBtn.tsx b/components/src/atoms/MenuList/OverflowBtn.tsx index ec5958746f4..efe9195f03d 100644 --- a/components/src/atoms/MenuList/OverflowBtn.tsx +++ b/components/src/atoms/MenuList/OverflowBtn.tsx @@ -16,7 +16,7 @@ export const OverflowBtn: ( props: OverflowBtnProps, ref: React.ForwardedRef ): JSX.Element => { - const { fillColor } = props + const { fillColor, ...restProps } = props return ( {message} - {linkText ? ( + {linkText != null ? ( { diff --git a/components/src/atoms/buttons/EmptySelectorButton.tsx b/components/src/atoms/buttons/EmptySelectorButton.tsx index 8a897e3569b..42e8822fc35 100644 --- a/components/src/atoms/buttons/EmptySelectorButton.tsx +++ b/components/src/atoms/buttons/EmptySelectorButton.tsx @@ -1,8 +1,6 @@ import styled from 'styled-components' import { Flex } from '../../primitives' import { - BORDERS, - COLORS, CURSOR_DEFAULT, CURSOR_POINTER, Icon, @@ -12,8 +10,17 @@ import { JUSTIFY_START, ALIGN_CENTER, FLEX_MAX_CONTENT, -} from '../..' -import type { IconName } from '../..' +} from '../../index' +import { + black90, + blue30, + blue50, + grey30, + grey40, + white, +} from '../../helix-design-system/colors' +import { borderRadius8 } from '../../helix-design-system/borders' +import type { IconName } from '../../index' interface EmptySelectorButtonProps { onClick: () => void @@ -29,27 +36,15 @@ export function EmptySelectorButton( ): JSX.Element { const { onClick, text, iconName, textAlignment, disabled = false } = props - const StyledButton = styled.button` - border: none; - width: ${FLEX_MAX_CONTENT}; - height: ${FLEX_MAX_CONTENT}; - cursor: ${disabled ? CURSOR_DEFAULT : CURSOR_POINTER}; - &:focus-visible { - outline: 2px solid ${COLORS.white}; - box-shadow: 0 0 0 4px ${COLORS.blue50}; - border-radius: ${BORDERS.borderRadius8}; - } - ` - return ( - + ) } + +interface ButtonProps { + disabled: boolean +} + +const StyledButton = styled.button` + border: none; + width: ${FLEX_MAX_CONTENT}; + height: ${FLEX_MAX_CONTENT}; + cursor: ${({ disabled }) => (disabled ? CURSOR_DEFAULT : CURSOR_POINTER)}; + &:focus-visible { + outline: 2px solid ${white}; + box-shadow: 0 0 0 4px ${blue50}; + border-radius: ${borderRadius8}; + } +` diff --git a/components/src/atoms/buttons/RadioButton.tsx b/components/src/atoms/buttons/RadioButton.tsx index e271f509a6c..f960987f67f 100644 --- a/components/src/atoms/buttons/RadioButton.tsx +++ b/components/src/atoms/buttons/RadioButton.tsx @@ -1,21 +1,20 @@ import type * as React from 'react' import styled, { css } from 'styled-components' import { Flex } from '../../primitives' +import { COLORS, BORDERS } from '../../helix-design-system' +import { RESPONSIVENESS, SPACING } from '../../ui-style-constants' import { - ALIGN_CENTER, - BORDERS, - COLORS, CURSOR_DEFAULT, - CURSOR_NOT_ALLOWED, CURSOR_POINTER, + CURSOR_NOT_ALLOWED, DIRECTION_ROW, + ALIGN_CENTER, Icon, - RESPONSIVENESS, - SPACING, StyledText, -} from '../..' -import type { IconName } from '../..' +} from '../../index' +import type { IconName } from '../../icons' import type { StyleProps } from '../../primitives' +import type { FlattenSimpleInterpolation } from 'styled-components' interface RadioButtonProps extends StyleProps { buttonLabel: string | React.ReactNode @@ -28,7 +27,7 @@ interface RadioButtonProps extends StyleProps { radioButtonType?: 'large' | 'small' subButtonLabel?: string id?: string - maxLines?: number | null + maxLines?: number // used for mouseEnter and mouseLeave setNoHover?: () => void setHovered?: () => void @@ -51,17 +50,12 @@ export function RadioButton(props: RadioButtonProps): JSX.Element { : `RadioButtonId_${buttonValue}`, largeDesktopBorderRadius = false, iconName, - maxLines = null, + maxLines = 1, setHovered, setNoHover, } = props - const isLarge = radioButtonType === 'large' - const SettingButton = styled.input` - display: none; - ` - const AVAILABLE_BUTTON_STYLE = css` background: ${COLORS.blue35}; @@ -81,46 +75,6 @@ export function RadioButton(props: RadioButtonProps): JSX.Element { } ` - const DISABLED_BUTTON_STYLE = css` - background-color: ${COLORS.grey35}; - color: ${COLORS.grey50}; - - &:hover, - &:active { - background-color: ${COLORS.grey35}; - } - - @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { - cursor: ${CURSOR_NOT_ALLOWED}; - } - ` - - const SettingButtonLabel = styled.label` - border-radius: ${!largeDesktopBorderRadius - ? BORDERS.borderRadius40 - : BORDERS.borderRadius8}; - cursor: ${CURSOR_POINTER}; - padding: ${SPACING.spacing12} ${SPACING.spacing16}; - width: 100%; - - ${isSelected ? SELECTED_BUTTON_STYLE : AVAILABLE_BUTTON_STYLE} - ${disabled && DISABLED_BUTTON_STYLE} - - &:focus-visible { - outline: 2px solid ${COLORS.blue55}; - } - - @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { - cursor: ${CURSOR_DEFAULT}; - padding: ${isLarge ? SPACING.spacing24 : SPACING.spacing20}; - border-radius: ${BORDERS.borderRadius16}; - display: ${maxLines != null ? '-webkit-box' : undefined}; - -webkit-line-clamp: ${maxLines ?? undefined}; - -webkit-box-orient: ${maxLines != null ? 'vertical' : undefined}; - word-wrap: break-word; - } - ` - const SUBBUTTON_LABEL_STYLE = css` color: ${disabled ? COLORS.grey50 @@ -129,6 +83,15 @@ export function RadioButton(props: RadioButtonProps): JSX.Element { : COLORS.grey60}; ` + const getButtonStyle = ( + isSelected: boolean, + disabled: boolean + ): FlattenSimpleInterpolation => { + if (disabled) return DISABLED_BUTTON_STYLE + if (isSelected) return SELECTED_BUTTON_STYLE + return AVAILABLE_BUTTON_STYLE + } + return ( ) } + +const DISABLED_BUTTON_STYLE = css` + background-color: ${COLORS.grey35}; + color: ${COLORS.grey50}; + + &:hover, + &:active { + background-color: ${COLORS.grey35}; + } + + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + cursor: ${CURSOR_NOT_ALLOWED}; + } +` + +const SettingButton = styled.input` + display: none; +` + +interface SettingsButtonLabelProps { + isSelected: boolean + disabled: boolean + largeDesktopBorderRadius: boolean + isLarge: boolean + maxLines?: number | null +} + +const SettingButtonLabel = styled.label` + border-radius: ${({ largeDesktopBorderRadius }) => + !largeDesktopBorderRadius ? BORDERS.borderRadius40 : BORDERS.borderRadius8}; + cursor: ${CURSOR_POINTER}; + padding: ${SPACING.spacing12} ${SPACING.spacing16}; + width: 100%; + + ${({ disabled }) => disabled && DISABLED_BUTTON_STYLE} + &:focus-visible { + outline: 2px solid ${COLORS.blue55}; + } + + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + cursor: ${CURSOR_DEFAULT}; + padding: ${({ largeDesktopBorderRadius }) => + largeDesktopBorderRadius ? SPACING.spacing24 : SPACING.spacing20}; + border-radius: ${BORDERS.borderRadius16}; + display: ${({ maxLines }) => (maxLines != null ? '-webkit-box' : 'none')}; + -webkit-line-clamp: ${({ maxLines }) => maxLines ?? 'none'}; + -webkit-box-orient: ${({ maxLines }) => + maxLines != null ? 'vertical' : 'none'}; + word-wrap: break-word; + } +` diff --git a/components/src/hardware-sim/Deck/DeckFromLayers.tsx b/components/src/hardware-sim/Deck/DeckFromLayers.tsx index aaf8f979151..badd7e80ca1 100644 --- a/components/src/hardware-sim/Deck/DeckFromLayers.tsx +++ b/components/src/hardware-sim/Deck/DeckFromLayers.tsx @@ -11,9 +11,9 @@ import { RemovalHandle, ScrewHoles, } from './OT2Layers' +import { ALL_OT2_DECK_LAYERS } from './constants' import type { RobotType } from '@opentrons/shared-data' -import { ALL_OT2_DECK_LAYERS } from './constants' export interface DeckFromLayersProps { robotType: RobotType @@ -21,18 +21,18 @@ export interface DeckFromLayersProps { } const OT2_LAYER_MAP: { - [layer in typeof ALL_OT2_DECK_LAYERS[number]]: JSX.Element + [layer in typeof ALL_OT2_DECK_LAYERS[number]]: () => JSX.Element } = { - fixedBase: , - fixedTrash: , - doorStops: , - metalFrame: , - removableDeckOutline: , - slotRidges: , - slotNumbers: , - calibrationMarkings: , - removalHandle: , - screwHoles: , + fixedBase: () => , + fixedTrash: () => , + doorStops: () => , + metalFrame: () => , + removableDeckOutline: () => , + slotRidges: () => , + slotNumbers: () => , + calibrationMarkings: () => , + removalHandle: () => , + screwHoles: () => , } /** @@ -47,10 +47,12 @@ export function DeckFromLayers(props: DeckFromLayersProps): JSX.Element | null { return ( - {ALL_OT2_DECK_LAYERS.reduce((acc, layer) => { - if (layerBlocklist.includes(layer)) return acc - return [...acc, OT2_LAYER_MAP[layer]] - }, [])} + {ALL_OT2_DECK_LAYERS.filter(layer => !layerBlocklist.includes(layer)).map( + layer => { + const LayerComponent = OT2_LAYER_MAP[layer] + return + } + )} ) } diff --git a/components/src/hardware-sim/Labware/LabwareAdapter/OpentronsAutoclavableDeckRiser.tsx b/components/src/hardware-sim/Labware/LabwareAdapter/OpentronsAutoclavableDeckRiser.tsx new file mode 100644 index 00000000000..d6685e0793c --- /dev/null +++ b/components/src/hardware-sim/Labware/LabwareAdapter/OpentronsAutoclavableDeckRiser.tsx @@ -0,0 +1,100 @@ +// x .32, y .31 +export function OpentronsAutoclavableDeckRiser(): JSX.Element { + return ( + + + + + + + + + + + + + + + + ) +} diff --git a/components/src/hardware-sim/Labware/LabwareAdapter/OpentronsToughPCRAutoSealingLid.tsx b/components/src/hardware-sim/Labware/LabwareAdapter/OpentronsToughPCRAutoSealingLid.tsx new file mode 100644 index 00000000000..b3f50f94dd0 --- /dev/null +++ b/components/src/hardware-sim/Labware/LabwareAdapter/OpentronsToughPCRAutoSealingLid.tsx @@ -0,0 +1,83 @@ +export function OpentronsToughPCRAutoSealingLid(): JSX.Element { + return ( + + + + + + + + + + + + + + + + ) +} diff --git a/components/src/hardware-sim/Labware/LabwareAdapter/index.tsx b/components/src/hardware-sim/Labware/LabwareAdapter/index.tsx index f3941d980af..417b83ce89c 100644 --- a/components/src/hardware-sim/Labware/LabwareAdapter/index.tsx +++ b/components/src/hardware-sim/Labware/LabwareAdapter/index.tsx @@ -3,6 +3,8 @@ import { Opentrons96FlatBottomAdapter } from './Opentrons96FlatBottomAdapter' import { OpentronsUniversalFlatAdapter } from './OpentronsUniversalFlatAdapter' import { OpentronsAluminumFlatBottomPlate } from './OpentronsAluminumFlatBottomPlate' import { OpentronsFlex96TiprackAdapter } from './OpentronsFlex96TiprackAdapter' +import { OpentronsToughPCRAutoSealingLid } from './OpentronsToughPCRAutoSealingLid' +import { OpentronsAutoclavableDeckRiser } from './OpentronsAutoclavableDeckRiser' import { COLORS } from '../../../helix-design-system' import { LabwareOutline } from '../labwareInternals' import type { LabwareDefinition2 } from '@opentrons/shared-data' @@ -13,6 +15,8 @@ const LABWARE_ADAPTER_LOADNAME_PATHS = { opentrons_aluminum_flat_bottom_plate: OpentronsAluminumFlatBottomPlate, opentrons_flex_96_tiprack_adapter: OpentronsFlex96TiprackAdapter, opentrons_universal_flat_adapter: OpentronsUniversalFlatAdapter, + opentrons_tough_pcr_auto_sealing_lid: OpentronsToughPCRAutoSealingLid, + opentrons_flex_deck_riser: OpentronsAutoclavableDeckRiser, } export type LabwareAdapterLoadName = keyof typeof LABWARE_ADAPTER_LOADNAME_PATHS diff --git a/components/src/hardware-sim/ProtocolDeck/index.tsx b/components/src/hardware-sim/ProtocolDeck/index.tsx index fb1ac06349b..20c3a0c990b 100644 --- a/components/src/hardware-sim/ProtocolDeck/index.tsx +++ b/components/src/hardware-sim/ProtocolDeck/index.tsx @@ -4,7 +4,7 @@ import { FLEX_ROBOT_TYPE, getLabwareDisplayName, getSimplestDeckConfigForProtocol, - parseInitialLoadedLabwareByAdapter, + getTopLabwareInfo, } from '@opentrons/shared-data' import { BaseDeck } from '../BaseDeck' @@ -19,6 +19,8 @@ import type { CompletedProtocolAnalysis, LabwareDefinition2, ProtocolAnalysisOutput, + RunTimeCommand, + LoadLabwareRunTimeCommand, } from '@opentrons/shared-data' export * from './utils/getStandardDeckViewLayerBlockList' @@ -46,13 +48,15 @@ export function ProtocolDeck(props: ProtocolDeckProps): JSX.Element | null { if (protocolAnalysis == null || (protocolAnalysis?.errors ?? []).length > 0) return null + const commands: RunTimeCommand[] = protocolAnalysis.commands + const loadLabwareCommands = commands?.filter( + (command): command is LoadLabwareRunTimeCommand => + command.commandType === 'loadLabware' + ) const robotType = protocolAnalysis.robotType ?? FLEX_ROBOT_TYPE const deckConfig = getSimplestDeckConfigForProtocol(protocolAnalysis) const labwareByLiquidId = getLabwareInfoByLiquidId(protocolAnalysis.commands) - const initialLoadedLabwareByAdapter = parseInitialLoadedLabwareByAdapter( - protocolAnalysis.commands - ) const modulesInSlots = getModulesInSlots(protocolAnalysis) const modulesOnDeck = modulesInSlots.map( @@ -63,16 +67,10 @@ export function ProtocolDeck(props: ProtocolDeckProps): JSX.Element | null { nestedLabwareDef, nestedLabwareNickName, }) => { - const labwareInAdapterInMod = - nestedLabwareId != null - ? initialLoadedLabwareByAdapter[nestedLabwareId] - : null - // only rendering the labware on top most layer so - // either the adapter or the labware are rendered but not both - const topLabwareDefinition = - labwareInAdapterInMod?.result?.definition ?? nestedLabwareDef - const topLabwareId = - labwareInAdapterInMod?.result?.labwareId ?? nestedLabwareId + const { topLabwareId, topLabwareDefinition } = getTopLabwareInfo( + nestedLabwareId ?? '', + loadLabwareCommands + ) return { moduleModel, @@ -112,15 +110,16 @@ export function ProtocolDeck(props: ProtocolDeckProps): JSX.Element | null { } ) + // this function gets the top labware assuming a stack of max 2 labware const topMostLabwareInSlots = getTopMostLabwareInSlots(protocolAnalysis) const labwareOnDeck = topMostLabwareInSlots.map( ({ labwareId, labwareDef, labwareNickName, location }) => { - const labwareInAdapter = initialLoadedLabwareByAdapter[labwareId] - // only rendering the labware on top most layer so - // either the adapter or the labware are rendered but not both - const topLabwareDefinition = - labwareInAdapter?.result?.definition ?? labwareDef - const topLabwareId = labwareInAdapter?.result?.labwareId ?? labwareId + // this gets the very top of the stack in case there is a stack + // of many like items, such as TC lids + const { topLabwareId, topLabwareDefinition } = getTopLabwareInfo( + labwareId, + loadLabwareCommands + ) const isLabwareInStack = protocolAnalysis?.commands.some( command => command.commandType === 'loadLabware' && @@ -146,7 +145,7 @@ export function ProtocolDeck(props: ProtocolDeckProps): JSX.Element | null { highlight: handleLabwareClick != null, highlightShadow: handleLabwareClick != null && isLabwareInStack, onLabwareClick: - handleLabwareClick != null + handleLabwareClick != null && topLabwareDefinition != null ? () => { handleLabwareClick(topLabwareDefinition, topLabwareId) } diff --git a/components/src/hardware-sim/RobotCoordinateSpace/RobotCoordinateSpaceWithRef.tsx b/components/src/hardware-sim/RobotCoordinateSpace/RobotCoordinateSpaceWithRef.tsx index c1986711ed2..a777299fb1c 100644 --- a/components/src/hardware-sim/RobotCoordinateSpace/RobotCoordinateSpaceWithRef.tsx +++ b/components/src/hardware-sim/RobotCoordinateSpace/RobotCoordinateSpaceWithRef.tsx @@ -1,5 +1,7 @@ -import * as React from 'react' +import { useRef } from 'react' import { Svg } from '../../primitives' + +import type { ReactNode } from 'react' import type { DeckDefinition, DeckSlot } from '@opentrons/shared-data' export interface RobotCoordinateSpaceWithRefRenderProps { @@ -10,14 +12,15 @@ interface RobotCoordinateSpaceWithRefProps extends React.ComponentProps { viewBox?: string | null deckDef?: DeckDefinition - children?: (props: RobotCoordinateSpaceWithRefRenderProps) => React.ReactNode + zoomed?: boolean + children?: (props: RobotCoordinateSpaceWithRefRenderProps) => ReactNode } export function RobotCoordinateSpaceWithRef( props: RobotCoordinateSpaceWithRefProps ): JSX.Element | null { - const { children, deckDef, viewBox, ...restProps } = props - const wrapperRef = React.useRef(null) + const { children, deckDef, viewBox, zoomed = false, ...restProps } = props + const wrapperRef = useRef(null) if (deckDef == null && viewBox == null) return null @@ -31,13 +34,26 @@ export function RobotCoordinateSpaceWithRef( (acc, deckSlot) => ({ ...acc, [deckSlot.id]: deckSlot }), {} ) - wholeDeckViewBox = `${viewBoxOriginX} ${viewBoxOriginY} ${deckXDimension} ${deckYDimension}` + + const PADDING = deckDef.otId === 'ot2_standard' ? 5 : 10 + if (deckDef.otId === 'ot2_standard') { + wholeDeckViewBox = `${viewBoxOriginX - PADDING} ${ + viewBoxOriginY + PADDING * 5 + } ${deckXDimension + PADDING * 2} ${deckYDimension - PADDING * 10}` + } else { + wholeDeckViewBox = `${viewBoxOriginX + PADDING * 2} ${ + viewBoxOriginY - PADDING + } ${deckXDimension + PADDING * 4} ${deckYDimension + PADDING * 3}` + } } + return ( {children?.({ deckSlotsById })} diff --git a/components/src/icons/icon-data.ts b/components/src/icons/icon-data.ts index edf90a1512c..9f8338c7e87 100644 --- a/components/src/icons/icon-data.ts +++ b/components/src/icons/icon-data.ts @@ -1,5 +1,8 @@ // icon data -export const ICON_DATA_BY_NAME = { +export const ICON_DATA_BY_NAME: Record< + string, + { path: string; viewBox: string } +> = { add: { path: 'M24 48C37.2548 48 48 37.2548 48 24C48 10.7452 37.2548 0 24 0C10.7452 0 0 10.7452 0 24C0 37.2548 10.7452 48 24 48ZM21 21V12H27V21H36V27H27V36H21V27H12V21H21Z', @@ -272,6 +275,11 @@ export const ICON_DATA_BY_NAME = { 'M8.63355 14.215C8.6365 14.5124 8.63911 14.7764 8.63911 15H7.36528C7.36528 14.8447 7.36714 14.6621 7.36924 14.4568C7.38181 13.225 7.40273 11.1766 7.07922 9.31768C6.89019 8.23151 6.59339 7.27753 6.15429 6.60988C5.73178 5.96745 5.2075 5.625 4.50024 5.625C3.79297 5.625 3.2687 5.96745 2.84618 6.60988C2.40708 7.27753 2.11028 8.23151 1.92125 9.31768C1.59774 11.1766 1.61866 13.225 1.63124 14.4568C1.63333 14.6621 1.6352 14.8447 1.6352 15H0.385197C0.385197 14.8588 0.383339 14.6876 0.38121 14.4914C0.367987 13.273 0.344279 11.0886 0.689764 9.10337C0.890012 7.95271 1.2241 6.80142 1.80181 5.92302C2.39611 5.01939 3.27456 4.375 4.50024 4.375C5.72592 4.375 6.60437 5.01939 7.19867 5.92301C7.40983 6.24409 7.58845 6.60162 7.73987 6.98226C7.75025 6.91343 7.76092 6.84476 7.77188 6.77626C8.02647 5.18496 8.4487 3.62176 9.16765 2.44065C9.89976 1.23791 10.9823 0.375 12.5141 0.375C14.0321 0.375 15.1148 1.19161 15.852 2.35243C16.5736 3.48863 16.9954 4.99227 17.2488 6.52295C17.6344 8.85309 17.6513 11.4038 17.6372 13.1367L19.1208 12.0031L19.8797 12.9963L17.0169 15.1838L14.1235 12.9984L14.8769 12.001L16.3866 13.1413C16.4006 11.422 16.3863 8.96723 16.0155 6.72705C15.7724 5.25773 15.3851 3.94887 14.7968 3.02257C14.2242 2.12089 13.4961 1.625 12.5141 1.625C11.5458 1.625 10.8158 2.13709 10.2354 3.0906C9.64181 4.06574 9.25156 5.44004 9.00618 6.97374C8.58775 9.58904 8.61631 12.4739 8.63355 14.215Z', viewBox: '0 0 20 16', }, + language: { + path: + 'M10 18.3333C8.83335 18.3333 7.74308 18.1146 6.72919 17.6771C5.7153 17.2396 4.83335 16.6458 4.08335 15.8958C3.33335 15.1458 2.74308 14.2604 2.31252 13.2396C1.88196 12.2187 1.66669 11.125 1.66669 9.95832C1.66669 8.79166 1.88196 7.70485 2.31252 6.69791C2.74308 5.69096 3.33335 4.81249 4.08335 4.06249C4.83335 3.31249 5.7153 2.72568 6.72919 2.30207C7.74308 1.87846 8.83335 1.66666 10 1.66666C11.1667 1.66666 12.257 1.87846 13.2709 2.30207C14.2847 2.72568 15.1667 3.31249 15.9167 4.06249C16.6667 4.81249 17.257 5.69096 17.6875 6.69791C18.1181 7.70485 18.3334 8.79166 18.3334 9.95832C18.3334 11.125 18.1181 12.2187 17.6875 13.2396C17.257 14.2604 16.6667 15.1458 15.9167 15.8958C15.1667 16.6458 14.2847 17.2396 13.2709 17.6771C12.257 18.1146 11.1667 18.3333 10 18.3333ZM10 17.125C10.4861 16.625 10.8924 16.0521 11.2188 15.4062C11.5452 14.7604 11.8125 13.993 12.0209 13.1042H8.00002C8.19446 13.9375 8.45488 14.6875 8.78127 15.3542C9.10766 16.0208 9.51391 16.6111 10 17.125ZM8.22919 16.875C7.88196 16.3472 7.58335 15.7778 7.33335 15.1667C7.08335 14.5555 6.87502 13.868 6.70835 13.1042H3.58335C4.11113 14.0903 4.72224 14.8646 5.41669 15.4271C6.11113 15.9896 7.04863 16.4722 8.22919 16.875ZM11.7917 16.8542C12.7917 16.5347 13.691 16.0555 14.4896 15.4167C15.2882 14.7778 15.9306 14.0069 16.4167 13.1042H13.3125C13.132 13.8542 12.9202 14.5347 12.6771 15.1458C12.434 15.7569 12.1389 16.3264 11.7917 16.8542ZM3.16669 11.8542H6.47919C6.43752 11.4792 6.41321 11.1424 6.40627 10.8437C6.39933 10.5451 6.39585 10.25 6.39585 9.95832C6.39585 9.6111 6.4028 9.30207 6.41669 9.03124C6.43058 8.76041 6.45835 8.45832 6.50002 8.12499H3.16669C3.06946 8.45832 3.00349 8.75693 2.96877 9.02082C2.93405 9.28471 2.91669 9.59721 2.91669 9.95832C2.91669 10.3194 2.93405 10.6424 2.96877 10.9271C3.00349 11.2118 3.06946 11.5208 3.16669 11.8542ZM7.77085 11.8542H12.25C12.3056 11.4236 12.3403 11.0729 12.3542 10.8021C12.3681 10.5312 12.375 10.25 12.375 9.95832C12.375 9.68054 12.3681 9.41318 12.3542 9.15624C12.3403 8.89929 12.3056 8.55554 12.25 8.12499H7.77085C7.7153 8.55554 7.68058 8.89929 7.66669 9.15624C7.6528 9.41318 7.64585 9.68054 7.64585 9.95832C7.64585 10.25 7.6528 10.5312 7.66669 10.8021C7.68058 11.0729 7.7153 11.4236 7.77085 11.8542ZM13.5 11.8542H16.8334C16.9306 11.5208 16.9965 11.2118 17.0313 10.9271C17.066 10.6424 17.0834 10.3194 17.0834 9.95832C17.0834 9.59721 17.066 9.28471 17.0313 9.02082C16.9965 8.75693 16.9306 8.45832 16.8334 8.12499H13.5209C13.5625 8.6111 13.5903 8.98263 13.6042 9.23957C13.6181 9.49652 13.625 9.7361 13.625 9.95832C13.625 10.2639 13.6146 10.5521 13.5938 10.8229C13.5729 11.0937 13.5417 11.4375 13.5 11.8542ZM13.2917 6.87499H16.4167C15.9584 5.91666 15.3299 5.11805 14.5313 4.47916C13.7327 3.84027 12.8125 3.38888 11.7709 3.12499C12.1181 3.63888 12.4132 4.19443 12.6563 4.79166C12.8993 5.38888 13.1111 6.08332 13.2917 6.87499ZM8.00002 6.87499H12.0417C11.8889 6.13888 11.632 5.42707 11.2709 4.73957C10.9097 4.05207 10.4861 3.44443 10 2.91666C9.55558 3.29166 9.18058 3.78471 8.87502 4.39582C8.56946 5.00693 8.2778 5.83332 8.00002 6.87499ZM3.58335 6.87499H6.72919C6.88196 6.12499 7.07641 5.45485 7.31252 4.86457C7.54863 4.2743 7.84724 3.70138 8.20835 3.14582C7.16669 3.40971 6.25696 3.85416 5.47919 4.47916C4.70141 5.10416 4.06946 5.90277 3.58335 6.87499Z', + viewBox: '0 0 20 20', + }, 'latch-closed': { path: 'M33.6663 10H6.33301V17H10.333V14H14.167V19.166H26.667V14H29.6663V17H33.6663V10Z', diff --git a/components/src/images/labware/measurement-guide/index.ts b/components/src/images/labware/measurement-guide/index.ts index b866627db1d..6b6c5e14fee 100644 --- a/components/src/images/labware/measurement-guide/index.ts +++ b/components/src/images/labware/measurement-guide/index.ts @@ -107,6 +107,10 @@ const FOOTPRINT_DIAGRAMS: Diagrams = { new URL(FOOTPRINT_IMAGE_RELATIVE_PATH, import.meta.url).href, new URL(DIMENSIONS_HEIGHT_PLATE_IMAGE_RELATIVE_PATH, import.meta.url).href, ], + lid: [ + new URL(FOOTPRINT_IMAGE_RELATIVE_PATH, import.meta.url).href, + new URL(DIMENSIONS_HEIGHT_PLATE_IMAGE_RELATIVE_PATH, import.meta.url).href, + ], } const ALUM_BLOCK_FOOTPRINTS: Diagrams = { diff --git a/components/src/modals/Modal.tsx b/components/src/modals/Modal.tsx index fc823243b5d..7be1ca06340 100644 --- a/components/src/modals/Modal.tsx +++ b/components/src/modals/Modal.tsx @@ -6,6 +6,7 @@ import { ModalHeader } from './ModalHeader' import { ModalShell } from './ModalShell' import type { IconProps } from '../icons' import type { StyleProps } from '../primitives' +import type { Position } from './ModalShell' type ModalType = 'info' | 'warning' | 'error' @@ -21,6 +22,8 @@ export interface ModalProps extends StyleProps { children?: React.ReactNode footer?: React.ReactNode zIndexOverlay?: number + showOverlay?: boolean + position?: Position } /** @@ -38,6 +41,8 @@ export const Modal = (props: ModalProps): JSX.Element => { titleElement1, titleElement2, zIndexOverlay, + position, + showOverlay, ...styleProps } = props @@ -72,9 +77,10 @@ export const Modal = (props: ModalProps): JSX.Element => { backgroundColor={COLORS.white} /> ) - return ( { @@ -61,7 +74,7 @@ export function ModalShell(props: ModalShellProps): JSX.Element { if (onOutsideClick != null) onOutsideClick(e) }} > - + ) } -const Overlay = styled.div<{ zIndex: string | number }>` +const Overlay = styled.div<{ zIndex: string | number; showOverlay: boolean }>` position: ${POSITION_ABSOLUTE}; left: 0; right: 0; top: 0; bottom: 0; z-index: ${({ zIndex }) => zIndex}; - background-color: ${COLORS.black90}${COLORS.opacity40HexCode}; + background-color: ${({ showOverlay }) => + showOverlay + ? `${COLORS.black90}${COLORS.opacity40HexCode}` + : COLORS.transparent}; cursor: ${CURSOR_DEFAULT}; ` -const ContentArea = styled.div<{ zIndex: string | number }>` +const ContentArea = styled.div<{ + zIndex: string | number + position: Position + noPadding: boolean +}>` display: flex; position: ${POSITION_ABSOLUTE}; - align-items: ${ALIGN_CENTER}; - justify-content: ${JUSTIFY_CENTER}; + align-items: ${({ position }) => + position === 'center' ? ALIGN_CENTER : ALIGN_END}; + justify-content: ${({ position }) => + position === 'center' ? JUSTIFY_CENTER : JUSTIFY_END}; top: 0; right: 0; bottom: 0; @@ -101,7 +123,7 @@ const ContentArea = styled.div<{ zIndex: string | number }>` width: 100%; height: 100%; z-index: ${({ zIndex }) => zIndex}; - padding: ${SPACING.spacing16}; + padding: ${({ noPadding }) => (noPadding ? 0 : SPACING.spacing16)}; ` const ModalArea = styled.div< diff --git a/components/src/molecules/DropdownMenu/index.tsx b/components/src/molecules/DropdownMenu/index.tsx index 30a02209121..c6aa7abc1c2 100644 --- a/components/src/molecules/DropdownMenu/index.tsx +++ b/components/src/molecules/DropdownMenu/index.tsx @@ -63,6 +63,12 @@ export interface DropdownMenuProps { tabIndex?: number /** optional error */ error?: string | null + /** focus handler */ + onFocus?: React.FocusEventHandler + /** blur handler */ + onBlur?: React.FocusEventHandler + /** optional disabled */ + disabled?: boolean } // TODO: (smb: 4/15/22) refactor this to use html select for accessibility @@ -79,6 +85,9 @@ export function DropdownMenu(props: DropdownMenuProps): JSX.Element { tooltipText, tabIndex = 0, error, + disabled = false, + onFocus, + onBlur, } = props const [targetProps, tooltipProps] = useHoverTooltip() const [showDropdownMenu, setShowDropdownMenu] = React.useState(false) @@ -159,10 +168,12 @@ export function DropdownMenu(props: DropdownMenuProps): JSX.Element { const DROPDOWN_STYLE = css` flex-direction: ${DIRECTION_ROW}; + color: ${disabled ? COLORS.grey40 : COLORS.black90}; background-color: ${COLORS.white}; cursor: ${isDisabled ? CURSOR_DEFAULT : CURSOR_POINTER}; padding: ${SPACING.spacing8} ${SPACING.spacing12}; - border: 1px ${BORDERS.styleSolid} ${defaultBorderColor}; + border: 1px ${BORDERS.styleSolid} + ${disabled ? COLORS.grey35 : defaultBorderColor}; border-radius: ${dropdownType === 'rounded' ? BORDERS.borderRadiusFull : BORDERS.borderRadius4}; @@ -172,7 +183,8 @@ export function DropdownMenu(props: DropdownMenuProps): JSX.Element { height: 2.25rem; &:hover { - border: 1px ${BORDERS.styleSolid} ${hoverBorderColor}; + border: 1px ${BORDERS.styleSolid} + ${disabled ? COLORS.grey35 : hoverBorderColor}; } &:active { @@ -184,11 +196,6 @@ export function DropdownMenu(props: DropdownMenuProps): JSX.Element { outline: 2px ${BORDERS.styleSolid} ${COLORS.blue50}; outline-offset: 2px; } - - &:disabled { - background-color: ${COLORS.transparent}; - color: ${COLORS.grey40}; - } ` return ( {title !== null ? ( - - + + {title} {tooltipText != null ? ( @@ -222,6 +236,8 @@ export function DropdownMenu(props: DropdownMenuProps): JSX.Element { e.preventDefault() toggleSetShowDropdownMenu() }} + onFocus={onFocus} + onBlur={onBlur} css={DROPDOWN_STYLE} tabIndex={tabIndex} > @@ -264,7 +280,7 @@ export function DropdownMenu(props: DropdownMenuProps): JSX.Element { {filterOptions.map((option, index) => ( { diff --git a/components/src/organisms/EndUserAgreementFooter/__tests__/EndUserAgreementFooter.test.tsx b/components/src/organisms/EndUserAgreementFooter/__tests__/EndUserAgreementFooter.test.tsx new file mode 100644 index 00000000000..a13aacfe27f --- /dev/null +++ b/components/src/organisms/EndUserAgreementFooter/__tests__/EndUserAgreementFooter.test.tsx @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest' +import { screen } from '@testing-library/react' +import { renderWithProviders } from '../../../testing/utils' +import { EndUserAgreementFooter } from '../index' + +const render = () => { + return renderWithProviders() +} + +describe('EndUserAgreementFooter', () => { + it('should render text and links', () => { + render() + screen.getByText('Copyright © 2024 Opentrons') + expect( + screen.getByRole('link', { name: 'privacy policy' }) + ).toHaveAttribute('href', 'https://opentrons.com/privacy-policy') + expect( + screen.getByRole('link', { name: 'end user license agreement' }) + ).toHaveAttribute('href', 'https://opentrons.com/eula') + }) +}) diff --git a/components/src/organisms/EndUserAgreementFooter/index.tsx b/components/src/organisms/EndUserAgreementFooter/index.tsx new file mode 100644 index 00000000000..5e40b205665 --- /dev/null +++ b/components/src/organisms/EndUserAgreementFooter/index.tsx @@ -0,0 +1,49 @@ +import { StyledText } from '../../atoms' +import { COLORS } from '../../helix-design-system' +import { Flex, Link } from '../../primitives' +import { + ALIGN_CENTER, + DIRECTION_COLUMN, + TEXT_DECORATION_UNDERLINE, +} from '../../styles' +import { SPACING } from '../../ui-style-constants' + +const PRIVACY_POLICY_URL = 'https://opentrons.com/privacy-policy' +const EULA_URL = 'https://opentrons.com/eula' + +export function EndUserAgreementFooter(): JSX.Element { + return ( + + + By continuing, you agree to the Opentrons{' '} + + privacy policy + {' '} + and{' '} + + end user license agreement + + + + Copyright © 2024 Opentrons + + + ) +} diff --git a/components/src/organisms/Toolbox/index.tsx b/components/src/organisms/Toolbox/index.tsx index 4346806b861..35e4083643b 100644 --- a/components/src/organisms/Toolbox/index.tsx +++ b/components/src/organisms/Toolbox/index.tsx @@ -76,18 +76,18 @@ export function Toolbox(props: ToolboxProps): JSX.Element { ...(side === 'left' && { left: '0' }), ...(horizontalSide === 'bottom' && { bottom: '0' }), ...(horizontalSide === 'top' && { top: '5rem' }), + zIndex: 10, } : {} return ( {closeButton} diff --git a/components/src/organisms/index.ts b/components/src/organisms/index.ts index 2aee78e806c..8775f49abc3 100644 --- a/components/src/organisms/index.ts +++ b/components/src/organisms/index.ts @@ -1,2 +1,3 @@ export * from './DeckLabelSet' +export * from './EndUserAgreementFooter' export * from './Toolbox' diff --git a/hardware-testing/hardware_testing/examples/capacitive_probe_ot3.py b/hardware-testing/hardware_testing/examples/capacitive_probe_ot3.py index c3bdfd588e7..e0306a25779 100644 --- a/hardware-testing/hardware_testing/examples/capacitive_probe_ot3.py +++ b/hardware-testing/hardware_testing/examples/capacitive_probe_ot3.py @@ -2,7 +2,7 @@ import argparse import asyncio -from opentrons.config.types import CapacitivePassSettings, OutputOptions +from opentrons.config.types import CapacitivePassSettings from opentrons.hardware_control.ot3api import OT3API from hardware_testing.opentrons_api import types @@ -44,14 +44,12 @@ max_overrun_distance_mm=3, speed_mm_per_s=1, sensor_threshold_pf=STABLE_CAP_PF, - output_option=OutputOptions.sync_only, ) PROBE_SETTINGS_XY_AXIS = CapacitivePassSettings( prep_distance_mm=CUTOUT_SIZE / 2, max_overrun_distance_mm=3, speed_mm_per_s=1, sensor_threshold_pf=STABLE_CAP_PF, - output_option=OutputOptions.sync_only, ) diff --git a/hardware-testing/hardware_testing/examples/capacitive_probe_ot3_tunable.py b/hardware-testing/hardware_testing/examples/capacitive_probe_ot3_tunable.py index 5b14e88bc12..0fe1f693246 100644 --- a/hardware-testing/hardware_testing/examples/capacitive_probe_ot3_tunable.py +++ b/hardware-testing/hardware_testing/examples/capacitive_probe_ot3_tunable.py @@ -2,9 +2,8 @@ import argparse import asyncio -from opentrons.config.types import CapacitivePassSettings, OutputOptions +from opentrons.config.types import CapacitivePassSettings from opentrons.hardware_control.ot3api import OT3API -from opentrons.hardware_control.types import InstrumentProbeType from hardware_testing.opentrons_api import types from hardware_testing.opentrons_api import helpers_ot3 @@ -46,15 +45,12 @@ max_overrun_distance_mm=3, speed_mm_per_s=1, sensor_threshold_pf=CAP_REL_THRESHOLD_PF, - output_option=OutputOptions.sync_only, ) PROBE_SETTINGS_Z_AXIS_OUTPUT = CapacitivePassSettings( prep_distance_mm=10, max_overrun_distance_mm=3, speed_mm_per_s=1, sensor_threshold_pf=CAP_REL_THRESHOLD_PF, - output_option=OutputOptions.sync_buffer_to_csv, - data_files={InstrumentProbeType.PRIMARY: "/data/capacitive_sensor_data.csv"}, ) diff --git a/hardware-testing/hardware_testing/gravimetric/config.py b/hardware-testing/hardware_testing/gravimetric/config.py index b783908d5e6..304087748d1 100644 --- a/hardware-testing/hardware_testing/gravimetric/config.py +++ b/hardware-testing/hardware_testing/gravimetric/config.py @@ -3,9 +3,8 @@ from typing import List, Dict, Tuple from typing_extensions import Final from enum import Enum -from opentrons.config.types import LiquidProbeSettings, OutputOptions +from opentrons.config.types import LiquidProbeSettings from opentrons.protocol_api.labware import Well -from opentrons.hardware_control.types import InstrumentProbeType class ConfigType(Enum): @@ -170,13 +169,11 @@ def _get_liquid_probe_settings( plunger_speed=lqid_cfg["plunger_speed"], plunger_impulse_time=0.2, sensor_threshold_pascals=lqid_cfg["sensor_threshold_pascals"], - output_option=OutputOptions.sync_only, aspirate_while_sensing=False, z_overlap_between_passes_mm=0.1, plunger_reset_offset=2.0, samples_for_baselining=20, sample_time_sec=0.004, - data_files={InstrumentProbeType.PRIMARY: "/data/testing_data/pressure.csv"}, ) diff --git a/hardware-testing/hardware_testing/liquid_sense/execute.py b/hardware-testing/hardware_testing/liquid_sense/execute.py index 01cb0d27375..001abdaa82f 100644 --- a/hardware-testing/hardware_testing/liquid_sense/execute.py +++ b/hardware-testing/hardware_testing/liquid_sense/execute.py @@ -4,7 +4,7 @@ from enum import Enum from typing import Dict, Any, List, Tuple, Optional from .report import store_tip_results, store_trial, store_baseline_trial -from opentrons.config.types import LiquidProbeSettings, OutputOptions +from opentrons.config.types import LiquidProbeSettings from .__main__ import RunArgs from hardware_testing.gravimetric.workarounds import get_sync_hw_api from hardware_testing.gravimetric.helpers import ( @@ -445,13 +445,11 @@ def _run_trial( plunger_speed=plunger_speed, plunger_impulse_time=0.2, sensor_threshold_pascals=lqid_cfg["sensor_threshold_pascals"], - output_option=OutputOptions.sync_buffer_to_csv, aspirate_while_sensing=run_args.aspirate, z_overlap_between_passes_mm=0.1, plunger_reset_offset=2.0, samples_for_baselining=20, sample_time_sec=0.004, - data_files=data_files, ) hw_mount = OT3Mount.LEFT if run_args.pipette.mount == "left" else OT3Mount.RIGHT diff --git a/hardware-testing/hardware_testing/production_qc/pipette_assembly_qc_ot3/__main__.py b/hardware-testing/hardware_testing/production_qc/pipette_assembly_qc_ot3/__main__.py index 139074ed0a1..90637e81540 100644 --- a/hardware-testing/hardware_testing/production_qc/pipette_assembly_qc_ot3/__main__.py +++ b/hardware-testing/hardware_testing/production_qc/pipette_assembly_qc_ot3/__main__.py @@ -18,7 +18,7 @@ from opentrons_hardware.firmware_bindings.messages.messages import MessageDefinition from opentrons_hardware.firmware_bindings.constants import SensorType, SensorId -from opentrons.config.types import LiquidProbeSettings, OutputOptions +from opentrons.config.types import LiquidProbeSettings from opentrons.hardware_control.types import ( TipStateType, FailedTipStateCheck, @@ -1378,13 +1378,11 @@ async def _test_liquid_probe( plunger_speed=probe_cfg.plunger_speed, plunger_impulse_time=0.2, sensor_threshold_pascals=probe_cfg.sensor_threshold_pascals, - output_option=OutputOptions.can_bus_only, # FIXME: remove aspirate_while_sensing=False, z_overlap_between_passes_mm=0.1, plunger_reset_offset=2.0, samples_for_baselining=20, sample_time_sec=0.004, - data_files=None, ) end_z = await api.liquid_probe( mount, max_z_distance_machine_coords, probe_settings, probe=probe diff --git a/hardware-testing/hardware_testing/production_qc/robot_assembly_qc_ot3/test_instruments.py b/hardware-testing/hardware_testing/production_qc/robot_assembly_qc_ot3/test_instruments.py index 994dbf4ea99..45c1a7cc9c3 100644 --- a/hardware-testing/hardware_testing/production_qc/robot_assembly_qc_ot3/test_instruments.py +++ b/hardware-testing/hardware_testing/production_qc/robot_assembly_qc_ot3/test_instruments.py @@ -1,7 +1,7 @@ """Test Instruments.""" from typing import List, Tuple, Optional, Union -from opentrons.config.types import CapacitivePassSettings, OutputOptions +from opentrons.config.types import CapacitivePassSettings from opentrons.hardware_control.ot3api import OT3API from hardware_testing.data.csv_report import ( @@ -30,7 +30,6 @@ max_overrun_distance_mm=0, speed_mm_per_s=Z_PROBE_DISTANCE_MM / Z_PROBE_TIME_SECONDS, sensor_threshold_pf=1.0, - output_option=OutputOptions.can_bus_only, ) RELATIVE_MOVE_FROM_HOME_DELTA = Point(x=-500, y=-300) diff --git a/hardware-testing/hardware_testing/scripts/ABRAsairScript.py b/hardware-testing/hardware_testing/scripts/ABRAsairScript.py index 2324e330dc7..8eea871b9a3 100644 --- a/hardware-testing/hardware_testing/scripts/ABRAsairScript.py +++ b/hardware-testing/hardware_testing/scripts/ABRAsairScript.py @@ -3,7 +3,7 @@ import paramiko as pmk import time import multiprocessing -from typing import Optional, List +from typing import Optional, List, Any def execute(client: pmk.SSHClient, command: str, args: list) -> Optional[int]: @@ -15,19 +15,8 @@ def execute(client: pmk.SSHClient, command: str, args: list) -> Optional[int]: stdin, stdout, stderr = client.exec_command(command, get_pty=True) stdout_lines: List[str] = [] stderr_lines: List[str] = [] - time.sleep(15) + time.sleep(25) - # check stdout, stderr - - # Check the exit status of the command. - # while not stdout.channel.exit_status_ready(): - # if stdout.channel.recv_ready(): - # stdout_lines = stdout.readlines() - # print(f"{args[0]} output:", "".join(stdout_lines)) - # if stderr.channel.recv_ready(): - # stderr_lines = stderr.readlines() - # print(f"{args[0]} ERROR:", "".join(stdout_lines)) - # return 1 if stderr.channel.recv_ready: stderr_lines = stderr.readlines() if stderr_lines != []: @@ -58,24 +47,9 @@ def connect_ssh(ip: str) -> pmk.SSHClient: return client -# Load Robot IPs -file_name = sys.argv[1] -robot_ips = [] -robot_names = [] - -with open(file_name) as file: - for line in file.readlines(): - info = line.split(",") - if "Y" in info[2]: - robot_ips.append(info[0]) - robot_names.append(info[1]) - -cmd = "nohup python3 -m hardware_testing.scripts.abr_asair_sensor {name} {duration} {frequency}" -cd = "cd /opt/opentrons-robot-server && " -print("Executing Script on All Robots:") - - -def run_command_on_ip(index: int) -> None: +def run_command_on_ip( + index: int, robot_ips: List[str], robot_names: List[str], cd: str, cmd: str +) -> None: """Execute ssh command and start abr_asair script on the specified robot.""" curr_ip = robot_ips[index] try: @@ -87,15 +61,35 @@ def run_command_on_ip(index: int) -> None: print(f"Error running command on {curr_ip}: {e}") -# Launch the processes for each robot. -processes = [] -for index in range(len(robot_ips)): - process = multiprocessing.Process(target=run_command_on_ip, args=(index,)) - processes.append(process) +def run(file_name: str) -> List[Any]: + """Run asair script module.""" + # Load Robot IPs + cmd = "nohup python3 -m hardware_testing.scripts.abr_asair_sensor {name} {duration} {frequency}" + cd = "cd /opt/opentrons-robot-server && " + robot_ips = [] + robot_names = [] + with open(file_name) as file: + for line in file.readlines(): + info = line.split(",") + if "Y" in info[2]: + robot_ips.append(info[0]) + robot_names.append(info[1]) + print("Executing Script on All Robots:") + # Launch the processes for each robot. + processes = [] + for index in range(len(robot_ips)): + process = multiprocessing.Process( + target=run_command_on_ip, args=(index, robot_ips, robot_names, cd, cmd) + ) + processes.append(process) + return processes if __name__ == "__main__": # Wait for all processes to finish. + file_name = sys.argv[1] + processes = run(file_name) + for process in processes: process.start() time.sleep(20) diff --git a/hardware-testing/hardware_testing/scripts/abr_asair_sensor.py b/hardware-testing/hardware_testing/scripts/abr_asair_sensor.py index 1e8fca0358c..ba41f9399f1 100644 --- a/hardware-testing/hardware_testing/scripts/abr_asair_sensor.py +++ b/hardware-testing/hardware_testing/scripts/abr_asair_sensor.py @@ -80,7 +80,7 @@ def __init__(self, robot: str, duration: int, frequency: int) -> None: break # write to google sheet try: - if google_sheet.creditals.access_token_expired: + if google_sheet.credentials.access_token_expired: google_sheet.gc.login() google_sheet.write_header(header) google_sheet.update_row_index() diff --git a/hardware-testing/hardware_testing/scripts/gripper_ot3.py b/hardware-testing/hardware_testing/scripts/gripper_ot3.py index 511ea11809d..cd131b8f13a 100644 --- a/hardware-testing/hardware_testing/scripts/gripper_ot3.py +++ b/hardware-testing/hardware_testing/scripts/gripper_ot3.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from typing import Optional, List, Any, Dict -from opentrons.config.defaults_ot3 import CapacitivePassSettings, OutputOptions +from opentrons.config.defaults_ot3 import CapacitivePassSettings from opentrons.hardware_control.ot3api import OT3API from hardware_testing.opentrons_api import types @@ -73,7 +73,6 @@ max_overrun_distance_mm=1, speed_mm_per_s=1, sensor_threshold_pf=0.5, - output_option=OutputOptions.sync_only, ) LABWARE_PROBE_CORNER_TOP_LEFT_XY = { "plate": Point(x=5, y=-5), diff --git a/hardware/opentrons_hardware/firmware_bindings/messages/messages.py b/hardware/opentrons_hardware/firmware_bindings/messages/messages.py index 0249ddec69e..35683bc1afb 100644 --- a/hardware/opentrons_hardware/firmware_bindings/messages/messages.py +++ b/hardware/opentrons_hardware/firmware_bindings/messages/messages.py @@ -74,6 +74,7 @@ defs.BaselineSensorResponse, defs.SetSensorThresholdRequest, defs.ReadFromSensorResponse, + defs.BatchReadFromSensorResponse, defs.SensorThresholdResponse, defs.SensorDiagnosticRequest, defs.SensorDiagnosticResponse, diff --git a/hardware/opentrons_hardware/hardware_control/tool_sensors.py b/hardware/opentrons_hardware/hardware_control/tool_sensors.py index 173a8c2738b..95076f01c1c 100644 --- a/hardware/opentrons_hardware/hardware_control/tool_sensors.py +++ b/hardware/opentrons_hardware/hardware_control/tool_sensors.py @@ -1,5 +1,6 @@ """Functions for commanding motion limited by tool sensors.""" import asyncio +from contextlib import AsyncExitStack from functools import partial from typing import ( Union, @@ -11,6 +12,7 @@ AsyncContextManager, Optional, AsyncIterator, + Mapping, ) from logging import getLogger from numpy import float64 @@ -41,6 +43,7 @@ from opentrons_hardware.sensors.sensor_driver import SensorDriver, LogListener from opentrons_hardware.sensors.types import ( sensor_fixed_point_conversion, + SensorDataType, ) from opentrons_hardware.sensors.sensor_types import ( SensorInformation, @@ -61,28 +64,13 @@ ) LOG = getLogger(__name__) + PipetteProbeTarget = Literal[NodeId.pipette_left, NodeId.pipette_right] InstrumentProbeTarget = Union[PipetteProbeTarget, Literal[NodeId.gripper]] ProbeSensorDict = Union[ Dict[SensorId, PressureSensor], Dict[SensorId, CapacitiveSensor] ] -pressure_output_file_heading = [ - "time(s)", - "Pressure(pascals)", - "z_velocity(mm/s)", - "plunger_velocity(mm/s)", - "threshold(pascals)", -] - -capacitive_output_file_heading = [ - "time(s)", - "Capacitance(farads)", - "z_velocity(mm/s)", - "plunger_velocity(mm/s)", - "threshold(farads)", -] - def _fix_pass_step_for_buffer( move_group: MoveGroupStep, @@ -167,124 +155,6 @@ def _build_pass_step( return move_group -async def run_sync_buffer_to_csv( - messenger: CanMessenger, - mount_speed: float, - plunger_speed: float, - threshold: float, - head_node: NodeId, - move_group: MoveGroupRunner, - log_files: Dict[SensorId, str], - tool: InstrumentProbeTarget, - sensor_type: SensorType, - output_file_heading: list[str], - raise_z: Optional[MoveGroupRunner] = None, -) -> Dict[NodeId, MotorPositionStatus]: - """Runs the sensor pass move group and creates a csv file with the results.""" - sensor_metadata = [0, 0, mount_speed, plunger_speed, threshold] - positions = await move_group.run(can_messenger=messenger) - # wait a little to see the dropoff curve - await asyncio.sleep(0.15) - for sensor_id in log_files.keys(): - await messenger.ensure_send( - node_id=tool, - message=BindSensorOutputRequest( - payload=BindSensorOutputRequestPayload( - sensor=SensorTypeField(sensor_type), - sensor_id=SensorIdField(sensor_id), - binding=SensorOutputBindingField(SensorOutputBinding.none), - ) - ), - expected_nodes=[tool], - ) - if raise_z is not None: - # if probing is finished, move the head node back up before requesting the data buffer - if positions[head_node].move_ack == MoveCompleteAck.stopped_by_condition: - await raise_z.run(can_messenger=messenger) - for sensor_id in log_files.keys(): - sensor_capturer = LogListener( - mount=head_node, - data_file=log_files[sensor_id], - file_heading=output_file_heading, - sensor_metadata=sensor_metadata, - ) - async with sensor_capturer: - messenger.add_listener(sensor_capturer, None) - request = SendAccumulatedSensorDataRequest( - payload=SendAccumulatedSensorDataPayload( - sensor_id=SensorIdField(sensor_id), - sensor_type=SensorTypeField(sensor_type), - ) - ) - await messenger.send( - node_id=tool, - message=request, - ) - await sensor_capturer.wait_for_complete( - message_index=request.payload.message_index.value - ) - messenger.remove_listener(sensor_capturer) - return positions - - -async def run_stream_output_to_csv( - messenger: CanMessenger, - sensors: ProbeSensorDict, - mount_speed: float, - plunger_speed: float, - threshold: float, - head_node: NodeId, - move_group: MoveGroupRunner, - log_files: Dict[SensorId, str], - output_file_heading: list[str], -) -> Dict[NodeId, MotorPositionStatus]: - """Runs the sensor pass move group and creates a csv file with the results.""" - sensor_metadata = [0, 0, mount_speed, plunger_speed, threshold] - sensor_capturer = LogListener( - mount=head_node, - data_file=log_files[ - next(iter(log_files)) - ], # hardcode to the first file, need to think more on this - file_heading=output_file_heading, - sensor_metadata=sensor_metadata, - ) - binding = [SensorOutputBinding.sync, SensorOutputBinding.report] - binding_field = SensorOutputBindingField.from_flags(binding) - for sensor_id in sensors.keys(): - sensor_info = sensors[sensor_id].sensor - await messenger.ensure_send( - node_id=sensor_info.node_id, - message=BindSensorOutputRequest( - payload=BindSensorOutputRequestPayload( - sensor=SensorTypeField(sensor_info.sensor_type), - sensor_id=SensorIdField(sensor_info.sensor_id), - binding=binding_field, - ) - ), - expected_nodes=[sensor_info.node_id], - ) - - messenger.add_listener(sensor_capturer, None) - async with sensor_capturer: - positions = await move_group.run(can_messenger=messenger) - messenger.remove_listener(sensor_capturer) - - for sensor_id in sensors.keys(): - sensor_info = sensors[sensor_id].sensor - await messenger.ensure_send( - node_id=sensor_info.node_id, - message=BindSensorOutputRequest( - payload=BindSensorOutputRequestPayload( - sensor=SensorTypeField(sensor_info.sensor_type), - sensor_id=SensorIdField(sensor_info.sensor_id), - binding=SensorOutputBindingField(SensorOutputBinding.none), - ) - ), - expected_nodes=[sensor_info.node_id], - ) - return positions - - async def _setup_pressure_sensors( messenger: CanMessenger, sensor_id: SensorId, @@ -351,42 +221,42 @@ async def _setup_capacitive_sensors( return result -async def _run_with_binding( +async def finalize_logs( messenger: CanMessenger, - sensors: ProbeSensorDict, - sensor_runner: MoveGroupRunner, - binding: List[SensorOutputBinding], -) -> Dict[NodeId, MotorPositionStatus]: - binding_field = SensorOutputBindingField.from_flags(binding) - for sensor_id in sensors.keys(): - sensor_info = sensors[sensor_id].sensor + tool: NodeId, + listeners: Dict[SensorId, LogListener], + sensors: Mapping[SensorId, Union[CapacitiveSensor, PressureSensor]], +) -> None: + """Signal the sensors to finish sending their data and wait for it to flush out.""" + for s_id in sensors.keys(): + # Tell the sensor to stop recording await messenger.ensure_send( - node_id=sensor_info.node_id, - message=BindSensorOutputRequest( - payload=BindSensorOutputRequestPayload( - sensor=SensorTypeField(sensor_info.sensor_type), - sensor_id=SensorIdField(sensor_info.sensor_id), - binding=binding_field, - ) - ), - expected_nodes=[sensor_info.node_id], - ) - - result = await sensor_runner.run(can_messenger=messenger) - for sensor_id in sensors.keys(): - sensor_info = sensors[sensor_id].sensor - await messenger.ensure_send( - node_id=sensor_info.node_id, + node_id=tool, message=BindSensorOutputRequest( payload=BindSensorOutputRequestPayload( - sensor=SensorTypeField(sensor_info.sensor_type), - sensor_id=SensorIdField(sensor_info.sensor_id), + sensor=SensorTypeField(sensors[s_id].sensor.sensor_type), + sensor_id=SensorIdField(s_id), binding=SensorOutputBindingField(SensorOutputBinding.none), ) ), - expected_nodes=[sensor_info.node_id], + expected_nodes=[tool], ) - return result + request = SendAccumulatedSensorDataRequest( + payload=SendAccumulatedSensorDataPayload( + sensor_id=SensorIdField(s_id), + sensor_type=SensorTypeField(sensors[s_id].sensor.sensor_type), + ) + ) + # set the message index of the Ack that signals this sensor is finished sending data + listeners[s_id].set_stop_ack(request.payload.message_index.value) + # tell the sensor to clear it's queue + await messenger.send( + node_id=tool, + message=request, + ) + # wait for the data to finish sending + for listener in listeners.values(): + await listener.wait_for_complete() async def liquid_probe( @@ -399,15 +269,13 @@ async def liquid_probe( threshold_pascals: float, plunger_impulse_time: float, num_baseline_reads: int, - csv_output: bool = False, - sync_buffer_output: bool = False, - can_bus_only_output: bool = False, - data_files: Optional[Dict[SensorId, str]] = None, sensor_id: SensorId = SensorId.S0, force_both_sensors: bool = False, + response_queue: Optional[ + asyncio.Queue[Dict[SensorId, List[SensorDataType]]] + ] = None, ) -> Dict[NodeId, MotorPositionStatus]: """Move the mount and pipette simultaneously while reading from the pressure sensor.""" - log_files: Dict[SensorId, str] = {} if not data_files else data_files sensor_driver = SensorDriver() threshold_fixed_point = threshold_pascals * sensor_fixed_point_conversion sensor_binding = None @@ -420,7 +288,7 @@ async def liquid_probe( + SensorOutputBinding.report + SensorOutputBinding.multi_sensor_sync ) - pressure_sensors = await _setup_pressure_sensors( + pressure_sensors: Dict[SensorId, PressureSensor] = await _setup_pressure_sensors( messenger, sensor_id, tool, @@ -440,6 +308,7 @@ async def liquid_probe( duration=float64(plunger_impulse_time), present_nodes=[tool], ) + sensor_group = _build_pass_step( movers=[head_node, tool], distance={head_node: max_z_distance, tool: p_pass_distance}, @@ -449,64 +318,56 @@ async def liquid_probe( stop_condition=MoveStopCondition.sync_line, binding_flags=sensor_binding, ) - if sync_buffer_output: - sensor_group = _fix_pass_step_for_buffer( - sensor_group, - movers=[head_node, tool], - distance={head_node: max_z_distance, tool: p_pass_distance}, - speed={head_node: mount_speed, tool: plunger_speed}, - sensor_type=SensorType.pressure, - sensor_id=sensor_id, - stop_condition=MoveStopCondition.sync_line, - binding_flags=sensor_binding, - ) + sensor_group = _fix_pass_step_for_buffer( + sensor_group, + movers=[head_node, tool], + distance={head_node: max_z_distance, tool: p_pass_distance}, + speed={head_node: mount_speed, tool: plunger_speed}, + sensor_type=SensorType.pressure, + sensor_id=sensor_id, + stop_condition=MoveStopCondition.sync_line, + binding_flags=sensor_binding, + ) sensor_runner = MoveGroupRunner(move_groups=[[lower_plunger], [sensor_group]]) - if csv_output: - return await run_stream_output_to_csv( - messenger, - pressure_sensors, - mount_speed, - plunger_speed, - threshold_pascals, - head_node, - sensor_runner, - log_files, - pressure_output_file_heading, - ) - elif sync_buffer_output: - raise_z = create_step( - distance={head_node: float64(max_z_distance)}, - velocity={head_node: float64(-1 * mount_speed)}, - acceleration={}, - duration=float64(max_z_distance / mount_speed), - present_nodes=[head_node], - ) - raise_z_runner = MoveGroupRunner(move_groups=[[raise_z]]) - - return await run_sync_buffer_to_csv( - messenger=messenger, - mount_speed=mount_speed, - plunger_speed=plunger_speed, - threshold=threshold_pascals, - head_node=head_node, - move_group=sensor_runner, - raise_z=raise_z_runner, - log_files=log_files, - tool=tool, - sensor_type=SensorType.pressure, - output_file_heading=pressure_output_file_heading, - ) - elif can_bus_only_output: - binding = [SensorOutputBinding.sync, SensorOutputBinding.report] - return await _run_with_binding( - messenger, pressure_sensors, sensor_runner, binding - ) - else: # none - binding = [SensorOutputBinding.sync] - return await _run_with_binding( - messenger, pressure_sensors, sensor_runner, binding - ) + + raise_z = create_step( + distance={head_node: float64(max_z_distance)}, + velocity={head_node: float64(-1 * mount_speed)}, + acceleration={}, + duration=float64(max_z_distance / mount_speed), + present_nodes=[head_node], + ) + + raise_z_runner = MoveGroupRunner(move_groups=[[raise_z]]) + listeners = { + s_id: LogListener(messenger, pressure_sensors[s_id]) + for s_id in pressure_sensors.keys() + } + + LOG.info( + f"Starting LLD pass: {head_node} {sensor_id} max p distance {max_p_distance} max z distance {max_z_distance}" + ) + async with AsyncExitStack() as binding_stack: + for listener in listeners.values(): + await binding_stack.enter_async_context(listener) + positions = await sensor_runner.run(can_messenger=messenger) + if positions[head_node].move_ack == MoveCompleteAck.stopped_by_condition: + LOG.info( + f"Liquid found {head_node} motor_postion {positions[head_node].motor_position} encoder position {positions[head_node].encoder_position}" + ) + await raise_z_runner.run(can_messenger=messenger) + await finalize_logs(messenger, tool, listeners, pressure_sensors) + + # give response data to any consumer that wants it + if response_queue: + for s_id in listeners.keys(): + data = listeners[s_id].get_data() + if data: + for d in data: + response_queue.put_nowait({s_id: data}) + + return positions async def check_overpressure( @@ -536,10 +397,9 @@ async def capacitive_probe( mount_speed: float, sensor_id: SensorId = SensorId.S0, relative_threshold_pf: float = 1.0, - csv_output: bool = False, - sync_buffer_output: bool = False, - can_bus_only_output: bool = False, - data_files: Optional[Dict[SensorId, str]] = None, + response_queue: Optional[ + asyncio.Queue[dict[SensorId, list[SensorDataType]]] + ] = None, ) -> MotorPositionStatus: """Move the specified tool down until its capacitive sensor triggers. @@ -549,7 +409,6 @@ async def capacitive_probe( The direction is sgn(distance)*sgn(speed), so you can set the direction either by negating speed or negating distance. """ - log_files: Dict[SensorId, str] = {} if not data_files else data_files sensor_driver = SensorDriver() pipette_present = tool in [NodeId.pipette_left, NodeId.pipette_right] @@ -577,53 +436,36 @@ async def capacitive_probe( sensor_id=sensor_id, stop_condition=MoveStopCondition.sync_line, ) - if sync_buffer_output: - sensor_group = _fix_pass_step_for_buffer( - sensor_group, - movers=movers, - distance=probe_distance, - speed=probe_speed, - sensor_type=SensorType.capacitive, - sensor_id=sensor_id, - stop_condition=MoveStopCondition.sync_line, - ) + + sensor_group = _fix_pass_step_for_buffer( + sensor_group, + movers=movers, + distance=probe_distance, + speed=probe_speed, + sensor_type=SensorType.capacitive, + sensor_id=sensor_id, + stop_condition=MoveStopCondition.sync_line, + ) runner = MoveGroupRunner(move_groups=[[sensor_group]]) - if csv_output: - positions = await run_stream_output_to_csv( - messenger, - capacitive_sensors, - mount_speed, - 0.0, - relative_threshold_pf, - mover, - runner, - log_files, - capacitive_output_file_heading, - ) - elif sync_buffer_output: - positions = await run_sync_buffer_to_csv( - messenger, - mount_speed, - 0.0, - relative_threshold_pf, - mover, - runner, - log_files, - tool=tool, - sensor_type=SensorType.capacitive, - output_file_heading=capacitive_output_file_heading, - ) - elif can_bus_only_output: - binding = [SensorOutputBinding.sync, SensorOutputBinding.report] - positions = await _run_with_binding( - messenger, capacitive_sensors, runner, binding - ) - else: - binding = [SensorOutputBinding.sync] - positions = await _run_with_binding( - messenger, capacitive_sensors, runner, binding - ) + + listeners = { + s_id: LogListener(messenger, capacitive_sensors[s_id]) + for s_id in capacitive_sensors.keys() + } + async with AsyncExitStack() as binding_stack: + for listener in listeners.values(): + await binding_stack.enter_async_context(listener) + positions = await runner.run(can_messenger=messenger) + await finalize_logs(messenger, tool, listeners, capacitive_sensors) + + # give response data to any consumer that wants it + if response_queue: + for s_id in listeners.keys(): + data = listeners[s_id].get_data() + if data: + for d in data: + response_queue.put_nowait({s_id: data}) return positions[mover] diff --git a/hardware/opentrons_hardware/sensors/__init__.py b/hardware/opentrons_hardware/sensors/__init__.py index adc4f0c52af..3ae059861a1 100644 --- a/hardware/opentrons_hardware/sensors/__init__.py +++ b/hardware/opentrons_hardware/sensors/__init__.py @@ -1 +1,3 @@ """Sub-module for sensor drivers.""" + +SENSOR_LOG_NAME = "pipettes-sensor-log" diff --git a/hardware/opentrons_hardware/sensors/sensor_driver.py b/hardware/opentrons_hardware/sensors/sensor_driver.py index 611bc091970..0f1904f8a26 100644 --- a/hardware/opentrons_hardware/sensors/sensor_driver.py +++ b/hardware/opentrons_hardware/sensors/sensor_driver.py @@ -1,9 +1,8 @@ """Capacitve Sensor Driver Class.""" import time import asyncio -import csv -from typing import Optional, AsyncIterator, Any, Sequence +from typing import Optional, AsyncIterator, Any, Sequence, List, Union from contextlib import asynccontextmanager, suppress from logging import getLogger @@ -19,7 +18,6 @@ from opentrons_hardware.firmware_bindings.constants import ( SensorOutputBinding, SensorThresholdMode, - NodeId, ) from opentrons_hardware.sensors.types import ( SensorDataType, @@ -32,7 +30,12 @@ SensorThresholdInformation, ) -from opentrons_hardware.sensors.sensor_types import BaseSensorType, ThresholdSensorType +from opentrons_hardware.sensors.sensor_types import ( + BaseSensorType, + ThresholdSensorType, + PressureSensor, + CapacitiveSensor, +) from opentrons_hardware.firmware_bindings.messages.payloads import ( BindSensorOutputRequestPayload, ) @@ -46,8 +49,10 @@ ) from .sensor_abc import AbstractSensorDriver from .scheduler import SensorScheduler +from . import SENSOR_LOG_NAME LOG = getLogger(__name__) +SENSOR_LOG = getLogger(SENSOR_LOG_NAME) class SensorDriver(AbstractSensorDriver): @@ -226,43 +231,50 @@ class LogListener: def __init__( self, - mount: NodeId, - data_file: Any, - file_heading: Sequence[str], - sensor_metadata: Sequence[Any], + messenger: CanMessenger, + sensor: Union[PressureSensor, CapacitiveSensor], ) -> None: """Build the capturer.""" - self.csv_writer = Any - self.data_file = data_file - self.file_heading = file_heading - self.sensor_metadata = sensor_metadata - self.response_queue: asyncio.Queue[float] = asyncio.Queue() - self.mount = mount + self.response_queue: asyncio.Queue[SensorDataType] = asyncio.Queue() + self.tool = sensor.sensor.node_id self.start_time = 0.0 self.event: Any = None + self.messenger = messenger + self.sensor = sensor + self.type = sensor.sensor.sensor_type + self.id = sensor.sensor.sensor_id - async def __aenter__(self) -> None: - """Create a csv heading for logging pressure readings.""" - self.data_file = open(self.data_file, "w") - self.csv_writer = csv.writer(self.data_file) - self.csv_writer.writerows([self.file_heading, self.sensor_metadata]) + def get_data(self) -> Optional[List[SensorDataType]]: + """Return the sensor data captured by this listener.""" + if self.response_queue.empty(): + return None + data: List[SensorDataType] = [] + while not self.response_queue.empty(): + data.append(self.response_queue.get_nowait()) + return data + async def __aenter__(self) -> None: + """Start logging sensor readings.""" + self.messenger.add_listener(self, None) self.start_time = time.time() + SENSOR_LOG.info(f"Data capture for {self.tool.name} started {self.start_time}") async def __aexit__(self, *args: Any) -> None: - """Close csv file.""" - self.data_file.close() + """Finish the capture.""" + self.messenger.remove_listener(self) + SENSOR_LOG.info(f"Data capture for {self.tool.name} ended {time.time()}") - async def wait_for_complete( - self, wait_time: float = 10, message_index: int = 0 - ) -> None: - """Wait for the data to stop, only use this with a send_accumulated_data_request.""" + def set_stop_ack(self, message_index: int = 0) -> None: + """Tell the Listener which message index to wait for.""" self.event = asyncio.Event() self.expected_ack = message_index + + async def wait_for_complete(self, wait_time: float = 10) -> None: + """Wait for the data to stop.""" with suppress(asyncio.TimeoutError): await asyncio.wait_for(self.event.wait(), wait_time) if not self.event.is_set(): - LOG.error("Did not receive the full data set from the sensor") + SENSOR_LOG.error("Did not receive the full data set from the sensor") self.event = None def __call__( @@ -271,30 +283,44 @@ def __call__( arbitration_id: ArbitrationId, ) -> None: """Callback entry point for capturing messages.""" + if arbitration_id.parts.originating_node_id != self.tool: + # check that this is from the node we care about + return if isinstance(message, message_definitions.ReadFromSensorResponse): + if ( + message.payload.sensor_id.value is not self.id + or message.payload.sensor is not self.type + ): + # ignore sensor responses from other sensors + return data = sensor_types.SensorDataType.build( message.payload.sensor_data, message.payload.sensor - ).to_float() + ) self.response_queue.put_nowait(data) - current_time = round((time.time() - self.start_time), 3) - self.csv_writer.writerow([current_time, data]) # type: ignore + SENSOR_LOG.info( + f"Revieved from {arbitration_id}: {message.payload.sensor_id}:{message.payload.sensor}: {data}" + ) if isinstance(message, message_definitions.BatchReadFromSensorResponse): data_length = message.payload.data_length.value data_bytes = message.payload.sensor_data.value data_ints = [ - int.from_bytes(data_bytes[i * 4 : i * 4 + 4]) + int.from_bytes(data_bytes[i * 4 : i * 4 + 4], byteorder="little") for i in range(data_length) ] - for d in data_ints: - data = sensor_types.SensorDataType.build( - d, message.payload.sensor - ).to_float() - self.response_queue.put_nowait(data) - current_time = round((time.time() - self.start_time), 3) - self.csv_writer.writerow([current_time, data]) + data_floats = [ + sensor_types.SensorDataType.build(d, message.payload.sensor) + for d in data_ints + ] + + for d in data_floats: + self.response_queue.put_nowait(d) + SENSOR_LOG.info( + f"Revieved from {arbitration_id}: {message.payload.sensor_id}:{message.payload.sensor}: {data_floats}" + ) if isinstance(message, message_definitions.Acknowledgement): if ( self.event is not None and message.payload.message_index.value == self.expected_ack ): + SENSOR_LOG.info("Finished receiving sensor data") self.event.set() diff --git a/hardware/tests/opentrons_hardware/hardware_control/test_tool_sensors.py b/hardware/tests/opentrons_hardware/hardware_control/test_tool_sensors.py index 2dc7614da63..0c53b81057a 100644 --- a/hardware/tests/opentrons_hardware/hardware_control/test_tool_sensors.py +++ b/hardware/tests/opentrons_hardware/hardware_control/test_tool_sensors.py @@ -1,12 +1,10 @@ """Test the tool-sensor coordination code.""" import logging from mock import patch, AsyncMock, call -import os import pytest from contextlib import asynccontextmanager from typing import Iterator, List, Tuple, AsyncIterator, Any, Dict, Callable from opentrons_hardware.firmware_bindings.messages.message_definitions import ( - AddLinearMoveRequest, ExecuteMoveGroupRequest, MoveCompleted, ReadFromSensorResponse, @@ -50,7 +48,6 @@ SensorType, SensorThresholdMode, SensorOutputBinding, - MoveStopCondition, ) from opentrons_hardware.sensors.scheduler import SensorScheduler from opentrons_hardware.sensors.sensor_driver import SensorDriver @@ -187,78 +184,7 @@ def check_second_move( ), ] - def get_responder() -> Iterator[ - Callable[ - [NodeId, MessageDefinition], List[Tuple[NodeId, MessageDefinition, NodeId]] - ] - ]: - yield check_first_move - yield check_second_move - - responder_getter = get_responder() - - def move_responder( - node_id: NodeId, message: MessageDefinition - ) -> List[Tuple[NodeId, MessageDefinition, NodeId]]: - message.payload.serialize() - if isinstance(message, ExecuteMoveGroupRequest): - responder = next(responder_getter) - return responder(node_id, message) - else: - return [] - - message_send_loopback.add_responder(move_responder) - - position = await liquid_probe( - messenger=mock_messenger, - tool=target_node, - head_node=motor_node, - max_p_distance=70, - mount_speed=10, - plunger_speed=8, - threshold_pascals=threshold_pascals, - plunger_impulse_time=0.2, - num_baseline_reads=20, - csv_output=False, - sync_buffer_output=False, - can_bus_only_output=False, - sensor_id=SensorId.S0, - ) - assert position[motor_node].positions_only()[0] == 14 - assert mock_sensor_threshold.call_args_list[0][0][0] == SensorThresholdInformation( - sensor=sensor_info, - data=SensorDataType.build(threshold_pascals * 65536, sensor_info.sensor_type), - mode=SensorThresholdMode.absolute, - ) - - -@pytest.mark.parametrize( - "csv_output, sync_buffer_output, can_bus_only_output, move_stop_condition", - [ - (True, False, False, MoveStopCondition.sync_line), - (True, True, False, MoveStopCondition.sensor_report), - (False, False, True, MoveStopCondition.sync_line), - ], -) -async def test_liquid_probe_output_options( - mock_messenger: AsyncMock, - mock_bind_output: AsyncMock, - message_send_loopback: CanLoopback, - mock_sensor_threshold: AsyncMock, - csv_output: bool, - sync_buffer_output: bool, - can_bus_only_output: bool, - move_stop_condition: MoveStopCondition, -) -> None: - """Test that liquid_probe targets the right nodes.""" - sensor_info = SensorInformation( - sensor_type=SensorType.pressure, - sensor_id=SensorId.S0, - node_id=NodeId.pipette_left, - ) - test_csv_file: str = os.path.join(os.getcwd(), "test.csv") - - def check_first_move( + def check_third_move( node_id: NodeId, message: MessageDefinition ) -> List[Tuple[NodeId, MessageDefinition, NodeId]]: return [ @@ -274,44 +200,10 @@ def check_first_move( ack_id=UInt8Field(1), ) ), - NodeId.pipette_left, + motor_node, ) ] - def check_second_move( - node_id: NodeId, message: MessageDefinition - ) -> List[Tuple[NodeId, MessageDefinition, NodeId]]: - return [ - ( - NodeId.host, - MoveCompleted( - payload=MoveCompletedPayload( - group_id=UInt8Field(1), - seq_id=UInt8Field(0), - current_position_um=UInt32Field(14000), - encoder_position_um=Int32Field(14000), - position_flags=MotorPositionFlagsField(0), - ack_id=UInt8Field(2), - ) - ), - NodeId.head_l, - ), - ( - NodeId.host, - MoveCompleted( - payload=MoveCompletedPayload( - group_id=UInt8Field(1), - seq_id=UInt8Field(0), - current_position_um=UInt32Field(14000), - encoder_position_um=Int32Field(14000), - position_flags=MotorPositionFlagsField(0), - ack_id=UInt8Field(2), - ) - ), - NodeId.pipette_left, - ), - ] - def get_responder() -> Iterator[ Callable[ [NodeId, MessageDefinition], List[Tuple[NodeId, MessageDefinition, NodeId]] @@ -319,6 +211,7 @@ def get_responder() -> Iterator[ ]: yield check_first_move yield check_second_move + yield check_third_move responder_getter = get_responder() @@ -330,42 +223,26 @@ def move_responder( responder = next(responder_getter) return responder(node_id, message) else: - if ( - isinstance(message, AddLinearMoveRequest) - and node_id == NodeId.pipette_left - and message.payload.group_id == 2 - ): - assert ( - message.payload.request_stop_condition.value == move_stop_condition - ) return [] message_send_loopback.add_responder(move_responder) - try: - position = await liquid_probe( - messenger=mock_messenger, - tool=NodeId.pipette_left, - head_node=NodeId.head_l, - max_p_distance=70, - mount_speed=10, - plunger_speed=8, - threshold_pascals=14, - plunger_impulse_time=0.2, - num_baseline_reads=20, - csv_output=csv_output, - sync_buffer_output=sync_buffer_output, - can_bus_only_output=can_bus_only_output, - data_files={SensorId.S0: test_csv_file}, - sensor_id=SensorId.S0, - ) - finally: - if os.path.isfile(test_csv_file): - # clean up the test file this creates if it exists - os.remove(test_csv_file) - assert position[NodeId.head_l].positions_only()[0] == 14 + + position = await liquid_probe( + messenger=mock_messenger, + tool=target_node, + head_node=motor_node, + max_p_distance=70, + mount_speed=10, + plunger_speed=8, + threshold_pascals=threshold_pascals, + plunger_impulse_time=0.2, + num_baseline_reads=20, + sensor_id=SensorId.S0, + ) + assert position[motor_node].positions_only()[0] == 14 assert mock_sensor_threshold.call_args_list[0][0][0] == SensorThresholdInformation( sensor=sensor_info, - data=SensorDataType.build(14 * 65536, sensor_info.sensor_type), + data=SensorDataType.build(threshold_pascals * 65536, sensor_info.sensor_type), mode=SensorThresholdMode.absolute, ) diff --git a/labware-library/Makefile b/labware-library/Makefile index a074edd4092..0ea32b222ee 100644 --- a/labware-library/Makefile +++ b/labware-library/Makefile @@ -37,13 +37,25 @@ dev: serve: all node ../scripts/serve-static dist +.PHONY: clean-downloads +clean-downloads: + shx rm -rf cypress/downloads + # end to end tests .PHONY: test-e2e -test-e2e: +test-e2e: clean-downloads concurrently --no-color --kill-others --success first --names "labware-library-server,labware-library-tests" \ - "$(MAKE) dev CYPRESS=1 GTM_ID=''" \ + "$(MAKE) dev GTM_ID=''" \ "wait-on http://localhost:5179/ && echo \"Running cypress at $(date)\" && cypress run --browser chrome --headless --record false" +REMOTE_BASE_URL ?= https://labware.opentrons.com +.PHONY: test-e2e-remote +test-e2e-remote: clean-downloads + @echo "Running cypress tests against $(CYPRESS_BASE_URL)" + @echo "example: make test-e2e-remote REMOTE_BASE_URL='https://labware.opentrons.com'" + @echo + cypress run --browser chrome --headless --config "baseUrl=$(REMOTE_BASE_URL)" + # unit tests .PHONY: test test: diff --git a/labware-library/README.md b/labware-library/README.md index 06982723d7d..3466de2cacf 100644 --- a/labware-library/README.md +++ b/labware-library/README.md @@ -83,3 +83,7 @@ Certain environment variables, when set, will affect the artifact output. | OT_LL_MIXPANEL_DEV_ID | some string ID | Mixpanel token for dev environment. | | OT_LL_VERSION | semver string eg "1.2.3" | reported to analytics. Read from package.json by default. | | OT_LL_BUILD_DATE | result of `new Date().toUTCString()` | reported to analytics. Uses current date-time by default. | + +## Cypress + +`npx cypress open` will open the Cypress test runner. From there, you can run the tests in the `labware-library` directory. diff --git a/labware-library/cypress.config.js b/labware-library/cypress.config.js index e1871656f84..7df4db9fcc6 100644 --- a/labware-library/cypress.config.js +++ b/labware-library/cypress.config.js @@ -3,11 +3,6 @@ const { defineConfig } = require('cypress') module.exports = defineConfig({ video: false, e2e: { - // We've imported your old cypress plugins here. - // You may want to clean this up later by importing these. - setupNodeEvents(on, config) { - return require('./cypress/plugins/index.js')(on, config) - }, baseUrl: 'http://localhost:5179', }, }) diff --git a/labware-library/cypress/e2e/home.cy.js b/labware-library/cypress/e2e/home.cy.js index 79a39e8712e..20c8015e834 100644 --- a/labware-library/cypress/e2e/home.cy.js +++ b/labware-library/cypress/e2e/home.cy.js @@ -1,7 +1,8 @@ +import { navigateToUrl } from '../support/e2e' + describe('The Desktop Home Page', () => { beforeEach(() => { - cy.visit('/') - cy.viewport('macbook-15') + navigateToUrl('/') }) it('successfully loads', () => { diff --git a/labware-library/cypress/e2e/labware-creator/create.cy.js b/labware-library/cypress/e2e/labware-creator/create.cy.js index 299a3444a86..917b263cb78 100644 --- a/labware-library/cypress/e2e/labware-creator/create.cy.js +++ b/labware-library/cypress/e2e/labware-creator/create.cy.js @@ -2,10 +2,11 @@ // an element is in view before clicking or checking with // { force: true } +import { navigateToUrl } from '../../support/e2e' + context('The Labware Creator Landing Page', () => { beforeEach(() => { - cy.visit('/#/create') - cy.viewport('macbook-15') + navigateToUrl('/#/create') }) describe('The initial text', () => { diff --git a/labware-library/cypress/e2e/labware-creator/customTubeRack.cy.js b/labware-library/cypress/e2e/labware-creator/customTubeRack.cy.js index 319e7f4ea81..b7f9cbbcc30 100644 --- a/labware-library/cypress/e2e/labware-creator/customTubeRack.cy.js +++ b/labware-library/cypress/e2e/labware-creator/customTubeRack.cy.js @@ -1,13 +1,13 @@ -import 'cypress-file-upload' -import { expectDeepEqual } from '@opentrons/shared-data/js/cypressUtils' - -const expectedExportFixture = - '../fixtures/somerackbrand_24_tuberack_1500ul.json' +import { + navigateToUrl, + fileHelper, + wellBottomImageLocator, +} from '../../support/e2e' +const fileHolder = fileHelper('somerackbrand_24_tuberack_1500ul') context('Tubes and Rack', () => { before(() => { - cy.visit('/#/create') - cy.viewport('macbook-15') + navigateToUrl('/#/create') }) describe('Custom 6 x 4 tube rack', () => { @@ -109,24 +109,29 @@ context('Tubes and Rack', () => { cy.contains('Diameter is a required field').should('not.exist') // well bottom shape and depth + // check flat cy.get("input[name='wellBottomShape'][value='flat']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('exist') - cy.get("img[src*='_round.']").should('not.exist') - cy.get("img[src*='_v.']").should('not.exist') + cy.get(wellBottomImageLocator.flat).should('exist') + cy.get(wellBottomImageLocator.round).should('not.exist') + cy.get(wellBottomImageLocator.v).should('not.exist') + + // check u shaped cy.get("input[name='wellBottomShape'][value='u']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('not.exist') - cy.get("img[src*='_round.']").should('exist') - cy.get("img[src*='_v.']").should('not.exist') + cy.get(wellBottomImageLocator.flat).should('not.exist') + cy.get(wellBottomImageLocator.round).should('exist') + cy.get(wellBottomImageLocator.v).should('not.exist') + + // check v shaped cy.get("input[name='wellBottomShape'][value='v']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('not.exist') - cy.get("img[src*='_round.']").should('not.exist') - cy.get("img[src*='_v.']").should('exist') + cy.get(wellBottomImageLocator.flat).should('not.exist') + cy.get(wellBottomImageLocator.round).should('not.exist') + cy.get(wellBottomImageLocator.v).should('exist') cy.get("input[name='wellDepth']").focus().blur() cy.contains('Depth is a required field').should('exist') cy.get("input[name='wellDepth']").type('100').blur() @@ -159,28 +164,20 @@ context('Tubes and Rack', () => { cy.get( "input[placeholder='somerackbrand 24 Tube Rack with sometubebrand 1.5 mL']" ).should('exist') - cy.get("input[placeholder='somerackbrand_24_tuberack_1500ul']").should( + cy.get(`input[placeholder='${fileHolder.downloadFileStem}']`).should( 'exist' ) - - // now try again with all fields inputed - cy.fixture(expectedExportFixture).then(expectedExportLabwareDef => { - cy.get('button').contains('EXPORT FILE').click() - - cy.window() - .its('__lastSavedFileBlob__') - .should('be.a', 'blob') - .should(async blob => { - const labwareDefText = await blob.text() - const savedDef = JSON.parse(labwareDefText) - - expectDeepEqual(assert, savedDef, expectedExportLabwareDef) + cy.get('button').contains('EXPORT FILE').click() + + cy.fixture(fileHolder.expectedExportFixture).then( + expectedExportLabwareDef => { + cy.readFile(fileHolder.downloadPath).then(actualExportLabwareDef => { + expect(actualExportLabwareDef).to.deep.equal( + expectedExportLabwareDef + ) }) - - cy.window() - .its('__lastSavedFileName__') - .should('equal', `somerackbrand_24_tuberack_1500ul.json`) - }) + } + ) }) }) }) diff --git a/labware-library/cypress/e2e/labware-creator/fileImport.cy.js b/labware-library/cypress/e2e/labware-creator/fileImport.cy.js index e0fc480107f..616edca7d5b 100644 --- a/labware-library/cypress/e2e/labware-creator/fileImport.cy.js +++ b/labware-library/cypress/e2e/labware-creator/fileImport.cy.js @@ -1,11 +1,15 @@ -import { expectDeepEqual } from '@opentrons/shared-data/js/cypressUtils' +import { + navigateToUrl, + fileHelper, + wellBottomImageLocator, +} from '../../support/e2e' +const fileHolder = fileHelper('testpro_15_wellplate_5ul') const importedLabwareFile = 'TestLabwareDefinition.json' describe('File Import', () => { before(() => { - cy.visit('/#/create') - cy.viewport('macbook-15') + navigateToUrl('/#/create') }) it('tests the file import flow', () => { @@ -49,9 +53,9 @@ describe('File Import', () => { // verify well bottom and depth cy.get("input[name='wellBottomShape'][value='flat']").should('exist') - cy.get("img[src*='_flat.']").should('exist') - cy.get("img[src*='_round.']").should('not.exist') - cy.get("img[src*='_v.']").should('not.exist') + cy.get(wellBottomImageLocator.flat).should('exist') + cy.get(wellBottomImageLocator.round).should('not.exist') + cy.get(wellBottomImageLocator.v).should('not.exist') cy.get("input[name='wellDepth'][value='5']").should('exist') // verify grid spacing @@ -69,7 +73,9 @@ describe('File Import', () => { // File info cy.get("input[placeholder='TestPro 15 Well Plate 5 µL']").should('exist') - cy.get("input[placeholder='testpro_15_wellplate_5ul']").should('exist') + cy.get(`input[placeholder='${fileHolder.downloadFileStem}']`).should( + 'exist' + ) // All fields present cy.get('button[class*="_export_button_"]').click({ force: true }) @@ -77,20 +83,12 @@ describe('File Import', () => { 'Please resolve all invalid fields in order to export the labware definition' ).should('not.exist') - cy.fixture(importedLabwareFile).then(expected => { - cy.window() - .its('__lastSavedFileBlob__') - .should('be.a', 'blob') // wait until we get the blob - .should(async blob => { - const labwareDefText = await blob.text() - const savedDef = JSON.parse(labwareDefText) - - expectDeepEqual(assert, savedDef, expected) + cy.fixture(fileHolder.expectedExportFixture).then( + expectedExportLabwareDef => { + cy.readFile(fileHolder.downloadPath).then(actualExportLabwareDef => { + expect(actualExportLabwareDef).to.deep.equal(expectedExportLabwareDef) }) - }) - - cy.window() - .its('__lastSavedFileName__') - .should('equal', 'testpro_15_wellplate_5ul.json') + } + ) }) }) diff --git a/labware-library/cypress/e2e/labware-creator/reservoir.cy.js b/labware-library/cypress/e2e/labware-creator/reservoir.cy.js index 75197208859..32b18c88a40 100644 --- a/labware-library/cypress/e2e/labware-creator/reservoir.cy.js +++ b/labware-library/cypress/e2e/labware-creator/reservoir.cy.js @@ -1,11 +1,13 @@ -// Scrolling seems wonky, so I disabled checking to see if -// an element is in view before clicking or checking with -// { force: true } +import { + navigateToUrl, + fileHelper, + wellBottomImageLocator, +} from '../../support/e2e' +const fileHolder = fileHelper('testpro_10_reservoir_250ul') context('Reservoirs', () => { before(() => { - cy.visit('/#/create') - cy.viewport('macbook-15') + navigateToUrl('/#/create') }) describe('Reservoir', () => { @@ -143,21 +145,21 @@ context('Reservoirs', () => { cy.get("input[name='wellBottomShape'][value='flat']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('exist') - cy.get("img[src*='_round.']").should('not.exist') - cy.get("img[src*='_v.']").should('not.exist') + cy.get(wellBottomImageLocator.flat).should('exist') + cy.get(wellBottomImageLocator.round).should('not.exist') + cy.get(wellBottomImageLocator.v).should('not.exist') cy.get("input[name='wellBottomShape'][value='u']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('not.exist') - cy.get("img[src*='_round.']").should('exist') - cy.get("img[src*='_v.']").should('not.exist') + cy.get(wellBottomImageLocator.flat).should('not.exist') + cy.get(wellBottomImageLocator.round).should('exist') + cy.get(wellBottomImageLocator.v).should('not.exist') cy.get("input[name='wellBottomShape'][value='v']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('not.exist') - cy.get("img[src*='_round.']").should('not.exist') - cy.get("img[src*='_v.']").should('exist') + cy.get(wellBottomImageLocator.flat).should('not.exist') + cy.get(wellBottomImageLocator.round).should('not.exist') + cy.get(wellBottomImageLocator.v).should('exist') cy.get("input[name='wellDepth']").focus().blur() cy.contains('Depth is a required field').should('exist') cy.get("input[name='wellDepth']").type('70').blur() @@ -198,13 +200,24 @@ context('Reservoirs', () => { // File info cy.get("input[placeholder='TestPro 10 Reservoir 250 µL']").should('exist') - cy.get("input[placeholder='testpro_10_reservoir_250ul']").should('exist') + cy.get(`input[placeholder='${fileHolder.downloadFileStem}']`).should( + 'exist' + ) // All fields present cy.get('button[class*="_export_button_"]').click({ force: true }) cy.contains( 'Please resolve all invalid fields in order to export the labware definition' ).should('not.exist') + cy.fixture(fileHolder.expectedExportFixture).then( + expectedExportLabwareDef => { + cy.readFile(fileHolder.downloadPath).then(actualExportLabwareDef => { + expect(actualExportLabwareDef).to.deep.equal( + expectedExportLabwareDef + ) + }) + } + ) }) }) }) diff --git a/labware-library/cypress/e2e/labware-creator/tipRack.cy.js b/labware-library/cypress/e2e/labware-creator/tipRack.cy.js index e69e3dd7285..4d27a47effc 100644 --- a/labware-library/cypress/e2e/labware-creator/tipRack.cy.js +++ b/labware-library/cypress/e2e/labware-creator/tipRack.cy.js @@ -1,12 +1,9 @@ -import 'cypress-file-upload' -import { expectDeepEqual } from '@opentrons/shared-data/js/cypressUtils' - -const expectedExportFixture = '../fixtures/generic_1_tiprack_20ul.json' +import { navigateToUrl, fileHelper } from '../../support/e2e' +const fileHolder = fileHelper('generic_1_tiprack_20ul') describe('Create a Tip Rack', () => { before(() => { - cy.visit('/#/create') - cy.viewport('macbook-15') + navigateToUrl('/#/create') }) it('Should create a tip rack', () => { // Tip Rack Selection from drop down @@ -242,26 +239,19 @@ describe('Create a Tip Rack', () => { cy.get('input[name="displayName"]') .clear() .type('Brand Chalu 1 Tip Rack 20ul') - cy.get('input[name="loadName"]').clear().type('generic_1_tiprack_20ul') + cy.get('input[name="loadName"]').clear().type(fileHolder.downloadFileStem) // Verify the exported file to the fixture cy.get('button').contains('EXPORT FILE').click() - cy.fixture(expectedExportFixture).then(expectedExportLabwareDef => { - cy.window() - .its('__lastSavedFileBlob__') - .should('be.a', 'blob') - .should(async blob => { - const labwareDefText = await blob.text() - const savedDef = JSON.parse(labwareDefText) - - expectDeepEqual(assert, savedDef, expectedExportLabwareDef) + cy.fixture(fileHolder.expectedExportFixture).then( + expectedExportLabwareDef => { + cy.readFile(fileHolder.downloadPath).then(actualExportLabwareDef => { + expect(actualExportLabwareDef).to.deep.equal(expectedExportLabwareDef) }) - }) + } + ) - cy.window() - .its('__lastSavedFileName__') - .should('equal', `generic_1_tiprack_20ul.json`) // 'verify the too big, too small error cy.get('input[name="gridOffsetY"]').clear().type('24') cy.get('#CheckYourWork span') diff --git a/labware-library/cypress/e2e/labware-creator/tubesBlock.cy.js b/labware-library/cypress/e2e/labware-creator/tubesBlock.cy.js index 66ea8d0dedc..4240342390a 100644 --- a/labware-library/cypress/e2e/labware-creator/tubesBlock.cy.js +++ b/labware-library/cypress/e2e/labware-creator/tubesBlock.cy.js @@ -1,12 +1,13 @@ -// Scrolling seems wonky, so I disabled checking to see if -// an element is in view before clicking or checking with -// { force: true } +import { + navigateToUrl, + fileHelper, + wellBottomImageLocator, +} from '../../support/e2e' +const fileHolder = fileHelper('testpro_24_aluminumblock_10ul') context('Tubes and Block', () => { beforeEach(() => { - cy.visit('/#/create') - cy.viewport('macbook-15') - + navigateToUrl('/#/create') cy.get('label') .contains('What type of labware are you creating?') .children() @@ -106,21 +107,21 @@ context('Tubes and Block', () => { cy.get("input[name='wellBottomShape'][value='flat']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('exist') - cy.get("img[src*='_round.']").should('not.exist') - cy.get("img[src*='_v.']").should('not.exist') + cy.get(wellBottomImageLocator.flat).should('exist') + cy.get(wellBottomImageLocator.round).should('not.exist') + cy.get(wellBottomImageLocator.v).should('not.exist') cy.get("input[name='wellBottomShape'][value='u']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('not.exist') - cy.get("img[src*='_round.']").should('exist') - cy.get("img[src*='_v.']").should('not.exist') + cy.get(wellBottomImageLocator.flat).should('not.exist') + cy.get(wellBottomImageLocator.round).should('exist') + cy.get(wellBottomImageLocator.v).should('not.exist') cy.get("input[name='wellBottomShape'][value='v']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('not.exist') - cy.get("img[src*='_round.']").should('not.exist') - cy.get("img[src*='_v.']").should('exist') + cy.get(wellBottomImageLocator.flat).should('not.exist') + cy.get(wellBottomImageLocator.round).should('not.exist') + cy.get(wellBottomImageLocator.v).should('exist') cy.get("input[name='wellDepth']").focus().blur() cy.contains('Depth is a required field').should('exist') cy.get("input[name='wellDepth']").type('10').blur() @@ -232,21 +233,21 @@ context('Tubes and Block', () => { cy.get("input[name='wellBottomShape'][value='flat']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('exist') - cy.get("img[src*='_round.']").should('not.exist') - cy.get("img[src*='_v.']").should('not.exist') + cy.get(wellBottomImageLocator.flat).should('exist') + cy.get(wellBottomImageLocator.round).should('not.exist') + cy.get(wellBottomImageLocator.v).should('not.exist') cy.get("input[name='wellBottomShape'][value='u']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('not.exist') - cy.get("img[src*='_round.']").should('exist') - cy.get("img[src*='_v.']").should('not.exist') + cy.get(wellBottomImageLocator.flat).should('not.exist') + cy.get(wellBottomImageLocator.round).should('exist') + cy.get(wellBottomImageLocator.v).should('not.exist') cy.get("input[name='wellBottomShape'][value='v']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('not.exist') - cy.get("img[src*='_round.']").should('not.exist') - cy.get("img[src*='_v.']").should('exist') + cy.get(wellBottomImageLocator.flat).should('not.exist') + cy.get(wellBottomImageLocator.round).should('not.exist') + cy.get(wellBottomImageLocator.v).should('exist') cy.get("input[name='wellDepth']").focus().blur() cy.contains('Depth is a required field').should('exist') cy.get("input[name='wellDepth']").type('10').blur() @@ -383,21 +384,21 @@ context('Tubes and Block', () => { cy.get("input[name='wellBottomShape'][value='flat']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('exist') - cy.get("img[src*='_round.']").should('not.exist') - cy.get("img[src*='_v.']").should('not.exist') + cy.get(wellBottomImageLocator.flat).should('exist') + cy.get(wellBottomImageLocator.round).should('not.exist') + cy.get(wellBottomImageLocator.v).should('not.exist') cy.get("input[name='wellBottomShape'][value='u']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('not.exist') - cy.get("img[src*='_round.']").should('exist') - cy.get("img[src*='_v.']").should('not.exist') + cy.get(wellBottomImageLocator.flat).should('not.exist') + cy.get(wellBottomImageLocator.round).should('exist') + cy.get(wellBottomImageLocator.v).should('not.exist') cy.get("input[name='wellBottomShape'][value='v']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('not.exist') - cy.get("img[src*='_round.']").should('not.exist') - cy.get("img[src*='_v.']").should('exist') + cy.get(wellBottomImageLocator.flat).should('not.exist') + cy.get(wellBottomImageLocator.round).should('not.exist') + cy.get(wellBottomImageLocator.v).should('exist') cy.get("input[name='wellDepth']").focus().blur() cy.contains('Depth is a required field').should('exist') cy.get("input[name='wellDepth']").type('10').blur() @@ -445,8 +446,7 @@ context('Tubes and Block', () => { }) it('tests the whole form and file export', () => { - cy.visit('/#/create') - cy.viewport('macbook-15') + navigateToUrl('/#/create') cy.get('label') .contains('What type of labware are you creating?') .children() @@ -533,21 +533,21 @@ context('Tubes and Block', () => { cy.get("input[name='wellBottomShape'][value='flat']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('exist') - cy.get("img[src*='_round.']").should('not.exist') - cy.get("img[src*='_v.']").should('not.exist') + cy.get(wellBottomImageLocator.flat).should('exist') + cy.get(wellBottomImageLocator.round).should('not.exist') + cy.get(wellBottomImageLocator.v).should('not.exist') cy.get("input[name='wellBottomShape'][value='u']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('not.exist') - cy.get("img[src*='_round.']").should('exist') - cy.get("img[src*='_v.']").should('not.exist') + cy.get(wellBottomImageLocator.flat).should('not.exist') + cy.get(wellBottomImageLocator.round).should('exist') + cy.get(wellBottomImageLocator.v).should('not.exist') cy.get("input[name='wellBottomShape'][value='v']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('not.exist') - cy.get("img[src*='_round.']").should('not.exist') - cy.get("img[src*='_v.']").should('exist') + cy.get(wellBottomImageLocator.flat).should('not.exist') + cy.get(wellBottomImageLocator.round).should('not.exist') + cy.get(wellBottomImageLocator.v).should('exist') cy.get("input[name='wellDepth']").focus().blur() cy.contains('Depth is a required field').should('exist') cy.get("input[name='wellDepth']").type('10').blur() @@ -575,7 +575,7 @@ context('Tubes and Block', () => { cy.get("input[placeholder='TestPro 24 Aluminum Block 10 µL']").should( 'exist' ) - cy.get("input[placeholder='testpro_24_aluminumblock_10ul']").should( + cy.get(`input[placeholder='${fileHolder.downloadFileStem}']`).should( 'exist' ) @@ -584,6 +584,18 @@ context('Tubes and Block', () => { cy.contains( 'Please resolve all invalid fields in order to export the labware definition' ).should('not.exist') + + cy.fixture(fileHolder.expectedExportFixture).then( + expectedExportLabwareDef => { + cy.readFile(fileHolder.downloadPath).then( + actualExportLabwareDef => { + expect(actualExportLabwareDef).to.deep.equal( + expectedExportLabwareDef + ) + } + ) + } + ) }) }) }) diff --git a/labware-library/cypress/e2e/labware-creator/tubesRack.cy.js b/labware-library/cypress/e2e/labware-creator/tubesRack.cy.js index 4214f215dc0..0409221b6a4 100644 --- a/labware-library/cypress/e2e/labware-creator/tubesRack.cy.js +++ b/labware-library/cypress/e2e/labware-creator/tubesRack.cy.js @@ -1,12 +1,9 @@ -// Scrolling seems wonky, so I disabled checking to see if -// an element is in view before clicking or checking with -// { force: true } +import { navigateToUrl, wellBottomImageLocator } from '../../support/e2e' context('Tubes and Rack', () => { describe('Six tubes', () => { before(() => { - cy.visit('/#/create') - cy.viewport('macbook-15') + navigateToUrl('/#/create') cy.get('label') .contains('What type of labware are you creating?') .children() @@ -95,21 +92,21 @@ context('Tubes and Rack', () => { cy.get("input[name='wellBottomShape'][value='flat']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('exist') - cy.get("img[src*='_round.']").should('not.exist') - cy.get("img[src*='_v.']").should('not.exist') + cy.get(wellBottomImageLocator.flat).should('exist') + cy.get(wellBottomImageLocator.round).should('not.exist') + cy.get(wellBottomImageLocator.v).should('not.exist') cy.get("input[name='wellBottomShape'][value='u']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('not.exist') - cy.get("img[src*='_round.']").should('exist') - cy.get("img[src*='_v.']").should('not.exist') + cy.get(wellBottomImageLocator.flat).should('not.exist') + cy.get(wellBottomImageLocator.round).should('exist') + cy.get(wellBottomImageLocator.v).should('not.exist') cy.get("input[name='wellBottomShape'][value='v']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('not.exist') - cy.get("img[src*='_round.']").should('not.exist') - cy.get("img[src*='_v.']").should('exist') + cy.get(wellBottomImageLocator.flat).should('not.exist') + cy.get(wellBottomImageLocator.round).should('not.exist') + cy.get(wellBottomImageLocator.v).should('exist') cy.get("input[name='wellDepth']").focus().blur() cy.contains('Depth is a required field').should('exist') cy.get("input[name='wellDepth']").type('10').blur() @@ -137,9 +134,7 @@ context('Tubes and Rack', () => { describe('Fifteen tubes', () => { before(() => { - cy.visit('/#/create') - cy.viewport('macbook-15') - + navigateToUrl('#/create') cy.get('label') .contains('What type of labware are you creating?') .children() @@ -226,21 +221,21 @@ context('Tubes and Rack', () => { cy.get("input[name='wellBottomShape'][value='flat']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('exist') - cy.get("img[src*='_round.']").should('not.exist') - cy.get("img[src*='_v.']").should('not.exist') + cy.get(wellBottomImageLocator.flat).should('exist') + cy.get(wellBottomImageLocator.round).should('not.exist') + cy.get(wellBottomImageLocator.v).should('not.exist') cy.get("input[name='wellBottomShape'][value='u']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('not.exist') - cy.get("img[src*='_round.']").should('exist') - cy.get("img[src*='_v.']").should('not.exist') + cy.get(wellBottomImageLocator.flat).should('not.exist') + cy.get(wellBottomImageLocator.round).should('exist') + cy.get(wellBottomImageLocator.v).should('not.exist') cy.get("input[name='wellBottomShape'][value='v']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('not.exist') - cy.get("img[src*='_round.']").should('not.exist') - cy.get("img[src*='_v.']").should('exist') + cy.get(wellBottomImageLocator.flat).should('not.exist') + cy.get(wellBottomImageLocator.round).should('not.exist') + cy.get(wellBottomImageLocator.v).should('exist') cy.get("input[name='wellDepth']").focus().blur() cy.contains('Depth is a required field').should('exist') cy.get("input[name='wellDepth']").type('10').blur() @@ -268,9 +263,7 @@ context('Tubes and Rack', () => { describe('Twentyfour tubes', () => { before(() => { - cy.visit('/#/create') - cy.viewport('macbook-15') - + navigateToUrl('/#/create') cy.get('label') .contains('What type of labware are you creating?') .children() @@ -356,21 +349,21 @@ context('Tubes and Rack', () => { cy.get("input[name='wellBottomShape'][value='flat']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('exist') - cy.get("img[src*='_round.']").should('not.exist') - cy.get("img[src*='_v.']").should('not.exist') + cy.get(wellBottomImageLocator.flat).should('exist') + cy.get(wellBottomImageLocator.round).should('not.exist') + cy.get(wellBottomImageLocator.v).should('not.exist') cy.get("input[name='wellBottomShape'][value='u']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('not.exist') - cy.get("img[src*='_round.']").should('exist') - cy.get("img[src*='_v.']").should('not.exist') + cy.get(wellBottomImageLocator.flat).should('not.exist') + cy.get(wellBottomImageLocator.round).should('exist') + cy.get(wellBottomImageLocator.v).should('not.exist') cy.get("input[name='wellBottomShape'][value='v']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('not.exist') - cy.get("img[src*='_round.']").should('not.exist') - cy.get("img[src*='_v.']").should('exist') + cy.get(wellBottomImageLocator.flat).should('not.exist') + cy.get(wellBottomImageLocator.round).should('not.exist') + cy.get(wellBottomImageLocator.v).should('exist') cy.get("input[name='wellDepth']").focus().blur() cy.contains('Depth is a required field').should('exist') cy.get("input[name='wellDepth']").type('10').blur() diff --git a/labware-library/cypress/e2e/labware-creator/wellPlate.cy.js b/labware-library/cypress/e2e/labware-creator/wellPlate.cy.js index 5b27cfcfd72..df12cf153a5 100644 --- a/labware-library/cypress/e2e/labware-creator/wellPlate.cy.js +++ b/labware-library/cypress/e2e/labware-creator/wellPlate.cy.js @@ -2,14 +2,16 @@ // that cannot be imported. The creator probably shouldn't allow // a user to do this. -// Scrolling seems wonky, so I disabled checking to see if -// an element is in view before clicking or checking with -// { force: true } +import { + navigateToUrl, + fileHelper, + wellBottomImageLocator, +} from '../../support/e2e' +const fileHolder = fileHelper('testpro_80_wellplate_100ul') context('Well Plates', () => { before(() => { - cy.visit('/#/create') - cy.viewport('macbook-15') + navigateToUrl('/#/create') }) describe('Create a well plate', () => { @@ -145,21 +147,21 @@ context('Well Plates', () => { cy.get("input[name='wellBottomShape'][value='flat']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('exist') - cy.get("img[src*='_round.']").should('not.exist') - cy.get("img[src*='_v.']").should('not.exist') + cy.get(wellBottomImageLocator.flat).should('exist') + cy.get(wellBottomImageLocator.round).should('not.exist') + cy.get(wellBottomImageLocator.v).should('not.exist') cy.get("input[name='wellBottomShape'][value='u']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('not.exist') - cy.get("img[src*='_round.']").should('exist') - cy.get("img[src*='_v.']").should('not.exist') + cy.get(wellBottomImageLocator.flat).should('not.exist') + cy.get(wellBottomImageLocator.round).should('exist') + cy.get(wellBottomImageLocator.v).should('not.exist') cy.get("input[name='wellBottomShape'][value='v']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('not.exist') - cy.get("img[src*='_round.']").should('not.exist') - cy.get("img[src*='_v.']").should('exist') + cy.get(wellBottomImageLocator.flat).should('not.exist') + cy.get(wellBottomImageLocator.round).should('not.exist') + cy.get(wellBottomImageLocator.v).should('exist') cy.get("input[name='wellDepth']").focus().blur() cy.contains('Depth is a required field').should('exist') cy.get("input[name='wellDepth']").type('10').blur() @@ -208,7 +210,9 @@ context('Well Plates', () => { cy.get("input[placeholder='TestPro 80 Well Plate 100 µL']").should( 'exist' ) - cy.get("input[placeholder='testpro_80_wellplate_100ul']").should('exist') + cy.get(`input[placeholder='${fileHolder.downloadFileStem}']`).should( + 'exist' + ) // All fields present cy.get('button[class*="_export_button_"]').click({ force: true }) @@ -216,7 +220,15 @@ context('Well Plates', () => { 'Please resolve all invalid fields in order to export the labware definition' ).should('not.exist') - // TODO IMMEDIATELY match against fixture ??? Is this not happening? + cy.fixture(fileHolder.expectedExportFixture).then( + expectedExportLabwareDef => { + cy.readFile(fileHolder.downloadPath).then(actualExportLabwareDef => { + expect(actualExportLabwareDef).to.deep.equal( + expectedExportLabwareDef + ) + }) + } + ) }) }) }) diff --git a/labware-library/cypress/e2e/navigation.cy.js b/labware-library/cypress/e2e/navigation.cy.js index 83ce2dd7369..0b4c3c14a40 100644 --- a/labware-library/cypress/e2e/navigation.cy.js +++ b/labware-library/cypress/e2e/navigation.cy.js @@ -1,7 +1,8 @@ +import { navigateToUrl } from '../support/e2e' + describe('Desktop Navigation', () => { beforeEach(() => { - cy.visit('/') - cy.viewport('macbook-15') + navigateToUrl('/') }) it('contains the subdomain nav bar', () => { diff --git a/labware-library/cypress/fixtures/testpro_10_reservoir_250ul.json b/labware-library/cypress/fixtures/testpro_10_reservoir_250ul.json new file mode 100644 index 00000000000..5941e1b3e5e --- /dev/null +++ b/labware-library/cypress/fixtures/testpro_10_reservoir_250ul.json @@ -0,0 +1,154 @@ +{ + "ordering": [ + ["A1"], + ["A2"], + ["A3"], + ["A4"], + ["A5"], + ["A6"], + ["A7"], + ["A8"], + ["A9"], + ["A10"] + ], + "brand": { + "brand": "TestPro", + "brandId": ["001"] + }, + "metadata": { + "displayName": "TestPro 10 Reservoir 250 µL", + "displayCategory": "reservoir", + "displayVolumeUnits": "µL", + "tags": [] + }, + "dimensions": { + "xDimension": 127, + "yDimension": 85, + "zDimension": 75 + }, + "wells": { + "A1": { + "depth": 70, + "totalLiquidVolume": 250, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 60, + "x": 10, + "y": 40, + "z": 5 + }, + "A2": { + "depth": 70, + "totalLiquidVolume": 250, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 60, + "x": 22, + "y": 40, + "z": 5 + }, + "A3": { + "depth": 70, + "totalLiquidVolume": 250, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 60, + "x": 34, + "y": 40, + "z": 5 + }, + "A4": { + "depth": 70, + "totalLiquidVolume": 250, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 60, + "x": 46, + "y": 40, + "z": 5 + }, + "A5": { + "depth": 70, + "totalLiquidVolume": 250, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 60, + "x": 58, + "y": 40, + "z": 5 + }, + "A6": { + "depth": 70, + "totalLiquidVolume": 250, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 60, + "x": 70, + "y": 40, + "z": 5 + }, + "A7": { + "depth": 70, + "totalLiquidVolume": 250, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 60, + "x": 82, + "y": 40, + "z": 5 + }, + "A8": { + "depth": 70, + "totalLiquidVolume": 250, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 60, + "x": 94, + "y": 40, + "z": 5 + }, + "A9": { + "depth": 70, + "totalLiquidVolume": 250, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 60, + "x": 106, + "y": 40, + "z": 5 + }, + "A10": { + "depth": 70, + "totalLiquidVolume": 250, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 60, + "x": 118, + "y": 40, + "z": 5 + } + }, + "groups": [ + { + "metadata": { + "wellBottomShape": "v" + }, + "wells": ["A1", "A2", "A3", "A4", "A5", "A6", "A7", "A8", "A9", "A10"] + } + ], + "parameters": { + "format": "irregular", + "quirks": [], + "isTiprack": false, + "isMagneticModuleCompatible": false, + "loadName": "testpro_10_reservoir_250ul" + }, + "namespace": "custom_beta", + "version": 1, + "schemaVersion": 2, + "cornerOffsetFromSlot": { + "x": 0, + "y": 0, + "z": 0 + } +} diff --git a/labware-library/cypress/fixtures/testpro_15_wellplate_5ul.json b/labware-library/cypress/fixtures/testpro_15_wellplate_5ul.json new file mode 100644 index 00000000000..6eac7bd5fc6 --- /dev/null +++ b/labware-library/cypress/fixtures/testpro_15_wellplate_5ul.json @@ -0,0 +1,200 @@ +{ + "ordering": [ + ["A1", "B1", "C1"], + ["A2", "B2", "C2"], + ["A3", "B3", "C3"], + ["A4", "B4", "C4"], + ["A5", "B5", "C5"] + ], + "brand": { + "brand": "TestPro", + "brandId": ["001"] + }, + "metadata": { + "displayName": "TestPro 15 Well Plate 5 µL", + "displayCategory": "wellPlate", + "displayVolumeUnits": "µL", + "tags": [] + }, + "dimensions": { + "xDimension": 127, + "yDimension": 85, + "zDimension": 5 + }, + "wells": { + "A1": { + "depth": 5, + "totalLiquidVolume": 5, + "shape": "circular", + "diameter": 5, + "x": 10, + "y": 75, + "z": 0 + }, + "B1": { + "depth": 5, + "totalLiquidVolume": 5, + "shape": "circular", + "diameter": 5, + "x": 10, + "y": 50, + "z": 0 + }, + "C1": { + "depth": 5, + "totalLiquidVolume": 5, + "shape": "circular", + "diameter": 5, + "x": 10, + "y": 25, + "z": 0 + }, + "A2": { + "depth": 5, + "totalLiquidVolume": 5, + "shape": "circular", + "diameter": 5, + "x": 35, + "y": 75, + "z": 0 + }, + "B2": { + "depth": 5, + "totalLiquidVolume": 5, + "shape": "circular", + "diameter": 5, + "x": 35, + "y": 50, + "z": 0 + }, + "C2": { + "depth": 5, + "totalLiquidVolume": 5, + "shape": "circular", + "diameter": 5, + "x": 35, + "y": 25, + "z": 0 + }, + "A3": { + "depth": 5, + "totalLiquidVolume": 5, + "shape": "circular", + "diameter": 5, + "x": 60, + "y": 75, + "z": 0 + }, + "B3": { + "depth": 5, + "totalLiquidVolume": 5, + "shape": "circular", + "diameter": 5, + "x": 60, + "y": 50, + "z": 0 + }, + "C3": { + "depth": 5, + "totalLiquidVolume": 5, + "shape": "circular", + "diameter": 5, + "x": 60, + "y": 25, + "z": 0 + }, + "A4": { + "depth": 5, + "totalLiquidVolume": 5, + "shape": "circular", + "diameter": 5, + "x": 85, + "y": 75, + "z": 0 + }, + "B4": { + "depth": 5, + "totalLiquidVolume": 5, + "shape": "circular", + "diameter": 5, + "x": 85, + "y": 50, + "z": 0 + }, + "C4": { + "depth": 5, + "totalLiquidVolume": 5, + "shape": "circular", + "diameter": 5, + "x": 85, + "y": 25, + "z": 0 + }, + "A5": { + "depth": 5, + "totalLiquidVolume": 5, + "shape": "circular", + "diameter": 5, + "x": 110, + "y": 75, + "z": 0 + }, + "B5": { + "depth": 5, + "totalLiquidVolume": 5, + "shape": "circular", + "diameter": 5, + "x": 110, + "y": 50, + "z": 0 + }, + "C5": { + "depth": 5, + "totalLiquidVolume": 5, + "shape": "circular", + "diameter": 5, + "x": 110, + "y": 25, + "z": 0 + } + }, + "groups": [ + { + "metadata": { + "wellBottomShape": "flat" + }, + "wells": [ + "A1", + "B1", + "C1", + "A2", + "B2", + "C2", + "A3", + "B3", + "C3", + "A4", + "B4", + "C4", + "A5", + "B5", + "C5" + ] + } + ], + "parameters": { + "format": "irregular", + "quirks": [], + "isTiprack": false, + "isMagneticModuleCompatible": false, + "loadName": "testpro_15_wellplate_5ul" + }, + "namespace": "custom_beta", + "version": 1, + "schemaVersion": 2, + "cornerOffsetFromSlot": { + "x": 0, + "y": 0, + "z": 0 + } +} diff --git a/labware-library/cypress/fixtures/testpro_24_aluminumblock_10ul.json b/labware-library/cypress/fixtures/testpro_24_aluminumblock_10ul.json new file mode 100644 index 00000000000..d653e918f90 --- /dev/null +++ b/labware-library/cypress/fixtures/testpro_24_aluminumblock_10ul.json @@ -0,0 +1,316 @@ +{ + "ordering": [ + ["A1", "B1", "C1", "D1"], + ["A2", "B2", "C2", "D2"], + ["A3", "B3", "C3", "D3"], + ["A4", "B4", "C4", "D4"], + ["A5", "B5", "C5", "D5"], + ["A6", "B6", "C6", "D6"] + ], + "brand": { + "brand": "TestPro", + "brandId": ["001"] + }, + "metadata": { + "displayName": "TestPro 24 Aluminum Block 10 µL", + "displayCategory": "aluminumBlock", + "displayVolumeUnits": "µL", + "tags": [] + }, + "dimensions": { + "xDimension": 127.75, + "yDimension": 85.5, + "zDimension": 75 + }, + "wells": { + "A1": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 20.75, + "y": 68.63, + "z": 65 + }, + "B1": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 20.75, + "y": 51.38, + "z": 65 + }, + "C1": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 20.75, + "y": 34.13, + "z": 65 + }, + "D1": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 20.75, + "y": 16.88, + "z": 65 + }, + "A2": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 38, + "y": 68.63, + "z": 65 + }, + "B2": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 38, + "y": 51.38, + "z": 65 + }, + "C2": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 38, + "y": 34.13, + "z": 65 + }, + "D2": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 38, + "y": 16.88, + "z": 65 + }, + "A3": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 55.25, + "y": 68.63, + "z": 65 + }, + "B3": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 55.25, + "y": 51.38, + "z": 65 + }, + "C3": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 55.25, + "y": 34.13, + "z": 65 + }, + "D3": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 55.25, + "y": 16.88, + "z": 65 + }, + "A4": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 72.5, + "y": 68.63, + "z": 65 + }, + "B4": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 72.5, + "y": 51.38, + "z": 65 + }, + "C4": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 72.5, + "y": 34.13, + "z": 65 + }, + "D4": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 72.5, + "y": 16.88, + "z": 65 + }, + "A5": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 89.75, + "y": 68.63, + "z": 65 + }, + "B5": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 89.75, + "y": 51.38, + "z": 65 + }, + "C5": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 89.75, + "y": 34.13, + "z": 65 + }, + "D5": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 89.75, + "y": 16.88, + "z": 65 + }, + "A6": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 107, + "y": 68.63, + "z": 65 + }, + "B6": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 107, + "y": 51.38, + "z": 65 + }, + "C6": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 107, + "y": 34.13, + "z": 65 + }, + "D6": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 107, + "y": 16.88, + "z": 65 + } + }, + "groups": [ + { + "metadata": { + "wellBottomShape": "v", + "displayCategory": "tubeRack" + }, + "wells": [ + "A1", + "B1", + "C1", + "D1", + "A2", + "B2", + "C2", + "D2", + "A3", + "B3", + "C3", + "D3", + "A4", + "B4", + "C4", + "D4", + "A5", + "B5", + "C5", + "D5", + "A6", + "B6", + "C6", + "D6" + ] + } + ], + "parameters": { + "format": "irregular", + "quirks": [], + "isTiprack": false, + "isMagneticModuleCompatible": false, + "loadName": "testpro_24_aluminumblock_10ul" + }, + "namespace": "custom_beta", + "version": 1, + "schemaVersion": 2, + "cornerOffsetFromSlot": { + "x": 0, + "y": 0, + "z": 0 + } +} diff --git a/labware-library/cypress/fixtures/testpro_80_wellplate_100ul.json b/labware-library/cypress/fixtures/testpro_80_wellplate_100ul.json new file mode 100644 index 00000000000..f51b575836a --- /dev/null +++ b/labware-library/cypress/fixtures/testpro_80_wellplate_100ul.json @@ -0,0 +1,935 @@ +{ + "ordering": [ + ["A1", "B1", "C1", "D1", "E1", "F1", "G1", "H1"], + ["A2", "B2", "C2", "D2", "E2", "F2", "G2", "H2"], + ["A3", "B3", "C3", "D3", "E3", "F3", "G3", "H3"], + ["A4", "B4", "C4", "D4", "E4", "F4", "G4", "H4"], + ["A5", "B5", "C5", "D5", "E5", "F5", "G5", "H5"], + ["A6", "B6", "C6", "D6", "E6", "F6", "G6", "H6"], + ["A7", "B7", "C7", "D7", "E7", "F7", "G7", "H7"], + ["A8", "B8", "C8", "D8", "E8", "F8", "G8", "H8"], + ["A9", "B9", "C9", "D9", "E9", "F9", "G9", "H9"], + ["A10", "B10", "C10", "D10", "E10", "F10", "G10", "H10"] + ], + "brand": { + "brand": "TestPro", + "brandId": ["001"] + }, + "metadata": { + "displayName": "TestPro 80 Well Plate 100 µL", + "displayCategory": "wellPlate", + "displayVolumeUnits": "µL", + "tags": [] + }, + "dimensions": { + "xDimension": 127, + "yDimension": 85, + "zDimension": 75 + }, + "wells": { + "A1": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 10, + "y": 77, + "z": 65 + }, + "B1": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 10, + "y": 67, + "z": 65 + }, + "C1": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 10, + "y": 57, + "z": 65 + }, + "D1": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 10, + "y": 47, + "z": 65 + }, + "E1": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 10, + "y": 37, + "z": 65 + }, + "F1": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 10, + "y": 27, + "z": 65 + }, + "G1": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 10, + "y": 17, + "z": 65 + }, + "H1": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 10, + "y": 7, + "z": 65 + }, + "A2": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 22, + "y": 77, + "z": 65 + }, + "B2": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 22, + "y": 67, + "z": 65 + }, + "C2": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 22, + "y": 57, + "z": 65 + }, + "D2": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 22, + "y": 47, + "z": 65 + }, + "E2": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 22, + "y": 37, + "z": 65 + }, + "F2": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 22, + "y": 27, + "z": 65 + }, + "G2": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 22, + "y": 17, + "z": 65 + }, + "H2": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 22, + "y": 7, + "z": 65 + }, + "A3": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 34, + "y": 77, + "z": 65 + }, + "B3": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 34, + "y": 67, + "z": 65 + }, + "C3": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 34, + "y": 57, + "z": 65 + }, + "D3": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 34, + "y": 47, + "z": 65 + }, + "E3": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 34, + "y": 37, + "z": 65 + }, + "F3": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 34, + "y": 27, + "z": 65 + }, + "G3": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 34, + "y": 17, + "z": 65 + }, + "H3": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 34, + "y": 7, + "z": 65 + }, + "A4": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 46, + "y": 77, + "z": 65 + }, + "B4": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 46, + "y": 67, + "z": 65 + }, + "C4": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 46, + "y": 57, + "z": 65 + }, + "D4": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 46, + "y": 47, + "z": 65 + }, + "E4": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 46, + "y": 37, + "z": 65 + }, + "F4": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 46, + "y": 27, + "z": 65 + }, + "G4": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 46, + "y": 17, + "z": 65 + }, + "H4": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 46, + "y": 7, + "z": 65 + }, + "A5": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 58, + "y": 77, + "z": 65 + }, + "B5": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 58, + "y": 67, + "z": 65 + }, + "C5": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 58, + "y": 57, + "z": 65 + }, + "D5": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 58, + "y": 47, + "z": 65 + }, + "E5": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 58, + "y": 37, + "z": 65 + }, + "F5": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 58, + "y": 27, + "z": 65 + }, + "G5": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 58, + "y": 17, + "z": 65 + }, + "H5": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 58, + "y": 7, + "z": 65 + }, + "A6": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 70, + "y": 77, + "z": 65 + }, + "B6": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 70, + "y": 67, + "z": 65 + }, + "C6": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 70, + "y": 57, + "z": 65 + }, + "D6": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 70, + "y": 47, + "z": 65 + }, + "E6": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 70, + "y": 37, + "z": 65 + }, + "F6": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 70, + "y": 27, + "z": 65 + }, + "G6": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 70, + "y": 17, + "z": 65 + }, + "H6": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 70, + "y": 7, + "z": 65 + }, + "A7": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 82, + "y": 77, + "z": 65 + }, + "B7": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 82, + "y": 67, + "z": 65 + }, + "C7": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 82, + "y": 57, + "z": 65 + }, + "D7": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 82, + "y": 47, + "z": 65 + }, + "E7": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 82, + "y": 37, + "z": 65 + }, + "F7": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 82, + "y": 27, + "z": 65 + }, + "G7": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 82, + "y": 17, + "z": 65 + }, + "H7": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 82, + "y": 7, + "z": 65 + }, + "A8": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 94, + "y": 77, + "z": 65 + }, + "B8": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 94, + "y": 67, + "z": 65 + }, + "C8": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 94, + "y": 57, + "z": 65 + }, + "D8": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 94, + "y": 47, + "z": 65 + }, + "E8": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 94, + "y": 37, + "z": 65 + }, + "F8": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 94, + "y": 27, + "z": 65 + }, + "G8": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 94, + "y": 17, + "z": 65 + }, + "H8": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 94, + "y": 7, + "z": 65 + }, + "A9": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 106, + "y": 77, + "z": 65 + }, + "B9": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 106, + "y": 67, + "z": 65 + }, + "C9": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 106, + "y": 57, + "z": 65 + }, + "D9": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 106, + "y": 47, + "z": 65 + }, + "E9": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 106, + "y": 37, + "z": 65 + }, + "F9": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 106, + "y": 27, + "z": 65 + }, + "G9": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 106, + "y": 17, + "z": 65 + }, + "H9": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 106, + "y": 7, + "z": 65 + }, + "A10": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 118, + "y": 77, + "z": 65 + }, + "B10": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 118, + "y": 67, + "z": 65 + }, + "C10": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 118, + "y": 57, + "z": 65 + }, + "D10": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 118, + "y": 47, + "z": 65 + }, + "E10": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 118, + "y": 37, + "z": 65 + }, + "F10": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 118, + "y": 27, + "z": 65 + }, + "G10": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 118, + "y": 17, + "z": 65 + }, + "H10": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 118, + "y": 7, + "z": 65 + } + }, + "groups": [ + { + "metadata": { + "wellBottomShape": "v" + }, + "wells": [ + "A1", + "B1", + "C1", + "D1", + "E1", + "F1", + "G1", + "H1", + "A2", + "B2", + "C2", + "D2", + "E2", + "F2", + "G2", + "H2", + "A3", + "B3", + "C3", + "D3", + "E3", + "F3", + "G3", + "H3", + "A4", + "B4", + "C4", + "D4", + "E4", + "F4", + "G4", + "H4", + "A5", + "B5", + "C5", + "D5", + "E5", + "F5", + "G5", + "H5", + "A6", + "B6", + "C6", + "D6", + "E6", + "F6", + "G6", + "H6", + "A7", + "B7", + "C7", + "D7", + "E7", + "F7", + "G7", + "H7", + "A8", + "B8", + "C8", + "D8", + "E8", + "F8", + "G8", + "H8", + "A9", + "B9", + "C9", + "D9", + "E9", + "F9", + "G9", + "H9", + "A10", + "B10", + "C10", + "D10", + "E10", + "F10", + "G10", + "H10" + ] + } + ], + "parameters": { + "format": "irregular", + "quirks": [], + "isTiprack": false, + "isMagneticModuleCompatible": false, + "loadName": "testpro_80_wellplate_100ul" + }, + "namespace": "custom_beta", + "version": 1, + "schemaVersion": 2, + "cornerOffsetFromSlot": { + "x": 0, + "y": 0, + "z": 0 + } +} diff --git a/labware-library/cypress/fixtures/testpro_96_aluminumblock_10ul.json b/labware-library/cypress/fixtures/testpro_96_aluminumblock_10ul.json new file mode 100644 index 00000000000..0b99d24def6 --- /dev/null +++ b/labware-library/cypress/fixtures/testpro_96_aluminumblock_10ul.json @@ -0,0 +1,1114 @@ +{ + "ordering": [ + ["A1", "B1", "C1", "D1", "E1", "F1", "G1", "H1"], + ["A2", "B2", "C2", "D2", "E2", "F2", "G2", "H2"], + ["A3", "B3", "C3", "D3", "E3", "F3", "G3", "H3"], + ["A4", "B4", "C4", "D4", "E4", "F4", "G4", "H4"], + ["A5", "B5", "C5", "D5", "E5", "F5", "G5", "H5"], + ["A6", "B6", "C6", "D6", "E6", "F6", "G6", "H6"], + ["A7", "B7", "C7", "D7", "E7", "F7", "G7", "H7"], + ["A8", "B8", "C8", "D8", "E8", "F8", "G8", "H8"], + ["A9", "B9", "C9", "D9", "E9", "F9", "G9", "H9"], + ["A10", "B10", "C10", "D10", "E10", "F10", "G10", "H10"], + ["A11", "B11", "C11", "D11", "E11", "F11", "G11", "H11"], + ["A12", "B12", "C12", "D12", "E12", "F12", "G12", "H12"] + ], + "brand": { + "brand": "TestPro", + "brandId": ["001"] + }, + "metadata": { + "displayName": "TestPro 96 Aluminum Block 10 µL", + "displayCategory": "aluminumBlock", + "displayVolumeUnits": "µL", + "tags": [] + }, + "dimensions": { + "xDimension": 127.75, + "yDimension": 85.5, + "zDimension": 75 + }, + "wells": { + "A1": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 14.38, + "y": 74.25, + "z": 65 + }, + "B1": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 14.38, + "y": 65.25, + "z": 65 + }, + "C1": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 14.38, + "y": 56.25, + "z": 65 + }, + "D1": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 14.38, + "y": 47.25, + "z": 65 + }, + "E1": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 14.38, + "y": 38.25, + "z": 65 + }, + "F1": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 14.38, + "y": 29.25, + "z": 65 + }, + "G1": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 14.38, + "y": 20.25, + "z": 65 + }, + "H1": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 14.38, + "y": 11.25, + "z": 65 + }, + "A2": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 23.38, + "y": 74.25, + "z": 65 + }, + "B2": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 23.38, + "y": 65.25, + "z": 65 + }, + "C2": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 23.38, + "y": 56.25, + "z": 65 + }, + "D2": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 23.38, + "y": 47.25, + "z": 65 + }, + "E2": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 23.38, + "y": 38.25, + "z": 65 + }, + "F2": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 23.38, + "y": 29.25, + "z": 65 + }, + "G2": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 23.38, + "y": 20.25, + "z": 65 + }, + "H2": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 23.38, + "y": 11.25, + "z": 65 + }, + "A3": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 32.38, + "y": 74.25, + "z": 65 + }, + "B3": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 32.38, + "y": 65.25, + "z": 65 + }, + "C3": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 32.38, + "y": 56.25, + "z": 65 + }, + "D3": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 32.38, + "y": 47.25, + "z": 65 + }, + "E3": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 32.38, + "y": 38.25, + "z": 65 + }, + "F3": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 32.38, + "y": 29.25, + "z": 65 + }, + "G3": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 32.38, + "y": 20.25, + "z": 65 + }, + "H3": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 32.38, + "y": 11.25, + "z": 65 + }, + "A4": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 41.38, + "y": 74.25, + "z": 65 + }, + "B4": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 41.38, + "y": 65.25, + "z": 65 + }, + "C4": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 41.38, + "y": 56.25, + "z": 65 + }, + "D4": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 41.38, + "y": 47.25, + "z": 65 + }, + "E4": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 41.38, + "y": 38.25, + "z": 65 + }, + "F4": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 41.38, + "y": 29.25, + "z": 65 + }, + "G4": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 41.38, + "y": 20.25, + "z": 65 + }, + "H4": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 41.38, + "y": 11.25, + "z": 65 + }, + "A5": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 50.38, + "y": 74.25, + "z": 65 + }, + "B5": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 50.38, + "y": 65.25, + "z": 65 + }, + "C5": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 50.38, + "y": 56.25, + "z": 65 + }, + "D5": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 50.38, + "y": 47.25, + "z": 65 + }, + "E5": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 50.38, + "y": 38.25, + "z": 65 + }, + "F5": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 50.38, + "y": 29.25, + "z": 65 + }, + "G5": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 50.38, + "y": 20.25, + "z": 65 + }, + "H5": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 50.38, + "y": 11.25, + "z": 65 + }, + "A6": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 59.38, + "y": 74.25, + "z": 65 + }, + "B6": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 59.38, + "y": 65.25, + "z": 65 + }, + "C6": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 59.38, + "y": 56.25, + "z": 65 + }, + "D6": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 59.38, + "y": 47.25, + "z": 65 + }, + "E6": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 59.38, + "y": 38.25, + "z": 65 + }, + "F6": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 59.38, + "y": 29.25, + "z": 65 + }, + "G6": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 59.38, + "y": 20.25, + "z": 65 + }, + "H6": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 59.38, + "y": 11.25, + "z": 65 + }, + "A7": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 68.38, + "y": 74.25, + "z": 65 + }, + "B7": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 68.38, + "y": 65.25, + "z": 65 + }, + "C7": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 68.38, + "y": 56.25, + "z": 65 + }, + "D7": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 68.38, + "y": 47.25, + "z": 65 + }, + "E7": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 68.38, + "y": 38.25, + "z": 65 + }, + "F7": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 68.38, + "y": 29.25, + "z": 65 + }, + "G7": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 68.38, + "y": 20.25, + "z": 65 + }, + "H7": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 68.38, + "y": 11.25, + "z": 65 + }, + "A8": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 77.38, + "y": 74.25, + "z": 65 + }, + "B8": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 77.38, + "y": 65.25, + "z": 65 + }, + "C8": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 77.38, + "y": 56.25, + "z": 65 + }, + "D8": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 77.38, + "y": 47.25, + "z": 65 + }, + "E8": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 77.38, + "y": 38.25, + "z": 65 + }, + "F8": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 77.38, + "y": 29.25, + "z": 65 + }, + "G8": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 77.38, + "y": 20.25, + "z": 65 + }, + "H8": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 77.38, + "y": 11.25, + "z": 65 + }, + "A9": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 86.38, + "y": 74.25, + "z": 65 + }, + "B9": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 86.38, + "y": 65.25, + "z": 65 + }, + "C9": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 86.38, + "y": 56.25, + "z": 65 + }, + "D9": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 86.38, + "y": 47.25, + "z": 65 + }, + "E9": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 86.38, + "y": 38.25, + "z": 65 + }, + "F9": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 86.38, + "y": 29.25, + "z": 65 + }, + "G9": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 86.38, + "y": 20.25, + "z": 65 + }, + "H9": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 86.38, + "y": 11.25, + "z": 65 + }, + "A10": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 95.38, + "y": 74.25, + "z": 65 + }, + "B10": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 95.38, + "y": 65.25, + "z": 65 + }, + "C10": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 95.38, + "y": 56.25, + "z": 65 + }, + "D10": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 95.38, + "y": 47.25, + "z": 65 + }, + "E10": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 95.38, + "y": 38.25, + "z": 65 + }, + "F10": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 95.38, + "y": 29.25, + "z": 65 + }, + "G10": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 95.38, + "y": 20.25, + "z": 65 + }, + "H10": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 95.38, + "y": 11.25, + "z": 65 + }, + "A11": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 104.38, + "y": 74.25, + "z": 65 + }, + "B11": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 104.38, + "y": 65.25, + "z": 65 + }, + "C11": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 104.38, + "y": 56.25, + "z": 65 + }, + "D11": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 104.38, + "y": 47.25, + "z": 65 + }, + "E11": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 104.38, + "y": 38.25, + "z": 65 + }, + "F11": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 104.38, + "y": 29.25, + "z": 65 + }, + "G11": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 104.38, + "y": 20.25, + "z": 65 + }, + "H11": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 104.38, + "y": 11.25, + "z": 65 + }, + "A12": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 113.38, + "y": 74.25, + "z": 65 + }, + "B12": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 113.38, + "y": 65.25, + "z": 65 + }, + "C12": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 113.38, + "y": 56.25, + "z": 65 + }, + "D12": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 113.38, + "y": 47.25, + "z": 65 + }, + "E12": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 113.38, + "y": 38.25, + "z": 65 + }, + "F12": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 113.38, + "y": 29.25, + "z": 65 + }, + "G12": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 113.38, + "y": 20.25, + "z": 65 + }, + "H12": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 113.38, + "y": 11.25, + "z": 65 + } + }, + "groups": [ + { + "metadata": { + "wellBottomShape": "v", + "displayCategory": "wellPlate" + }, + "wells": [ + "A1", + "B1", + "C1", + "D1", + "E1", + "F1", + "G1", + "H1", + "A2", + "B2", + "C2", + "D2", + "E2", + "F2", + "G2", + "H2", + "A3", + "B3", + "C3", + "D3", + "E3", + "F3", + "G3", + "H3", + "A4", + "B4", + "C4", + "D4", + "E4", + "F4", + "G4", + "H4", + "A5", + "B5", + "C5", + "D5", + "E5", + "F5", + "G5", + "H5", + "A6", + "B6", + "C6", + "D6", + "E6", + "F6", + "G6", + "H6", + "A7", + "B7", + "C7", + "D7", + "E7", + "F7", + "G7", + "H7", + "A8", + "B8", + "C8", + "D8", + "E8", + "F8", + "G8", + "H8", + "A9", + "B9", + "C9", + "D9", + "E9", + "F9", + "G9", + "H9", + "A10", + "B10", + "C10", + "D10", + "E10", + "F10", + "G10", + "H10", + "A11", + "B11", + "C11", + "D11", + "E11", + "F11", + "G11", + "H11", + "A12", + "B12", + "C12", + "D12", + "E12", + "F12", + "G12", + "H12" + ] + } + ], + "parameters": { + "format": "irregular", + "quirks": [], + "isTiprack": false, + "isMagneticModuleCompatible": false, + "loadName": "testpro_96_aluminumblock_10ul" + }, + "namespace": "custom_beta", + "version": 1, + "schemaVersion": 2, + "cornerOffsetFromSlot": { + "x": 0, + "y": 0, + "z": 0 + } +} diff --git a/labware-library/cypress/mocks/file-saver.js b/labware-library/cypress/mocks/file-saver.js deleted file mode 100644 index d4c7febe539..00000000000 --- a/labware-library/cypress/mocks/file-saver.js +++ /dev/null @@ -1,6 +0,0 @@ -// mock for 'file-saver' npm module - -export const saveAs = (blob, fileName) => { - global.__lastSavedFileBlob__ = blob - global.__lastSavedFileName__ = fileName -} diff --git a/labware-library/cypress/plugins/index.js b/labware-library/cypress/plugins/index.js deleted file mode 100644 index f392875c7d9..00000000000 --- a/labware-library/cypress/plugins/index.js +++ /dev/null @@ -1,23 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/triple-slash-reference -/// -// *********************************************************** -// This example plugins/index.js can be used to load plugins -// -// You can change the location of this file or turn off loading -// the plugins file with the 'pluginsFile' configuration option. -// -// You can read more here: -// https://on.cypress.io/plugins-guide -// *********************************************************** - -// This function is called when a project is opened or re-opened (e.g. due to -// the project's config changing) - -/** - * @type {Cypress.PluginConfig} - */ -// eslint-disable-next-line no-unused-vars -module.exports = (on, config) => { - // `on` is used to hook into various events Cypress emits - // `config` is the resolved Cypress config -} diff --git a/labware-library/cypress/support/e2e.js b/labware-library/cypress/support/e2e.js deleted file mode 100644 index d68db96df26..00000000000 --- a/labware-library/cypress/support/e2e.js +++ /dev/null @@ -1,20 +0,0 @@ -// *********************************************************** -// This example support/index.js is processed and -// loaded automatically before your test files. -// -// This is a great place to put global configuration and -// behavior that modifies Cypress. -// -// You can change the location of this file or turn off -// automatically serving support files with the -// 'supportFile' configuration option. -// -// You can read more here: -// https://on.cypress.io/configuration -// *********************************************************** - -// Import commands.js using ES2015 syntax: -import './commands' - -// Alternatively you can use CommonJS syntax: -// require('./commands') diff --git a/labware-library/cypress/support/e2e.ts b/labware-library/cypress/support/e2e.ts new file mode 100644 index 00000000000..85dcff19ba8 --- /dev/null +++ b/labware-library/cypress/support/e2e.ts @@ -0,0 +1,40 @@ +// *********************************************************** +// This file runs before every single spec file. +// We do this purely as a convenience mechanism so you don't have to import this file. +// https://docs.cypress.io/guides/core-concepts/writing-and-organizing-tests#Support-file +// *********************************************************** +import { join } from 'path' +import './commands' + +export const navigateToUrl = (url: string): void => { + cy.visit(url) + cy.viewport('macbook-15') +} + +export const wellBottomImageLocator: Record = { + flat: 'img[alt*="flat bottom"]', + round: 'img[alt*="u shaped"]', + v: 'img[alt*="v shaped"]', +} + +interface FileHelperResult { + downloadsFolder: string + downloadFileStem: string + downloadFilename: string + downloadPath: string + expectedExportFixture: string +} + +export const fileHelper = (fileStem: string): FileHelperResult => { + const downloadsFolder = Cypress.config('downloadsFolder') + const downloadFileStem = fileStem + const downloadFilename = `${downloadFileStem}.json` + const downloadPath = join(downloadsFolder, downloadFilename) + return { + downloadsFolder, + downloadFileStem, + downloadFilename, + downloadPath, + expectedExportFixture: `../fixtures/${downloadFilename}`, + } +} diff --git a/labware-library/src/components/labware-ui/labware-images.ts b/labware-library/src/components/labware-ui/labware-images.ts index 36fe2cb8dfb..8df00e07f2a 100644 --- a/labware-library/src/components/labware-ui/labware-images.ts +++ b/labware-library/src/components/labware-ui/labware-images.ts @@ -468,4 +468,13 @@ export const labwareImages: Record = { import.meta.url ).href, ], + opentrons_tough_pcr_auto_sealing_lid: [ + new URL( + '../../images/opentrons_tough_pcr_auto_sealing_lid.jpg', + import.meta.url + ).href, + ], + opentrons_flex_deck_riser: [ + new URL('../../images/opentrons_flex_deck_riser.jpg', import.meta.url).href, + ], } diff --git a/labware-library/src/images/opentrons_flex_deck_riser.jpg b/labware-library/src/images/opentrons_flex_deck_riser.jpg new file mode 100644 index 00000000000..b8576833538 Binary files /dev/null and b/labware-library/src/images/opentrons_flex_deck_riser.jpg differ diff --git a/labware-library/src/images/opentrons_tough_pcr_auto_sealing_lid.jpg b/labware-library/src/images/opentrons_tough_pcr_auto_sealing_lid.jpg new file mode 100644 index 00000000000..a81c42b2d2c Binary files /dev/null and b/labware-library/src/images/opentrons_tough_pcr_auto_sealing_lid.jpg differ diff --git a/labware-library/src/localization/en.ts b/labware-library/src/localization/en.ts index 07e7bda76d1..9745ed44fb2 100644 --- a/labware-library/src/localization/en.ts +++ b/labware-library/src/localization/en.ts @@ -10,6 +10,7 @@ export const CATEGORY_LABELS_BY_CATEGORY = { trash: 'Trash', other: 'Other', adapter: 'Adapter', + lid: 'Lid', } export const PLURAL_CATEGORY_LABELS_BY_CATEGORY = { @@ -20,6 +21,7 @@ export const PLURAL_CATEGORY_LABELS_BY_CATEGORY = { wellPlate: 'Well Plates', reservoir: 'Reservoirs', aluminumBlock: 'Aluminum Blocks', + lid: 'Lid', trash: 'Trashes', other: 'Other', } diff --git a/labware-library/vite.config.mts b/labware-library/vite.config.mts index 43d5065c011..0c05338af06 100644 --- a/labware-library/vite.config.mts +++ b/labware-library/vite.config.mts @@ -8,14 +8,6 @@ import postCssPresetEnv from 'postcss-preset-env' import lostCss from 'lost' import { cssModuleSideEffect } from './cssModuleSideEffect' -const testAliases: {} | { 'file-saver': string } = - process.env.CYPRESS === '1' - ? { - 'file-saver': - path.resolve(__dirname, 'cypress/mocks/file-saver.js') ?? '', - } - : {} - export default defineConfig({ // this makes imports relative rather than absolute base: '', @@ -70,7 +62,6 @@ export default defineConfig({ '@opentrons/step-generation': path.resolve( '../step-generation/src/index.ts' ), - ...testAliases, }, }, server: { diff --git a/opentrons-ai-client/src/App.test.tsx b/opentrons-ai-client/src/App.test.tsx index 859bb488f0e..ec61b02472c 100644 --- a/opentrons-ai-client/src/App.test.tsx +++ b/opentrons-ai-client/src/App.test.tsx @@ -1,22 +1,13 @@ -import { fireEvent, screen } from '@testing-library/react' +import { screen } from '@testing-library/react' import { describe, it, vi, beforeEach, expect } from 'vitest' -import * as auth0 from '@auth0/auth0-react' import { renderWithProviders } from './__testing-utils__' import { i18n } from './i18n' -import { SidePanel } from './molecules/SidePanel' -import { MainContentContainer } from './organisms/MainContentContainer' -import { Loading } from './molecules/Loading' import { App } from './App' +import { OpentronsAI } from './OpentronsAI' -vi.mock('@auth0/auth0-react') - -const mockLogout = vi.fn() - -vi.mock('./molecules/SidePanel') -vi.mock('./organisms/MainContentContainer') -vi.mock('./molecules/Loading') +vi.mock('./OpentronsAI') const render = (): ReturnType => { return renderWithProviders(, { @@ -26,42 +17,11 @@ const render = (): ReturnType => { describe('App', () => { beforeEach(() => { - vi.mocked(SidePanel).mockReturnValue(
mock SidePanel
) - vi.mocked(MainContentContainer).mockReturnValue( -
mock MainContentContainer
- ) - vi.mocked(Loading).mockReturnValue(
mock Loading
) - }) - - it('should render loading screen when isLoading is true', () => { - ;(auth0 as any).useAuth0 = vi.fn().mockReturnValue({ - isAuthenticated: false, - isLoading: true, - }) - render() - screen.getByText('mock Loading') - }) - - it('should render text', () => { - ;(auth0 as any).useAuth0 = vi.fn().mockReturnValue({ - isAuthenticated: true, - isLoading: false, - }) - render() - screen.getByText('mock SidePanel') - screen.getByText('mock MainContentContainer') - screen.getByText('Logout') + vi.mocked(OpentronsAI).mockReturnValue(
mock OpentronsAI
) }) - it('should call a mock function when clicking logout button', () => { - ;(auth0 as any).useAuth0 = vi.fn().mockReturnValue({ - isAuthenticated: true, - isLoading: false, - logout: mockLogout, - }) + it('should render OpentronsAI', () => { render() - const logoutButton = screen.getByText('Logout') - fireEvent.click(logoutButton) - expect(mockLogout).toHaveBeenCalled() + expect(screen.getByText('mock OpentronsAI')).toBeInTheDocument() }) }) diff --git a/opentrons-ai-client/src/App.tsx b/opentrons-ai-client/src/App.tsx index 263ea02c844..104977150fc 100644 --- a/opentrons-ai-client/src/App.tsx +++ b/opentrons-ai-client/src/App.tsx @@ -1,82 +1,5 @@ -import { useEffect } from 'react' -import { useAuth0 } from '@auth0/auth0-react' -import { useTranslation } from 'react-i18next' -import { useForm, FormProvider } from 'react-hook-form' -import { useAtom } from 'jotai' -import { - COLORS, - Flex, - Link as LinkButton, - POSITION_ABSOLUTE, - POSITION_RELATIVE, - TYPOGRAPHY, -} from '@opentrons/components' - -import { tokenAtom } from './resources/atoms' -import { useGetAccessToken } from './resources/hooks' -import { SidePanel } from './molecules/SidePanel' -import { Loading } from './molecules/Loading' -import { MainContentContainer } from './organisms/MainContentContainer' - -export interface InputType { - userPrompt: string -} +import { OpentronsAI } from './OpentronsAI' export function App(): JSX.Element | null { - const { t } = useTranslation('protocol_generator') - const { isAuthenticated, logout, isLoading, loginWithRedirect } = useAuth0() - const [, setToken] = useAtom(tokenAtom) - const { getAccessToken } = useGetAccessToken() - - const fetchAccessToken = async (): Promise => { - try { - const accessToken = await getAccessToken() - setToken(accessToken) - } catch (error) { - console.error('Error fetching access token:', error) - } - } - const methods = useForm({ - defaultValues: { - userPrompt: '', - }, - }) - - useEffect(() => { - if (!isAuthenticated && !isLoading) { - void loginWithRedirect() - } - if (isAuthenticated) { - void fetchAccessToken() - } - }, [isAuthenticated, isLoading, loginWithRedirect]) - - if (isLoading) { - return - } - - if (!isAuthenticated) { - return null - } - - return ( - - - logout()} - textDecoration={TYPOGRAPHY.textDecorationUnderline} - > - {t('logout')} - - - - - - - - ) + return } diff --git a/opentrons-ai-client/src/OpentronsAI.test.tsx b/opentrons-ai-client/src/OpentronsAI.test.tsx new file mode 100644 index 00000000000..ba069ca3081 --- /dev/null +++ b/opentrons-ai-client/src/OpentronsAI.test.tsx @@ -0,0 +1,92 @@ +import { screen } from '@testing-library/react' +import { describe, it, vi, beforeEach } from 'vitest' +import * as auth0 from '@auth0/auth0-react' +import { renderWithProviders } from './__testing-utils__' +import { i18n } from './i18n' +import { Loading } from './molecules/Loading' +import { OpentronsAI } from './OpentronsAI' +import { Landing } from './pages/Landing' +import { useGetAccessToken } from './resources/hooks' +import { Header } from './molecules/Header' +import { Footer } from './molecules/Footer' +import { HeaderWithMeter } from './molecules/HeaderWithMeter' +import { headerWithMeterAtom } from './resources/atoms' + +vi.mock('@auth0/auth0-react') + +vi.mock('./pages/Landing') +vi.mock('./molecules/Header') +vi.mock('./molecules/HeaderWithMeter') +vi.mock('./molecules/Footer') +vi.mock('./molecules/Loading') +vi.mock('./resources/hooks/useGetAccessToken') +vi.mock('./analytics/mixpanel') + +const mockUseTrackEvent = vi.fn() + +vi.mock('./resources/hooks/useTrackEvent', () => ({ + useTrackEvent: () => mockUseTrackEvent, +})) + +const initialValues: Array<[any, any]> = [ + [headerWithMeterAtom, { displayHeaderWithMeter: false, progress: 0 }], +] + +const render = (): ReturnType => { + return renderWithProviders(, { + i18nInstance: i18n, + initialValues, + }) +} + +describe('OpentronsAI', () => { + beforeEach(() => { + vi.mocked(useGetAccessToken).mockReturnValue({ + getAccessToken: vi.fn().mockResolvedValue('mock access token'), + }) + vi.mocked(Landing).mockReturnValue(
mock Landing page
) + vi.mocked(Loading).mockReturnValue(
mock Loading
) + vi.mocked(Header).mockReturnValue(
mock Header component
) + vi.mocked(HeaderWithMeter).mockReturnValue( +
mock Header With Meter component
+ ) + vi.mocked(Footer).mockReturnValue(
mock Footer component
) + ;(auth0 as any).useAuth0 = vi.fn().mockReturnValue({ + isAuthenticated: true, + isLoading: false, + }) + }) + + it('should render loading screen when isLoading is true', () => { + ;(auth0 as any).useAuth0 = vi.fn().mockReturnValue({ + isAuthenticated: false, + isLoading: true, + }) + render() + screen.getByText('mock Loading') + }) + + it('should render text', () => { + render() + screen.getByText('mock Landing page') + }) + + it('should render the default Header component if displayHeaderWithMeter is false', () => { + render() + + screen.getByText('mock Header component') + }) + + it('should render Header with meter component if displayHeaderWithMeter is true', () => { + initialValues[0][1].displayHeaderWithMeter = true + + render() + + screen.getByText('mock Header With Meter component') + }) + + it('should render Footer component', () => { + render() + screen.getByText('mock Footer component') + }) +}) diff --git a/opentrons-ai-client/src/OpentronsAI.tsx b/opentrons-ai-client/src/OpentronsAI.tsx new file mode 100644 index 00000000000..e4bb9cca5fb --- /dev/null +++ b/opentrons-ai-client/src/OpentronsAI.tsx @@ -0,0 +1,112 @@ +import { HashRouter } from 'react-router-dom' +import { + DIRECTION_COLUMN, + Flex, + OVERFLOW_AUTO, + COLORS, + ALIGN_CENTER, +} from '@opentrons/components' +import { OpentronsAIRoutes } from './OpentronsAIRoutes' +import { useAuth0 } from '@auth0/auth0-react' +import { useAtom } from 'jotai' +import { useEffect } from 'react' +import { Loading } from './molecules/Loading' +import { headerWithMeterAtom, mixpanelAtom, tokenAtom } from './resources/atoms' +import { useGetAccessToken } from './resources/hooks' +import { initializeMixpanel } from './analytics/mixpanel' +import { useTrackEvent } from './resources/hooks/useTrackEvent' +import { Header } from './molecules/Header' +import { CLIENT_MAX_WIDTH } from './resources/constants' +import { Footer } from './molecules/Footer' +import { HeaderWithMeter } from './molecules/HeaderWithMeter' +import styled from 'styled-components' +import { ExitConfirmModal } from './molecules/ExitConfirmModal' + +export function OpentronsAI(): JSX.Element | null { + const { isAuthenticated, isLoading, loginWithRedirect } = useAuth0() + const [, setToken] = useAtom(tokenAtom) + const [{ displayHeaderWithMeter, progress }] = useAtom(headerWithMeterAtom) + const [mixpanelState, setMixpanelState] = useAtom(mixpanelAtom) + const { getAccessToken } = useGetAccessToken() + const trackEvent = useTrackEvent() + + const fetchAccessToken = async (): Promise => { + try { + const accessToken = await getAccessToken() + setToken(accessToken) + } catch (error) { + console.error('Error fetching access token:', error) + } + } + + if (mixpanelState?.isInitialized === false) { + setMixpanelState({ ...mixpanelState, isInitialized: true }) + initializeMixpanel(mixpanelState) + } + + useEffect(() => { + if (!isAuthenticated && !isLoading) { + void loginWithRedirect() + } + if (isAuthenticated) { + void fetchAccessToken() + } + }, [isAuthenticated, isLoading, loginWithRedirect]) + + useEffect(() => { + if (isAuthenticated) { + trackEvent({ name: 'user-login', properties: {} }) + } + }, [isAuthenticated]) + + if (isLoading) { + return + } + + if (!isAuthenticated) { + return null + } + + return ( + + + {displayHeaderWithMeter ? ( + + ) : ( +
+ )} + + + + + + + + + +