diff --git a/api-client/src/pipettes/index.ts b/api-client/src/pipettes/index.ts index cc19fe7ed15..f2fa52365fc 100644 --- a/api-client/src/pipettes/index.ts +++ b/api-client/src/pipettes/index.ts @@ -1,5 +1,6 @@ export { getPipettes } from './getPipettes' export { getPipetteSettings } from './getPipetteSettings' +export { updatePipetteSettings } from './updatePipetteSettings' export * from './types' export * from './__fixtures__' diff --git a/api-client/src/pipettes/types.ts b/api-client/src/pipettes/types.ts index dff905cca82..c637b64d967 100644 --- a/api-client/src/pipettes/types.ts +++ b/api-client/src/pipettes/types.ts @@ -51,8 +51,8 @@ export interface FetchPipettesResponseBody { right: FetchPipettesResponsePipette } -interface PipetteSettingsField { - value: number | null | undefined +export interface PipetteSettingsField { + value: number | null | boolean | undefined default: number min?: number max?: number @@ -66,7 +66,7 @@ interface PipetteQuirksField { interface QuirksField { quirks?: PipetteQuirksField } -type PipetteSettingsFieldsMap = QuirksField & { +export type PipetteSettingsFieldsMap = QuirksField & { [fieldId: string]: PipetteSettingsField } export interface IndividualPipetteSettings { @@ -77,3 +77,15 @@ export interface IndividualPipetteSettings { type PipetteSettingsById = Partial<{ [id: string]: IndividualPipetteSettings }> export type PipetteSettings = PipetteSettingsById + +export interface PipetteSettingsUpdateFieldsMap { + [fieldId: string]: PipetteSettingsUpdateField +} + +export type PipetteSettingsUpdateField = { + value: PipetteSettingsField['value'] +} | null + +export interface UpdatePipetteSettingsData { + fields: { [fieldId: string]: PipetteSettingsUpdateField } +} diff --git a/api-client/src/pipettes/updatePipetteSettings.ts b/api-client/src/pipettes/updatePipetteSettings.ts new file mode 100644 index 00000000000..7ed76178914 --- /dev/null +++ b/api-client/src/pipettes/updatePipetteSettings.ts @@ -0,0 +1,21 @@ +import { PATCH, request } from '../request' + +import type { ResponsePromise } from '../request' +import type { HostConfig } from '../types' +import type { + IndividualPipetteSettings, + UpdatePipetteSettingsData, +} from './types' + +export function updatePipetteSettings( + config: HostConfig, + pipetteId: string, + data: UpdatePipetteSettingsData +): ResponsePromise { + return request( + PATCH, + `/settings/pipettes/${pipetteId}`, + data, + config + ) +} diff --git a/api/Pipfile b/api/Pipfile index 6f18398fd2d..7db020c22c9 100755 --- a/api/Pipfile +++ b/api/Pipfile @@ -17,9 +17,8 @@ pytest-asyncio = "~=0.18" pytest-cov = "==2.10.1" pytest-lazy-fixture = "==0.6.3" pytest-xdist = "~=2.2.1" -pygments = "==2.9.0" sphinx = "==5.0.1" -twine = "==2.0.0" +twine = "==4.0.2" wheel = "==0.30.0" typeguard = "==2.12.1" sphinx-substitution-extensions = "==2020.9.30.0" diff --git a/api/Pipfile.lock b/api/Pipfile.lock index 279798a84a3..6a0090887a1 100644 --- a/api/Pipfile.lock +++ b/api/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "20a4273bec8ce91f1718a83db4076e66ffa583d27b35d038215bdb6970342cb5" + "sha256": "e53961988b9cfe876088bf31ed01f5e145545b9b6d014eac255b723c262a245f" }, "pipfile-spec": 6, "requires": {}, @@ -17,11 +17,11 @@ "develop": { "aenum": { "hashes": [ - "sha256:1d60e15f2e2d4ba66371c19c691edb085ecf82027e773309a9c4291b5cbccc17", - "sha256:7c4b04b5c9621533d6311e6ca23ea2ee213c7a992ed0be79a2b944cdaf2a45ec", - "sha256:93ba417f1c461d2aab6d107204110381d1b3e53561193ae53df3a17701821777" + "sha256:27b1710b9d084de6e2e695dab78fe9f269de924b51ae2850170ee7e1ca6288a5", + "sha256:8cbd76cd18c4f870ff39b24284d3ea028fbe8731a58df3aa581e434c575b9559", + "sha256:e0dfaeea4c2bd362144b87377e2c61d91958c5ed0b4daf89cb6f45ae23af6288" ], - "version": "==3.1.14" + "version": "==3.1.15" }, "aionotify": { "hashes": [ @@ -64,11 +64,11 @@ }, "babel": { "hashes": [ - "sha256:b4246fb7677d3b98f501a39d43396d3cafdc8eadb045f4a31be01863f655c610", - "sha256:cc2d99999cd01d44420ae725a21c9e3711b3aadc7976d6147f622d8581963455" + "sha256:04c3e2d28d2b7681644508f836be388ae49e0cfe91465095340395b60d00f210", + "sha256:fbfcae1575ff78e26c7449136f1abbefc3c13ce542eeb13d43d50d8b047216ec" ], "markers": "python_version >= '3.7'", - "version": "==2.12.1" + "version": "==2.13.0" }, "black": { "hashes": [ @@ -109,100 +109,115 @@ }, "certifi": { "hashes": [ - "sha256:0f0d56dc5a6ad56fd4ba36484d6cc34451e1c6548c61daad8c320169f91eddc7", - "sha256:c6c2e98f5c7869efca1f8916fed228dd91539f9f1b444c314c06eef02980c716" + "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082", + "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9" ], "markers": "python_version >= '3.6'", - "version": "==2023.5.7" + "version": "==2023.7.22" }, "charset-normalizer": { "hashes": [ - "sha256:04afa6387e2b282cf78ff3dbce20f0cc071c12dc8f685bd40960cc68644cfea6", - "sha256:04eefcee095f58eaabe6dc3cc2262f3bcd776d2c67005880894f447b3f2cb9c1", - "sha256:0be65ccf618c1e7ac9b849c315cc2e8a8751d9cfdaa43027d4f6624bd587ab7e", - "sha256:0c95f12b74681e9ae127728f7e5409cbbef9cd914d5896ef238cc779b8152373", - "sha256:0ca564606d2caafb0abe6d1b5311c2649e8071eb241b2d64e75a0d0065107e62", - "sha256:10c93628d7497c81686e8e5e557aafa78f230cd9e77dd0c40032ef90c18f2230", - "sha256:11d117e6c63e8f495412d37e7dc2e2fff09c34b2d09dbe2bee3c6229577818be", - "sha256:11d3bcb7be35e7b1bba2c23beedac81ee893ac9871d0ba79effc7fc01167db6c", - "sha256:12a2b561af122e3d94cdb97fe6fb2bb2b82cef0cdca131646fdb940a1eda04f0", - "sha256:12d1a39aa6b8c6f6248bb54550efcc1c38ce0d8096a146638fd4738e42284448", - "sha256:1435ae15108b1cb6fffbcea2af3d468683b7afed0169ad718451f8db5d1aff6f", - "sha256:1c60b9c202d00052183c9be85e5eaf18a4ada0a47d188a83c8f5c5b23252f649", - "sha256:1e8fcdd8f672a1c4fc8d0bd3a2b576b152d2a349782d1eb0f6b8e52e9954731d", - "sha256:20064ead0717cf9a73a6d1e779b23d149b53daf971169289ed2ed43a71e8d3b0", - "sha256:21fa558996782fc226b529fdd2ed7866c2c6ec91cee82735c98a197fae39f706", - "sha256:22908891a380d50738e1f978667536f6c6b526a2064156203d418f4856d6e86a", - "sha256:3160a0fd9754aab7d47f95a6b63ab355388d890163eb03b2d2b87ab0a30cfa59", - "sha256:322102cdf1ab682ecc7d9b1c5eed4ec59657a65e1c146a0da342b78f4112db23", - "sha256:34e0a2f9c370eb95597aae63bf85eb5e96826d81e3dcf88b8886012906f509b5", - "sha256:3573d376454d956553c356df45bb824262c397c6e26ce43e8203c4c540ee0acb", - "sha256:3747443b6a904001473370d7810aa19c3a180ccd52a7157aacc264a5ac79265e", - "sha256:38e812a197bf8e71a59fe55b757a84c1f946d0ac114acafaafaf21667a7e169e", - "sha256:3a06f32c9634a8705f4ca9946d667609f52cf130d5548881401f1eb2c39b1e2c", - "sha256:3a5fc78f9e3f501a1614a98f7c54d3969f3ad9bba8ba3d9b438c3bc5d047dd28", - "sha256:3d9098b479e78c85080c98e1e35ff40b4a31d8953102bb0fd7d1b6f8a2111a3d", - "sha256:3dc5b6a8ecfdc5748a7e429782598e4f17ef378e3e272eeb1340ea57c9109f41", - "sha256:4155b51ae05ed47199dc5b2a4e62abccb274cee6b01da5b895099b61b1982974", - "sha256:49919f8400b5e49e961f320c735388ee686a62327e773fa5b3ce6721f7e785ce", - "sha256:53d0a3fa5f8af98a1e261de6a3943ca631c526635eb5817a87a59d9a57ebf48f", - "sha256:5f008525e02908b20e04707a4f704cd286d94718f48bb33edddc7d7b584dddc1", - "sha256:628c985afb2c7d27a4800bfb609e03985aaecb42f955049957814e0491d4006d", - "sha256:65ed923f84a6844de5fd29726b888e58c62820e0769b76565480e1fdc3d062f8", - "sha256:6734e606355834f13445b6adc38b53c0fd45f1a56a9ba06c2058f86893ae8017", - "sha256:6baf0baf0d5d265fa7944feb9f7451cc316bfe30e8df1a61b1bb08577c554f31", - "sha256:6f4f4668e1831850ebcc2fd0b1cd11721947b6dc7c00bf1c6bd3c929ae14f2c7", - "sha256:6f5c2e7bc8a4bf7c426599765b1bd33217ec84023033672c1e9a8b35eaeaaaf8", - "sha256:6f6c7a8a57e9405cad7485f4c9d3172ae486cfef1344b5ddd8e5239582d7355e", - "sha256:7381c66e0561c5757ffe616af869b916c8b4e42b367ab29fedc98481d1e74e14", - "sha256:73dc03a6a7e30b7edc5b01b601e53e7fc924b04e1835e8e407c12c037e81adbd", - "sha256:74db0052d985cf37fa111828d0dd230776ac99c740e1a758ad99094be4f1803d", - "sha256:75f2568b4189dda1c567339b48cba4ac7384accb9c2a7ed655cd86b04055c795", - "sha256:78cacd03e79d009d95635e7d6ff12c21eb89b894c354bd2b2ed0b4763373693b", - "sha256:80d1543d58bd3d6c271b66abf454d437a438dff01c3e62fdbcd68f2a11310d4b", - "sha256:830d2948a5ec37c386d3170c483063798d7879037492540f10a475e3fd6f244b", - "sha256:891cf9b48776b5c61c700b55a598621fdb7b1e301a550365571e9624f270c203", - "sha256:8f25e17ab3039b05f762b0a55ae0b3632b2e073d9c8fc88e89aca31a6198e88f", - "sha256:9a3267620866c9d17b959a84dd0bd2d45719b817245e49371ead79ed4f710d19", - "sha256:a04f86f41a8916fe45ac5024ec477f41f886b3c435da2d4e3d2709b22ab02af1", - "sha256:aaf53a6cebad0eae578f062c7d462155eada9c172bd8c4d250b8c1d8eb7f916a", - "sha256:abc1185d79f47c0a7aaf7e2412a0eb2c03b724581139193d2d82b3ad8cbb00ac", - "sha256:ac0aa6cd53ab9a31d397f8303f92c42f534693528fafbdb997c82bae6e477ad9", - "sha256:ac3775e3311661d4adace3697a52ac0bab17edd166087d493b52d4f4f553f9f0", - "sha256:b06f0d3bf045158d2fb8837c5785fe9ff9b8c93358be64461a1089f5da983137", - "sha256:b116502087ce8a6b7a5f1814568ccbd0e9f6cfd99948aa59b0e241dc57cf739f", - "sha256:b82fab78e0b1329e183a65260581de4375f619167478dddab510c6c6fb04d9b6", - "sha256:bd7163182133c0c7701b25e604cf1611c0d87712e56e88e7ee5d72deab3e76b5", - "sha256:c36bcbc0d5174a80d6cccf43a0ecaca44e81d25be4b7f90f0ed7bcfbb5a00909", - "sha256:c3af8e0f07399d3176b179f2e2634c3ce9c1301379a6b8c9c9aeecd481da494f", - "sha256:c84132a54c750fda57729d1e2599bb598f5fa0344085dbde5003ba429a4798c0", - "sha256:cb7b2ab0188829593b9de646545175547a70d9a6e2b63bf2cd87a0a391599324", - "sha256:cca4def576f47a09a943666b8f829606bcb17e2bc2d5911a46c8f8da45f56755", - "sha256:cf6511efa4801b9b38dc5546d7547d5b5c6ef4b081c60b23e4d941d0eba9cbeb", - "sha256:d16fd5252f883eb074ca55cb622bc0bee49b979ae4e8639fff6ca3ff44f9f854", - "sha256:d2686f91611f9e17f4548dbf050e75b079bbc2a82be565832bc8ea9047b61c8c", - "sha256:d7fc3fca01da18fbabe4625d64bb612b533533ed10045a2ac3dd194bfa656b60", - "sha256:dd5653e67b149503c68c4018bf07e42eeed6b4e956b24c00ccdf93ac79cdff84", - "sha256:de5695a6f1d8340b12a5d6d4484290ee74d61e467c39ff03b39e30df62cf83a0", - "sha256:e0ac8959c929593fee38da1c2b64ee9778733cdf03c482c9ff1d508b6b593b2b", - "sha256:e1b25e3ad6c909f398df8921780d6a3d120d8c09466720226fc621605b6f92b1", - "sha256:e633940f28c1e913615fd624fcdd72fdba807bf53ea6925d6a588e84e1151531", - "sha256:e89df2958e5159b811af9ff0f92614dabf4ff617c03a4c1c6ff53bf1c399e0e1", - "sha256:ea9f9c6034ea2d93d9147818f17c2a0860d41b71c38b9ce4d55f21b6f9165a11", - "sha256:f645caaf0008bacf349875a974220f1f1da349c5dbe7c4ec93048cdc785a3326", - "sha256:f8303414c7b03f794347ad062c0516cee0e15f7a612abd0ce1e25caf6ceb47df", - "sha256:fca62a8301b605b954ad2e9c3666f9d97f63872aa4efcae5492baca2056b74ab" + "sha256:02673e456dc5ab13659f85196c534dc596d4ef260e4d86e856c3b2773ce09843", + "sha256:02af06682e3590ab952599fbadac535ede5d60d78848e555aa58d0c0abbde786", + "sha256:03680bb39035fbcffe828eae9c3f8afc0428c91d38e7d61aa992ef7a59fb120e", + "sha256:0570d21da019941634a531444364f2482e8db0b3425fcd5ac0c36565a64142c8", + "sha256:09c77f964f351a7369cc343911e0df63e762e42bac24cd7d18525961c81754f4", + "sha256:0d3d5b7db9ed8a2b11a774db2bbea7ba1884430a205dbd54a32d61d7c2a190fa", + "sha256:1063da2c85b95f2d1a430f1c33b55c9c17ffaf5e612e10aeaad641c55a9e2b9d", + "sha256:12ebea541c44fdc88ccb794a13fe861cc5e35d64ed689513a5c03d05b53b7c82", + "sha256:153e7b6e724761741e0974fc4dcd406d35ba70b92bfe3fedcb497226c93b9da7", + "sha256:15b26ddf78d57f1d143bdf32e820fd8935d36abe8a25eb9ec0b5a71c82eb3895", + "sha256:1872d01ac8c618a8da634e232f24793883d6e456a66593135aeafe3784b0848d", + "sha256:187d18082694a29005ba2944c882344b6748d5be69e3a89bf3cc9d878e548d5a", + "sha256:1b2919306936ac6efb3aed1fbf81039f7087ddadb3160882a57ee2ff74fd2382", + "sha256:232ac332403e37e4a03d209a3f92ed9071f7d3dbda70e2a5e9cff1c4ba9f0678", + "sha256:23e8565ab7ff33218530bc817922fae827420f143479b753104ab801145b1d5b", + "sha256:24817cb02cbef7cd499f7c9a2735286b4782bd47a5b3516a0e84c50eab44b98e", + "sha256:249c6470a2b60935bafd1d1d13cd613f8cd8388d53461c67397ee6a0f5dce741", + "sha256:24a91a981f185721542a0b7c92e9054b7ab4fea0508a795846bc5b0abf8118d4", + "sha256:2502dd2a736c879c0f0d3e2161e74d9907231e25d35794584b1ca5284e43f596", + "sha256:250c9eb0f4600361dd80d46112213dff2286231d92d3e52af1e5a6083d10cad9", + "sha256:278c296c6f96fa686d74eb449ea1697f3c03dc28b75f873b65b5201806346a69", + "sha256:2935ffc78db9645cb2086c2f8f4cfd23d9b73cc0dc80334bc30aac6f03f68f8c", + "sha256:2f4a0033ce9a76e391542c182f0d48d084855b5fcba5010f707c8e8c34663d77", + "sha256:30a85aed0b864ac88309b7d94be09f6046c834ef60762a8833b660139cfbad13", + "sha256:380c4bde80bce25c6e4f77b19386f5ec9db230df9f2f2ac1e5ad7af2caa70459", + "sha256:3ae38d325b512f63f8da31f826e6cb6c367336f95e418137286ba362925c877e", + "sha256:3b447982ad46348c02cb90d230b75ac34e9886273df3a93eec0539308a6296d7", + "sha256:3debd1150027933210c2fc321527c2299118aa929c2f5a0a80ab6953e3bd1908", + "sha256:4162918ef3098851fcd8a628bf9b6a98d10c380725df9e04caf5ca6dd48c847a", + "sha256:468d2a840567b13a590e67dd276c570f8de00ed767ecc611994c301d0f8c014f", + "sha256:4cc152c5dd831641e995764f9f0b6589519f6f5123258ccaca8c6d34572fefa8", + "sha256:542da1178c1c6af8873e143910e2269add130a299c9106eef2594e15dae5e482", + "sha256:557b21a44ceac6c6b9773bc65aa1b4cc3e248a5ad2f5b914b91579a32e22204d", + "sha256:5707a746c6083a3a74b46b3a631d78d129edab06195a92a8ece755aac25a3f3d", + "sha256:588245972aca710b5b68802c8cad9edaa98589b1b42ad2b53accd6910dad3545", + "sha256:5adf257bd58c1b8632046bbe43ee38c04e1038e9d37de9c57a94d6bd6ce5da34", + "sha256:619d1c96099be5823db34fe89e2582b336b5b074a7f47f819d6b3a57ff7bdb86", + "sha256:63563193aec44bce707e0c5ca64ff69fa72ed7cf34ce6e11d5127555756fd2f6", + "sha256:67b8cc9574bb518ec76dc8e705d4c39ae78bb96237cb533edac149352c1f39fe", + "sha256:6a685067d05e46641d5d1623d7c7fdf15a357546cbb2f71b0ebde91b175ffc3e", + "sha256:70f1d09c0d7748b73290b29219e854b3207aea922f839437870d8cc2168e31cc", + "sha256:750b446b2ffce1739e8578576092179160f6d26bd5e23eb1789c4d64d5af7dc7", + "sha256:7966951325782121e67c81299a031f4c115615e68046f79b85856b86ebffc4cd", + "sha256:7b8b8bf1189b3ba9b8de5c8db4d541b406611a71a955bbbd7385bbc45fcb786c", + "sha256:7f5d10bae5d78e4551b7be7a9b29643a95aded9d0f602aa2ba584f0388e7a557", + "sha256:805dfea4ca10411a5296bcc75638017215a93ffb584c9e344731eef0dcfb026a", + "sha256:81bf654678e575403736b85ba3a7867e31c2c30a69bc57fe88e3ace52fb17b89", + "sha256:82eb849f085624f6a607538ee7b83a6d8126df6d2f7d3b319cb837b289123078", + "sha256:85a32721ddde63c9df9ebb0d2045b9691d9750cb139c161c80e500d210f5e26e", + "sha256:86d1f65ac145e2c9ed71d8ffb1905e9bba3a91ae29ba55b4c46ae6fc31d7c0d4", + "sha256:86f63face3a527284f7bb8a9d4f78988e3c06823f7bea2bd6f0e0e9298ca0403", + "sha256:8eaf82f0eccd1505cf39a45a6bd0a8cf1c70dcfc30dba338207a969d91b965c0", + "sha256:93aa7eef6ee71c629b51ef873991d6911b906d7312c6e8e99790c0f33c576f89", + "sha256:96c2b49eb6a72c0e4991d62406e365d87067ca14c1a729a870d22354e6f68115", + "sha256:9cf3126b85822c4e53aa28c7ec9869b924d6fcfb76e77a45c44b83d91afd74f9", + "sha256:9fe359b2e3a7729010060fbca442ca225280c16e923b37db0e955ac2a2b72a05", + "sha256:a0ac5e7015a5920cfce654c06618ec40c33e12801711da6b4258af59a8eff00a", + "sha256:a3f93dab657839dfa61025056606600a11d0b696d79386f974e459a3fbc568ec", + "sha256:a4b71f4d1765639372a3b32d2638197f5cd5221b19531f9245fcc9ee62d38f56", + "sha256:aae32c93e0f64469f74ccc730a7cb21c7610af3a775157e50bbd38f816536b38", + "sha256:aaf7b34c5bc56b38c931a54f7952f1ff0ae77a2e82496583b247f7c969eb1479", + "sha256:abecce40dfebbfa6abf8e324e1860092eeca6f7375c8c4e655a8afb61af58f2c", + "sha256:abf0d9f45ea5fb95051c8bfe43cb40cda383772f7e5023a83cc481ca2604d74e", + "sha256:ac71b2977fb90c35d41c9453116e283fac47bb9096ad917b8819ca8b943abecd", + "sha256:ada214c6fa40f8d800e575de6b91a40d0548139e5dc457d2ebb61470abf50186", + "sha256:b09719a17a2301178fac4470d54b1680b18a5048b481cb8890e1ef820cb80455", + "sha256:b1121de0e9d6e6ca08289583d7491e7fcb18a439305b34a30b20d8215922d43c", + "sha256:b3b2316b25644b23b54a6f6401074cebcecd1244c0b8e80111c9a3f1c8e83d65", + "sha256:b3d9b48ee6e3967b7901c052b670c7dda6deb812c309439adaffdec55c6d7b78", + "sha256:b5bcf60a228acae568e9911f410f9d9e0d43197d030ae5799e20dca8df588287", + "sha256:b8f3307af845803fb0b060ab76cf6dd3a13adc15b6b451f54281d25911eb92df", + "sha256:c2af80fb58f0f24b3f3adcb9148e6203fa67dd3f61c4af146ecad033024dde43", + "sha256:c350354efb159b8767a6244c166f66e67506e06c8924ed74669b2c70bc8735b1", + "sha256:c5a74c359b2d47d26cdbbc7845e9662d6b08a1e915eb015d044729e92e7050b7", + "sha256:c71f16da1ed8949774ef79f4a0260d28b83b3a50c6576f8f4f0288d109777989", + "sha256:d47ecf253780c90ee181d4d871cd655a789da937454045b17b5798da9393901a", + "sha256:d7eff0f27edc5afa9e405f7165f85a6d782d308f3b6b9d96016c010597958e63", + "sha256:d97d85fa63f315a8bdaba2af9a6a686e0eceab77b3089af45133252618e70884", + "sha256:db756e48f9c5c607b5e33dd36b1d5872d0422e960145b08ab0ec7fd420e9d649", + "sha256:dc45229747b67ffc441b3de2f3ae5e62877a282ea828a5bdb67883c4ee4a8810", + "sha256:e0fc42822278451bc13a2e8626cf2218ba570f27856b536e00cfa53099724828", + "sha256:e39c7eb31e3f5b1f88caff88bcff1b7f8334975b46f6ac6e9fc725d829bc35d4", + "sha256:e46cd37076971c1040fc8c41273a8b3e2c624ce4f2be3f5dfcb7a430c1d3acc2", + "sha256:e5c1502d4ace69a179305abb3f0bb6141cbe4714bc9b31d427329a95acfc8bdd", + "sha256:edfe077ab09442d4ef3c52cb1f9dab89bff02f4524afc0acf2d46be17dc479f5", + "sha256:effe5406c9bd748a871dbcaf3ac69167c38d72db8c9baf3ff954c344f31c4cbe", + "sha256:f0d1e3732768fecb052d90d62b220af62ead5748ac51ef61e7b32c266cac9293", + "sha256:f5969baeaea61c97efa706b9b107dcba02784b1601c74ac84f2a532ea079403e", + "sha256:f8888e31e3a85943743f8fc15e71536bda1c81d5aa36d014a3c0c44481d7db6e", + "sha256:fc52b79d83a3fe3a360902d3f5d79073a993597d48114c29485e9431092905d8" ], "markers": "python_version >= '3.7'", - "version": "==3.1.0" + "version": "==3.3.0" }, "click": { "hashes": [ - "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e", - "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48" + "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", + "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de" ], "markers": "python_version >= '3.7'", - "version": "==8.1.3" + "version": "==8.1.7" }, "colorama": { "hashes": [ @@ -275,19 +290,19 @@ }, "exceptiongroup": { "hashes": [ - "sha256:232c37c63e4f682982c8b6459f33a8981039e5fb8756b2074364e5055c498c9e", - "sha256:d484c3090ba2889ae2928419117447a14daf3c1231d5e30d0aae34f354f01785" + "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9", + "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3" ], "markers": "python_version < '3.11'", - "version": "==1.1.1" + "version": "==1.1.3" }, "execnet": { "hashes": [ - "sha256:8f694f3ba9cc92cab508b152dcfe322153975c29bda272e2fd7f3f00f36e47c5", - "sha256:a295f7cc774947aac58dde7fdc85f4aa00c42adf5d8f5468fc630c1acf30a142" + "sha256:88256416ae766bc9e8895c76a87928c0012183da3cc4fc18016e6f050e025f41", + "sha256:cc59bc4423742fd71ad227122eb0dd44db51efb3dc4095b45ac9a08c770096af" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==1.9.0" + "markers": "python_version >= '3.7'", + "version": "==2.0.2" }, "flake8": { "hashes": [ @@ -339,11 +354,11 @@ }, "hypothesis": { "hashes": [ - "sha256:245bed0fcf7612caa0ca1ecaa5c1e3a7100bbf9fd0fe4a24bdd9e46249b2774f", - "sha256:69b55ee1dae2c7edd214e273a977d0dfba542946a211c9ef1f958743b49e430e" + "sha256:5ce05bc70aa4f20114effaf3375dc8b51d09a04026a0cf89d4514fc0b69f6304", + "sha256:e9a9ff3dc3f3eebbf214d6852882ac96ad72023f0e9770139fd3d3c1b87673e2" ], "index": "pypi", - "version": "==6.79.3" + "version": "==6.79.4" }, "idna": { "hashes": [ @@ -366,9 +381,17 @@ "sha256:8a8a81bcf996e74fee46f0d16bd3eaa382a7eb20fd82445c3ad11f4090334116", "sha256:dd0173e8f150d6815e098fd354f6414b0f079af4644ddfe90c71e2fc6174346d" ], - "markers": "python_version < '3.10'", + "markers": "python_version < '3.10' and python_version < '3.12'", "version": "==4.13.0" }, + "importlib-resources": { + "hashes": [ + "sha256:4be82589bf5c1d7999aedf2a45159d10cb3ca4f19b2271f8792bc8e6da7b22f6", + "sha256:7b1deeebbf351c7578e09bf2f63fa2ce8b5ffec296e0d349139d43cca061a81a" + ], + "markers": "python_version < '3.9'", + "version": "==5.12.0" + }, "iniconfig": { "hashes": [ "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", @@ -377,6 +400,14 @@ "markers": "python_version >= '3.7'", "version": "==2.0.0" }, + "jaraco.classes": { + "hashes": [ + "sha256:2353de3288bc6b82120752201c6b1c1a14b058267fa424ed5ce5984e3b922158", + "sha256:89559fa5c1d3c34eff6f631ad80bb21f378dbcbb35dd161fd2c6b93f5be2f98a" + ], + "markers": "python_version >= '3.7'", + "version": "==3.2.3" + }, "jinja2": { "hashes": [ "sha256:077ce6014f7b40d03b47d1f1ca4b0fc8328a692bd284016f806ed0eaca390ad8", @@ -392,79 +423,131 @@ ], "version": "==3.0.2" }, + "keyring": { + "hashes": [ + "sha256:3d44a48fa9a254f6c72879d7c88604831ebdaac6ecb0b214308b02953502c510", + "sha256:bc402c5e501053098bcbd149c4ddbf8e36c6809e572c2d098d4961e88d4c270d" + ], + "markers": "python_version >= '3.7'", + "version": "==24.1.1" + }, "kiwisolver": { "hashes": [ - "sha256:02f79693ec433cb4b5f51694e8477ae83b3205768a6fb48ffba60549080e295b", - "sha256:03baab2d6b4a54ddbb43bba1a3a2d1627e82d205c5cf8f4c924dc49284b87166", - "sha256:1041feb4cda8708ce73bb4dcb9ce1ccf49d553bf87c3954bdfa46f0c3f77252c", - "sha256:10ee06759482c78bdb864f4109886dff7b8a56529bc1609d4f1112b93fe6423c", - "sha256:1d1573129aa0fd901076e2bfb4275a35f5b7aa60fbfb984499d661ec950320b0", - "sha256:283dffbf061a4ec60391d51e6155e372a1f7a4f5b15d59c8505339454f8989e4", - "sha256:28bc5b299f48150b5f822ce68624e445040595a4ac3d59251703779836eceff9", - "sha256:2a66fdfb34e05b705620dd567f5a03f239a088d5a3f321e7b6ac3239d22aa286", - "sha256:2e307eb9bd99801f82789b44bb45e9f541961831c7311521b13a6c85afc09767", - "sha256:2e407cb4bd5a13984a6c2c0fe1845e4e41e96f183e5e5cd4d77a857d9693494c", - "sha256:2f5e60fabb7343a836360c4f0919b8cd0d6dbf08ad2ca6b9cf90bf0c76a3c4f6", - "sha256:36dafec3d6d6088d34e2de6b85f9d8e2324eb734162fba59d2ba9ed7a2043d5b", - "sha256:3fe20f63c9ecee44560d0e7f116b3a747a5d7203376abeea292ab3152334d004", - "sha256:41dae968a94b1ef1897cb322b39360a0812661dba7c682aa45098eb8e193dbdf", - "sha256:4bd472dbe5e136f96a4b18f295d159d7f26fd399136f5b17b08c4e5f498cd494", - "sha256:4ea39b0ccc4f5d803e3337dd46bcce60b702be4d86fd0b3d7531ef10fd99a1ac", - "sha256:5853eb494c71e267912275e5586fe281444eb5e722de4e131cddf9d442615626", - "sha256:5bce61af018b0cb2055e0e72e7d65290d822d3feee430b7b8203d8a855e78766", - "sha256:6295ecd49304dcf3bfbfa45d9a081c96509e95f4b9d0eb7ee4ec0530c4a96514", - "sha256:62ac9cc684da4cf1778d07a89bf5f81b35834cb96ca523d3a7fb32509380cbf6", - "sha256:70e7c2e7b750585569564e2e5ca9845acfaa5da56ac46df68414f29fea97be9f", - "sha256:7577c1987baa3adc4b3c62c33bd1118c3ef5c8ddef36f0f2c950ae0b199e100d", - "sha256:75facbe9606748f43428fc91a43edb46c7ff68889b91fa31f53b58894503a191", - "sha256:787518a6789009c159453da4d6b683f468ef7a65bbde796bcea803ccf191058d", - "sha256:78d6601aed50c74e0ef02f4204da1816147a6d3fbdc8b3872d263338a9052c51", - "sha256:7c43e1e1206cd421cd92e6b3280d4385d41d7166b3ed577ac20444b6995a445f", - "sha256:81e38381b782cc7e1e46c4e14cd997ee6040768101aefc8fa3c24a4cc58e98f8", - "sha256:841293b17ad704d70c578f1f0013c890e219952169ce8a24ebc063eecf775454", - "sha256:872b8ca05c40d309ed13eb2e582cab0c5a05e81e987ab9c521bf05ad1d5cf5cb", - "sha256:877272cf6b4b7e94c9614f9b10140e198d2186363728ed0f701c6eee1baec1da", - "sha256:8c808594c88a025d4e322d5bb549282c93c8e1ba71b790f539567932722d7bd8", - "sha256:8ed58b8acf29798b036d347791141767ccf65eee7f26bde03a71c944449e53de", - "sha256:91672bacaa030f92fc2f43b620d7b337fd9a5af28b0d6ed3f77afc43c4a64b5a", - "sha256:968f44fdbf6dd757d12920d63b566eeb4d5b395fd2d00d29d7ef00a00582aac9", - "sha256:9f85003f5dfa867e86d53fac6f7e6f30c045673fa27b603c397753bebadc3008", - "sha256:a553dadda40fef6bfa1456dc4be49b113aa92c2a9a9e8711e955618cd69622e3", - "sha256:a68b62a02953b9841730db7797422f983935aeefceb1679f0fc85cbfbd311c32", - "sha256:abbe9fa13da955feb8202e215c4018f4bb57469b1b78c7a4c5c7b93001699938", - "sha256:ad881edc7ccb9d65b0224f4e4d05a1e85cf62d73aab798943df6d48ab0cd79a1", - "sha256:b1792d939ec70abe76f5054d3f36ed5656021dcad1322d1cc996d4e54165cef9", - "sha256:b428ef021242344340460fa4c9185d0b1f66fbdbfecc6c63eff4b7c29fad429d", - "sha256:b533558eae785e33e8c148a8d9921692a9fe5aa516efbdff8606e7d87b9d5824", - "sha256:ba59c92039ec0a66103b1d5fe588fa546373587a7d68f5c96f743c3396afc04b", - "sha256:bc8d3bd6c72b2dd9decf16ce70e20abcb3274ba01b4e1c96031e0c4067d1e7cd", - "sha256:bc9db8a3efb3e403e4ecc6cd9489ea2bac94244f80c78e27c31dcc00d2790ac2", - "sha256:bf7d9fce9bcc4752ca4a1b80aabd38f6d19009ea5cbda0e0856983cf6d0023f5", - "sha256:c2dbb44c3f7e6c4d3487b31037b1bdbf424d97687c1747ce4ff2895795c9bf69", - "sha256:c79ebe8f3676a4c6630fd3f777f3cfecf9289666c84e775a67d1d358578dc2e3", - "sha256:c97528e64cb9ebeff9701e7938653a9951922f2a38bd847787d4a8e498cc83ae", - "sha256:d0611a0a2a518464c05ddd5a3a1a0e856ccc10e67079bb17f265ad19ab3c7597", - "sha256:d06adcfa62a4431d404c31216f0f8ac97397d799cd53800e9d3efc2fbb3cf14e", - "sha256:d41997519fcba4a1e46eb4a2fe31bc12f0ff957b2b81bac28db24744f333e955", - "sha256:d5b61785a9ce44e5a4b880272baa7cf6c8f48a5180c3e81c59553ba0cb0821ca", - "sha256:da152d8cdcab0e56e4f45eb08b9aea6455845ec83172092f09b0e077ece2cf7a", - "sha256:da7e547706e69e45d95e116e6939488d62174e033b763ab1496b4c29b76fabea", - "sha256:db5283d90da4174865d520e7366801a93777201e91e79bacbac6e6927cbceede", - "sha256:db608a6757adabb32f1cfe6066e39b3706d8c3aa69bbc353a5b61edad36a5cb4", - "sha256:e0ea21f66820452a3f5d1655f8704a60d66ba1191359b96541eaf457710a5fc6", - "sha256:e7da3fec7408813a7cebc9e4ec55afed2d0fd65c4754bc376bf03498d4e92686", - "sha256:e92a513161077b53447160b9bd8f522edfbed4bd9759e4c18ab05d7ef7e49408", - "sha256:ecb1fa0db7bf4cff9dac752abb19505a233c7f16684c5826d1f11ebd9472b871", - "sha256:efda5fc8cc1c61e4f639b8067d118e742b812c930f708e6667a5ce0d13499e29", - "sha256:f0a1dbdb5ecbef0d34eb77e56fcb3e95bbd7e50835d9782a45df81cc46949750", - "sha256:f0a71d85ecdd570ded8ac3d1c0f480842f49a40beb423bb8014539a9f32a5897", - "sha256:f4f270de01dd3e129a72efad823da90cc4d6aafb64c410c9033aba70db9f1ff0", - "sha256:f6cb459eea32a4e2cf18ba5fcece2dbdf496384413bc1bae15583f19e567f3b2", - "sha256:f8ad8285b01b0d4695102546b342b493b3ccc6781fc28c8c6a1bb63e95d22f09", - "sha256:f9f39e2f049db33a908319cf46624a569b36983c7c78318e9726a4cb8923b26c" + "sha256:00bd361b903dc4bbf4eb165f24d1acbee754fce22ded24c3d56eec268658a5cf", + "sha256:040c1aebeda72197ef477a906782b5ab0d387642e93bda547336b8957c61022e", + "sha256:05703cf211d585109fcd72207a31bb170a0f22144d68298dc5e61b3c946518af", + "sha256:06f54715b7737c2fecdbf140d1afb11a33d59508a47bf11bb38ecf21dc9ab79f", + "sha256:0dc9db8e79f0036e8173c466d21ef18e1befc02de8bf8aa8dc0813a6dc8a7046", + "sha256:0f114aa76dc1b8f636d077979c0ac22e7cd8f3493abbab152f20eb8d3cda71f3", + "sha256:11863aa14a51fd6ec28688d76f1735f8f69ab1fabf388851a595d0721af042f5", + "sha256:11c7de8f692fc99816e8ac50d1d1aef4f75126eefc33ac79aac02c099fd3db71", + "sha256:11d011a7574eb3b82bcc9c1a1d35c1d7075677fdd15de527d91b46bd35e935ee", + "sha256:146d14bebb7f1dc4d5fbf74f8a6cb15ac42baadee8912eb84ac0b3b2a3dc6ac3", + "sha256:15568384086b6df3c65353820a4473575dbad192e35010f622c6ce3eebd57af9", + "sha256:19df6e621f6d8b4b9c4d45f40a66839294ff2bb235e64d2178f7522d9170ac5b", + "sha256:1b04139c4236a0f3aff534479b58f6f849a8b351e1314826c2d230849ed48985", + "sha256:210ef2c3a1f03272649aff1ef992df2e724748918c4bc2d5a90352849eb40bea", + "sha256:2270953c0d8cdab5d422bee7d2007f043473f9d2999631c86a223c9db56cbd16", + "sha256:2400873bccc260b6ae184b2b8a4fec0e4082d30648eadb7c3d9a13405d861e89", + "sha256:2a40773c71d7ccdd3798f6489aaac9eee213d566850a9533f8d26332d626b82c", + "sha256:2c5674c4e74d939b9d91dda0fae10597ac7521768fec9e399c70a1f27e2ea2d9", + "sha256:3195782b26fc03aa9c6913d5bad5aeb864bdc372924c093b0f1cebad603dd712", + "sha256:31a82d498054cac9f6d0b53d02bb85811185bcb477d4b60144f915f3b3126342", + "sha256:32d5cf40c4f7c7b3ca500f8985eb3fb3a7dfc023215e876f207956b5ea26632a", + "sha256:346f5343b9e3f00b8db8ba359350eb124b98c99efd0b408728ac6ebf38173958", + "sha256:378a214a1e3bbf5ac4a8708304318b4f890da88c9e6a07699c4ae7174c09a68d", + "sha256:39b42c68602539407884cf70d6a480a469b93b81b7701378ba5e2328660c847a", + "sha256:3a2b053a0ab7a3960c98725cfb0bf5b48ba82f64ec95fe06f1d06c99b552e130", + "sha256:3aba7311af82e335dd1e36ffff68aaca609ca6290c2cb6d821a39aa075d8e3ff", + "sha256:3cd32d6c13807e5c66a7cbb79f90b553642f296ae4518a60d8d76243b0ad2898", + "sha256:3edd2fa14e68c9be82c5b16689e8d63d89fe927e56debd6e1dbce7a26a17f81b", + "sha256:4c380469bd3f970ef677bf2bcba2b6b0b4d5c75e7a020fb863ef75084efad66f", + "sha256:4e66e81a5779b65ac21764c295087de82235597a2293d18d943f8e9e32746265", + "sha256:53abb58632235cd154176ced1ae8f0d29a6657aa1aa9decf50b899b755bc2b93", + "sha256:5794cf59533bc3f1b1c821f7206a3617999db9fbefc345360aafe2e067514929", + "sha256:59415f46a37f7f2efeec758353dd2eae1b07640d8ca0f0c42548ec4125492635", + "sha256:59ec7b7c7e1a61061850d53aaf8e93db63dce0c936db1fda2658b70e4a1be709", + "sha256:59edc41b24031bc25108e210c0def6f6c2191210492a972d585a06ff246bb79b", + "sha256:5a580c91d686376f0f7c295357595c5a026e6cbc3d77b7c36e290201e7c11ecb", + "sha256:5b94529f9b2591b7af5f3e0e730a4e0a41ea174af35a4fd067775f9bdfeee01a", + "sha256:5c7b3b3a728dc6faf3fc372ef24f21d1e3cee2ac3e9596691d746e5a536de920", + "sha256:5c90ae8c8d32e472be041e76f9d2f2dbff4d0b0be8bd4041770eddb18cf49a4e", + "sha256:5e7139af55d1688f8b960ee9ad5adafc4ac17c1c473fe07133ac092310d76544", + "sha256:5ff5cf3571589b6d13bfbfd6bcd7a3f659e42f96b5fd1c4830c4cf21d4f5ef45", + "sha256:620ced262a86244e2be10a676b646f29c34537d0d9cc8eb26c08f53d98013390", + "sha256:6512cb89e334e4700febbffaaa52761b65b4f5a3cf33f960213d5656cea36a77", + "sha256:6c08e1312a9cf1074d17b17728d3dfce2a5125b2d791527f33ffbe805200a355", + "sha256:6c3bd3cde54cafb87d74d8db50b909705c62b17c2099b8f2e25b461882e544ff", + "sha256:6ef7afcd2d281494c0a9101d5c571970708ad911d028137cd558f02b851c08b4", + "sha256:7269d9e5f1084a653d575c7ec012ff57f0c042258bf5db0954bf551c158466e7", + "sha256:72d40b33e834371fd330fb1472ca19d9b8327acb79a5821d4008391db8e29f20", + "sha256:74d1b44c6cfc897df648cc9fdaa09bc3e7679926e6f96df05775d4fb3946571c", + "sha256:74db36e14a7d1ce0986fa104f7d5637aea5c82ca6326ed0ec5694280942d1162", + "sha256:763773d53f07244148ccac5b084da5adb90bfaee39c197554f01b286cf869228", + "sha256:76c6a5964640638cdeaa0c359382e5703e9293030fe730018ca06bc2010c4437", + "sha256:76d9289ed3f7501012e05abb8358bbb129149dbd173f1f57a1bf1c22d19ab7cc", + "sha256:7931d8f1f67c4be9ba1dd9c451fb0eeca1a25b89e4d3f89e828fe12a519b782a", + "sha256:7b8b454bac16428b22560d0a1cf0a09875339cab69df61d7805bf48919415901", + "sha256:7e5bab140c309cb3a6ce373a9e71eb7e4873c70c2dda01df6820474f9889d6d4", + "sha256:83d78376d0d4fd884e2c114d0621624b73d2aba4e2788182d286309ebdeed770", + "sha256:852542f9481f4a62dbb5dd99e8ab7aedfeb8fb6342349a181d4036877410f525", + "sha256:85267bd1aa8880a9c88a8cb71e18d3d64d2751a790e6ca6c27b8ccc724bcd5ad", + "sha256:88a2df29d4724b9237fc0c6eaf2a1adae0cdc0b3e9f4d8e7dc54b16812d2d81a", + "sha256:88b9f257ca61b838b6f8094a62418421f87ac2a1069f7e896c36a7d86b5d4c29", + "sha256:8ab3919a9997ab7ef2fbbed0cc99bb28d3c13e6d4b1ad36e97e482558a91be90", + "sha256:92dea1ffe3714fa8eb6a314d2b3c773208d865a0e0d35e713ec54eea08a66250", + "sha256:9407b6a5f0d675e8a827ad8742e1d6b49d9c1a1da5d952a67d50ef5f4170b18d", + "sha256:9408acf3270c4b6baad483865191e3e582b638b1654a007c62e3efe96f09a9a3", + "sha256:955e8513d07a283056b1396e9a57ceddbd272d9252c14f154d450d227606eb54", + "sha256:9db8ea4c388fdb0f780fe91346fd438657ea602d58348753d9fb265ce1bca67f", + "sha256:9eaa8b117dc8337728e834b9c6e2611f10c79e38f65157c4c38e9400286f5cb1", + "sha256:a51a263952b1429e429ff236d2f5a21c5125437861baeed77f5e1cc2d2c7c6da", + "sha256:a6aa6315319a052b4ee378aa171959c898a6183f15c1e541821c5c59beaa0238", + "sha256:aa12042de0171fad672b6c59df69106d20d5596e4f87b5e8f76df757a7c399aa", + "sha256:aaf7be1207676ac608a50cd08f102f6742dbfc70e8d60c4db1c6897f62f71523", + "sha256:b0157420efcb803e71d1b28e2c287518b8808b7cf1ab8af36718fd0a2c453eb0", + "sha256:b3f7e75f3015df442238cca659f8baa5f42ce2a8582727981cbfa15fee0ee205", + "sha256:b9098e0049e88c6a24ff64545cdfc50807818ba6c1b739cae221bbbcbc58aad3", + "sha256:ba55dce0a9b8ff59495ddd050a0225d58bd0983d09f87cfe2b6aec4f2c1234e4", + "sha256:bb86433b1cfe686da83ce32a9d3a8dd308e85c76b60896d58f082136f10bffac", + "sha256:bbea0db94288e29afcc4c28afbf3a7ccaf2d7e027489c449cf7e8f83c6346eb9", + "sha256:bbf1d63eef84b2e8c89011b7f2235b1e0bf7dacc11cac9431fc6468e99ac77fb", + "sha256:c7940c1dc63eb37a67721b10d703247552416f719c4188c54e04334321351ced", + "sha256:c9bf3325c47b11b2e51bca0824ea217c7cd84491d8ac4eefd1e409705ef092bd", + "sha256:cdc8a402aaee9a798b50d8b827d7ecf75edc5fb35ea0f91f213ff927c15f4ff0", + "sha256:ceec1a6bc6cab1d6ff5d06592a91a692f90ec7505d6463a88a52cc0eb58545da", + "sha256:cfe6ab8da05c01ba6fbea630377b5da2cd9bcbc6338510116b01c1bc939a2c18", + "sha256:d099e745a512f7e3bbe7249ca835f4d357c586d78d79ae8f1dcd4d8adeb9bda9", + "sha256:d0ef46024e6a3d79c01ff13801cb19d0cad7fd859b15037aec74315540acc276", + "sha256:d2e5a98f0ec99beb3c10e13b387f8db39106d53993f498b295f0c914328b1333", + "sha256:da4cfb373035def307905d05041c1d06d8936452fe89d464743ae7fb8371078b", + "sha256:da802a19d6e15dffe4b0c24b38b3af68e6c1a68e6e1d8f30148c83864f3881db", + "sha256:dced8146011d2bc2e883f9bd68618b8247387f4bbec46d7392b3c3b032640126", + "sha256:dfdd7c0b105af050eb3d64997809dc21da247cf44e63dc73ff0fd20b96be55a9", + "sha256:e368f200bbc2e4f905b8e71eb38b3c04333bddaa6a2464a6355487b02bb7fb09", + "sha256:e391b1f0a8a5a10ab3b9bb6afcfd74f2175f24f8975fb87ecae700d1503cdee0", + "sha256:e57e563a57fb22a142da34f38acc2fc1a5c864bc29ca1517a88abc963e60d6ec", + "sha256:e5d706eba36b4c4d5bc6c6377bb6568098765e990cfc21ee16d13963fab7b3e7", + "sha256:ec20916e7b4cbfb1f12380e46486ec4bcbaa91a9c448b97023fde0d5bbf9e4ff", + "sha256:f1d072c2eb0ad60d4c183f3fb44ac6f73fb7a8f16a2694a91f988275cbf352f9", + "sha256:f846c260f483d1fd217fe5ed7c173fb109efa6b1fc8381c8b7552c5781756192", + "sha256:f91de7223d4c7b793867797bacd1ee53bfe7359bd70d27b7b58a04efbb9436c8", + "sha256:faae4860798c31530dd184046a900e652c95513796ef51a12bc086710c2eec4d", + "sha256:fc579bf0f502e54926519451b920e875f433aceb4624a3646b3252b5caa9e0b6", + "sha256:fcc700eadbbccbf6bc1bcb9dbe0786b4b1cb91ca0dcda336eef5c2beed37b797", + "sha256:fd32ea360bcbb92d28933fc05ed09bffcb1704ba3fc7942e81db0fd4f81a7892", + "sha256:fdb7adb641a0d13bdcd4ef48e062363d8a9ad4a182ac7647ec88f695e719ae9f" + ], + "markers": "python_version >= '3.7'", + "version": "==1.4.5" + }, + "markdown-it-py": { + "hashes": [ + "sha256:5a35f8d1870171d9acc47b99612dc146129b631baf04970128b568f190d0cc30", + "sha256:7c9a5e412688bc771c67432cbfebcdd686c93ce6484913dccf06cb5a0bea35a1" ], "markers": "python_version >= '3.7'", - "version": "==1.4.4" + "version": "==2.2.0" }, "markupsafe": { "hashes": [ @@ -472,8 +555,11 @@ "sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e", "sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431", "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686", + "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c", "sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559", "sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc", + "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb", + "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939", "sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c", "sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0", "sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4", @@ -481,6 +567,7 @@ "sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575", "sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba", "sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d", + "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd", "sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3", "sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00", "sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155", @@ -489,6 +576,7 @@ "sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f", "sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8", "sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b", + "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007", "sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24", "sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea", "sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198", @@ -496,9 +584,12 @@ "sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee", "sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be", "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2", + "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1", "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707", "sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6", + "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c", "sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58", + "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823", "sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779", "sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636", "sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c", @@ -517,7 +608,9 @@ "sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9", "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57", "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc", - "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2" + "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc", + "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2", + "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11" ], "markers": "python_version >= '3.7'", "version": "==2.1.3" @@ -570,6 +663,14 @@ ], "version": "==0.6.1" }, + "mdurl": { + "hashes": [ + "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", + "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" + ], + "markers": "python_version >= '3.7'", + "version": "==0.1.2" + }, "mock": { "hashes": [ "sha256:122fcb64ee37cfad5b3f48d7a7d51875d7031aaf3d8be7c42e2bee25044eee62", @@ -578,6 +679,14 @@ "index": "pypi", "version": "==4.0.3" }, + "more-itertools": { + "hashes": [ + "sha256:cabaa341ad0389ea83c17a94566a53ae4c9d07349861ecb14dc6d0345cf9ac5d", + "sha256:d2bc7f02446e86a68911e58ded76d6561eea00cddfb2a91e7019bbb586c799f3" + ], + "markers": "python_version >= '3.7'", + "version": "==9.1.0" + }, "mypy": { "hashes": [ "sha256:088cd9c7904b4ad80bec811053272986611b84221835e079be5bcad029e79dd9", @@ -672,19 +781,19 @@ }, "packaging": { "hashes": [ - "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61", - "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f" + "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5", + "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7" ], "markers": "python_version >= '3.7'", - "version": "==23.1" + "version": "==23.2" }, "pathspec": { "hashes": [ - "sha256:2798de800fa92780e33acca925945e9a19a133b715067cf165b8866c15a31687", - "sha256:d8af70af76652554bd134c22b3e8a1cc46ed7d91edcdd721ef1a0c51a84a5293" + "sha256:1d6ed233af05e679efb96b1851550ea95bbb64b7c490b0f5aa52996c11e92a20", + "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3" ], "markers": "python_version >= '3.7'", - "version": "==0.11.1" + "version": "==0.11.2" }, "pillow": { "hashes": [ @@ -768,11 +877,11 @@ }, "platformdirs": { "hashes": [ - "sha256:b0cabcb11063d21a0b261d557acb0a9d2126350e63b70cdf7db6347baea456dc", - "sha256:ca9ed98ce73076ba72e092b23d3c93ea6c4e186b3f1c3dad6edd98ff6ffcca2e" + "sha256:cf8ee52a3afdb965072dcc652433e0c7e3e40cf5ea1477cd4b3b1d2eb75495b3", + "sha256:e9d171d00af68be50e9202731309c4e658fd8bc76f55c11c7dd760d023bda68e" ], "markers": "python_version >= '3.7'", - "version": "==3.8.0" + "version": "==3.11.0" }, "pluggy": { "hashes": [ @@ -844,19 +953,19 @@ }, "pygments": { "hashes": [ - "sha256:a18f47b506a429f6f4b9df81bb02beab9ca21d0a5fee38ed15aef65f0545519f", - "sha256:d66e804411278594d764fc69ec36ec13d9ae9147193a1740cd34d272ca383b8e" + "sha256:13fc09fa63bc8d8671a6d247e1eb303c4b343eaee81d861f3404db2935653692", + "sha256:1daff0494820c69bc8941e407aa20f577374ee88364ee10a98fdbe0aece96e29" ], - "index": "pypi", - "version": "==2.9.0" + "markers": "python_version >= '3.7'", + "version": "==2.16.1" }, "pyparsing": { "hashes": [ - "sha256:d554a96d1a7d3ddaf7183104485bc19fd80543ad6ac5bdb6426719d766fb06c1", - "sha256:edb662d6fe322d6e990b1594b5feaeadf806803359e3d4d42f11e295e588f0ea" + "sha256:32c7c0b711493c72ff18a981d24f28aaf9c1fb7ed5e9667c9e84e3db623bdbfb", + "sha256:ede28a1a32462f5a9705e07aea48001a08f7cf81a021585011deba701581a0db" ], "markers": "python_full_version >= '3.6.8'", - "version": "==3.1.0" + "version": "==3.1.1" }, "pyrsistent": { "hashes": [ @@ -908,11 +1017,11 @@ }, "pytest-asyncio": { "hashes": [ - "sha256:2b38a496aef56f56b0e87557ec313e11e1ab9276fc3863f6a7be0f1d0e415e1b", - "sha256:f2b3366b7cd501a4056858bd39349d5af19742aed2d81660b7998b6341c7eb9c" + "sha256:40a7eae6dded22c7b604986855ea48400ab15b069ae38116e8c01238e9eeb64d", + "sha256:8666c1c8ac02631d7c51ba282e0c69a8a452b211ffedf2599099845da5c5c37b" ], "index": "pypi", - "version": "==0.21.0" + "version": "==0.21.1" }, "pytest-cov": { "hashes": [ @@ -968,16 +1077,16 @@ "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", "version": "==2.8.2" }, "pytz": { "hashes": [ - "sha256:1d8ce29db189191fb55338ee6d0387d82ab59f3d00eac103412d64e0ebd0c588", - "sha256:a151b3abb88eda1d4e34a9814df37de2a80e301e68ba0fd856fb9b46bfbbbffb" + "sha256:7b4fddbeb94a1eba4b557da24f19fdf9db575192544270a9101d8509f9f43d7b", + "sha256:ce42d816b81b68506614c11e8937d3aa9e41007ceb50bfdcb0749b921bf646c7" ], "markers": "python_version < '3.9'", - "version": "==2023.3" + "version": "==2023.3.post1" }, "readme-renderer": { "hashes": [ @@ -1003,12 +1112,28 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.0.0" }, + "rfc3986": { + "hashes": [ + "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", + "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c" + ], + "markers": "python_version >= '3.7'", + "version": "==2.0.0" + }, + "rich": { + "hashes": [ + "sha256:2b38e2fe9ca72c9a00170a1a2d20c63c790d0e10ef1fe35eba76e1e7b1d7d245", + "sha256:5c14d22737e6d5084ef4771b62d5d4363165b403455a30a1c8ca39dc7b644bef" + ], + "markers": "python_version >= '3.7'", + "version": "==13.6.0" + }, "six": { "hashes": [ "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", "version": "==1.16.0" }, "sniffio": { @@ -1124,7 +1249,7 @@ "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2'", "version": "==0.10.2" }, "tomli": { @@ -1135,21 +1260,13 @@ "markers": "python_version < '3.11'", "version": "==2.0.1" }, - "tqdm": { - "hashes": [ - "sha256:1871fb68a86b8fb3b59ca4cdd3dcccbc7e6d613eeed31f4c332531977b89beb5", - "sha256:c4f53a17fe37e132815abceec022631be8ffe1b9381c2e6e30aa70edc99e9671" - ], - "markers": "python_version >= '3.7'", - "version": "==4.65.0" - }, "twine": { "hashes": [ - "sha256:5319dd3e02ac73fcddcd94f035b9631589ab5d23e1f4699d57365199d85261e1", - "sha256:9fe7091715c7576df166df8ef6654e61bada39571783f2fd415bdcba867c6993" + "sha256:929bc3c280033347a00f847236564d1c52a3e61b1ac2516c97c48f3ceab756d8", + "sha256:9e102ef5fdd5a20661eb88fad46338806c3bd32cf1db729603fe3697b1bc83c8" ], "index": "pypi", - "version": "==2.0.0" + "version": "==4.0.2" }, "typed-ast": { "hashes": [ @@ -1184,7 +1301,7 @@ "sha256:f8afcf15cc511ada719a88e013cec87c11aff7b91f019295eb4530f96fe5ef2f", "sha256:fb1bbeac803adea29cedd70781399c99138358c26d05fcbd23c13016b7f5ec65" ], - "markers": "python_version < '3.8' and implementation_name == 'cpython'", + "markers": "python_version < '3.8' and implementation_name == 'cpython' and python_version < '3.8'", "version": "==1.4.3" }, "typeguard": { @@ -1213,19 +1330,19 @@ }, "typing-extensions": { "hashes": [ - "sha256:88a4153d8505aabbb4e13aacb7c486c2b4a33ca3b3f807914a9b4c844c471c26", - "sha256:d91d5919357fe7f681a9f2b5b4cb2a5f1ef0a1e9f59c4d8ff0d3491e05c0ffd5" + "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36", + "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2" ], "index": "pypi", - "version": "==4.6.3" + "version": "==4.7.1" }, "urllib3": { "hashes": [ - "sha256:48e7fafa40319d358848e1bc6809b208340fafe2096f1725d05d67443d0483d1", - "sha256:bee28b5e56addb8226c96f7f13ac28cb4c301dd5ea8a6ca179c0b9835e032825" + "sha256:7a7c7003b000adf9e7ca2a377c9688bbc54ed41b985789ed576570342a375cd2", + "sha256:b19e1a85d206b56d7df1d5e683df4a7725252a964e3993648dd0fb5a1c157564" ], "markers": "python_version >= '3.7'", - "version": "==2.0.3" + "version": "==2.0.6" }, "webencodings": { "hashes": [ @@ -1328,7 +1445,7 @@ "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b", "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556" ], - "markers": "python_version >= '3.7'", + "markers": "python_version < '3.10'", "version": "==3.15.0" } } diff --git a/api/docs/v2/new_advanced_running.rst b/api/docs/v2/new_advanced_running.rst index e642da6e9d1..2f98933a2df 100644 --- a/api/docs/v2/new_advanced_running.rst +++ b/api/docs/v2/new_advanced_running.rst @@ -14,10 +14,7 @@ Jupyter Notebook The Flex and OT-2 run `Jupyter Notebook `_ servers on port 48888, which you can connect to with your web browser. This is a convenient environment for writing and debugging protocols, since you can define different parts of your protocol in different notebook cells and run a single cell at a time. -.. note:: - Currently, the Jupyter Notebook server does not work with Python Protocol API versions 2.14 and 2.15. It does work with API versions 2.13 and earlier. Use the Opentrons App to run protocols that require functionality added in newer versions. - -Access your robot's Jupyter Notebook by either: +Access your robot’s Jupyter Notebook by either: - Going to the **Advanced** tab of Robot Settings and clicking **Launch Jupyter Notebook**. - Going directly to ``http://:48888`` in your web browser (if you know your robot's IP address). @@ -32,9 +29,10 @@ Jupyter Notebook is structured around `cells`: discrete chunks of code that can Rather than writing a ``run`` function and embedding commands within it, start your notebook by importing ``opentrons.execute`` and calling :py:meth:`opentrons.execute.get_protocol_api`. This function also replaces the ``metadata`` block of a standalone protocol by taking the minimum :ref:`API version ` as its argument. Then you can call :py:class:`~opentrons.protocol_api.ProtocolContext` methods in subsequent lines or cells: .. code-block:: python + :substitutions: import opentrons.execute - protocol = opentrons.execute.get_protocol_api("2.13") + protocol = opentrons.execute.get_protocol_api('|apiLevel|') protocol.home() The first command you execute should always be :py:meth:`~opentrons.protocol_api.ProtocolContext.home`. If you try to execute other commands first, you will get a ``MustHomeError``. (When running protocols through the Opentrons App, the robot homes automatically.) @@ -57,8 +55,9 @@ You can also use Jupyter to run a protocol that you have already written. To do Since a typical protocol only `defines` the ``run`` function but doesn't `call` it, this won't immediately cause the robot to move. To begin the run, instantiate a :py:class:`.ProtocolContext` and pass it to the ``run`` function you just defined: .. code-block:: python + :substitutions: - protocol = opentrons.execute.get_protocol_api("2.13") + protocol = opentrons.execute.get_protocol_api('|apiLevel|') run(protocol) # your protocol will now run .. _using_lpc: diff --git a/api/docs/v2/versioning.rst b/api/docs/v2/versioning.rst index b52327c0075..f635a84812f 100644 --- a/api/docs/v2/versioning.rst +++ b/api/docs/v2/versioning.rst @@ -59,11 +59,6 @@ When choosing an API level, consider what features you need and how widely you p On the one hand, using the highest available version will give your protocol access to all the latest :ref:`features and fixes `. On the other hand, using the lowest possible version lets the protocol work on a wider range of robot software versions. For example, a protocol that uses the Heater-Shaker and specifies version 2.13 of the API should work equally well on a robot running version 6.1.0 or 6.2.0 of the robot software. Specifying version 2.14 would limit the protocol to robots running 6.2.0 or higher. -.. note:: - - Python protocols with an ``apiLevel`` of 2.14 or higher can't currently be simulated with the ``opentrons_simulate`` command-line tool, the :py:func:`opentrons.simulate.simulate` function, or the :py:func:`opentrons.simulate.get_protocol_api` function. If your protocol doesn't rely on new functionality added after version 2.13, use a lower ``apiLevel``. For protocols that require 2.14 or higher, analyze your protocol with the Opentrons App instead. - - Maximum Supported Versions ========================== diff --git a/api/release-notes.md b/api/release-notes.md index 8251770fad6..ac6ab08ae02 100644 --- a/api/release-notes.md +++ b/api/release-notes.md @@ -6,6 +6,18 @@ log][]. For a list of currently known issues, please see the [Opentrons issue tr --- +## Opentrons Robot Software Changes in 7.0.1 + +Welcome to the v7.0.1 release of the Opentrons robot software! This release builds on the major release that added support for Opentrons Flex. + +This update may take longer than usual if you are updating from v6.x. Allow **approximately 15 minutes** for your robot to restart. This delay will only happen once. + +### Known Issues + +JSON protocols created or modified with Protocol Designer v6.0.0 or higher can't be simulated with the `opentrons_simulate` command-line tool. + +--- + ## Opentrons Robot Software Changes in 7.0.0 Welcome to the v7.0.0 release of the Opentrons robot software! This release adds support for the Opentrons Flex robot, instruments, modules, and labware. @@ -21,7 +33,7 @@ Flex touchscreen - Manage instruments: View information about connected pipettes and the gripper. Attach, detach, or recalibrate instruments. - Robot settings: Customize the behavior of your Flex, including the LED and touchscreen displays. -Flex features +Flex features - Analyze and run protocols that use the Flex robot, Flex pipettes, and Flex tip racks. - Move labware around the deck automatically with the Flex Gripper. @@ -32,6 +44,7 @@ Python API features - Manually move labware around, off of, or onto the deck without ending your protocol. - Load adapters separately from labware (to allow moving labware onto or off of the adapter). - Use coordinate or numeric deck slot names interchangeably. +- Set 50 µL pipettes to a low-volume mode for handling very small quantities of liquid. ### Improved Features @@ -40,6 +53,7 @@ Python API features ### Bug Fixes +- Fixed a problem with empty files being stored in the robot's database if the robot is power cycled at the wrong time. - The API no longer raises an error when dropping tips into labware other than the fixed trash. - All API versions now properly track tips, including starting at a well other than A1. diff --git a/api/src/opentrons/config/feature_flags.py b/api/src/opentrons/config/feature_flags.py index f46674fb56e..5bf289a49d2 100644 --- a/api/src/opentrons/config/feature_flags.py +++ b/api/src/opentrons/config/feature_flags.py @@ -20,10 +20,8 @@ def use_old_aspiration_functions() -> bool: ) -def enable_door_safety_switch() -> bool: - return advs.get_setting_with_env_overload( - "enableDoorSafetySwitch", RobotTypeEnum.FLEX - ) +def enable_door_safety_switch(robot_type: RobotTypeEnum) -> bool: + return advs.get_setting_with_env_overload("enableDoorSafetySwitch", robot_type) def disable_fast_protocol_upload() -> bool: diff --git a/api/src/opentrons/execute.py b/api/src/opentrons/execute.py index 709cd2c6c69..15f0acf84c2 100644 --- a/api/src/opentrons/execute.py +++ b/api/src/opentrons/execute.py @@ -11,15 +11,12 @@ import contextlib import logging import os -from pathlib import Path import sys -import tempfile from typing import ( TYPE_CHECKING, BinaryIO, Callable, Dict, - Generator, List, Optional, TextIO, @@ -33,11 +30,8 @@ from opentrons.commands import types as command_types -from opentrons.config import IS_ROBOT, JUPYTER_NOTEBOOK_LABWARE_DIR - from opentrons.hardware_control import ( API as OT2API, - HardwareControlAPI, ThreadManagedHardware, ThreadManager, ) @@ -61,23 +55,16 @@ Config, DeckType, EngineStatus, - ErrorOccurrence as ProtocolEngineErrorOccurrence, - command_monitor as pe_command_monitor, create_protocol_engine, create_protocol_engine_in_thread, ) from opentrons.protocol_engine.types import PostRunHardwareState -from opentrons.protocol_reader import ProtocolReader, ProtocolSource +from opentrons.protocol_reader import ProtocolSource from opentrons.protocol_runner import create_protocol_runner -from .util.entrypoint_util import ( - FoundLabware, - labware_from_paths, - datafiles_from_paths, - copy_file_like, -) +from .util import entrypoint_util if TYPE_CHECKING: from opentrons_shared_data.labware.dev_types import ( @@ -124,6 +111,9 @@ def get_protocol_api( bundled_labware: Optional[Dict[str, "LabwareDefinitionDict"]] = None, bundled_data: Optional[Dict[str, bytes]] = None, extra_labware: Optional[Dict[str, "LabwareDefinitionDict"]] = None, + # If you add any more arguments here, make sure they're kw-only to make mistakes harder in + # environments without type checking, like Jupyter Notebook. + # * ) -> protocol_api.ProtocolContext: """ Build and return a ``protocol_api.ProtocolContext`` @@ -170,7 +160,8 @@ def get_protocol_api( if extra_labware is None: extra_labware = { - uri: details.definition for uri, details in _get_jupyter_labware().items() + uri: details.definition + for uri, details in (entrypoint_util.find_jupyter_labware() or {}).items() } robot_type = _get_robot_type() @@ -189,6 +180,8 @@ def get_protocol_api( ) else: if bundled_labware is not None: + # Protocol Engine has a deep assumption that standard labware definitions are always + # implicitly loadable. raise NotImplementedError( f"The bundled_labware argument is not currently supported for Python protocols" f" with apiLevel {ENGINE_CORE_API_VERSION} or newer." @@ -196,7 +189,7 @@ def get_protocol_api( context = _create_live_context_pe( api_version=checked_version, robot_type=robot_type, - deck_type=guess_deck_type_from_global_config(), + deck_type=deck_type, hardware_api=_THREAD_MANAGED_HW, # type: ignore[arg-type] bundled_data=bundled_data, extra_labware=extra_labware, @@ -361,18 +354,20 @@ def execute( # noqa: C901 stack_logger.propagate = propagate_logs stack_logger.setLevel(getattr(logging, log_level.upper(), logging.WARNING)) - contents = protocol_file.read() - + # TODO(mm, 2023-10-02): Switch this truthy check to `is not None` + # to match documented behavior. + # See notes in https://github.com/Opentrons/opentrons/pull/13107 if custom_labware_paths: - extra_labware = labware_from_paths(custom_labware_paths) + extra_labware = entrypoint_util.labware_from_paths(custom_labware_paths) else: - extra_labware = _get_jupyter_labware() + extra_labware = entrypoint_util.find_jupyter_labware() or {} if custom_data_paths: - extra_data = datafiles_from_paths(custom_data_paths) + extra_data = entrypoint_util.datafiles_from_paths(custom_data_paths) else: extra_data = {} + contents = protocol_file.read() try: protocol = parse.parse( contents, @@ -394,6 +389,8 @@ def execute( # noqa: C901 # Guard against trying to run protocols for the wrong robot type. # This matches what robot-server does. + # FIXME: This exposes the internal strings "OT-2 Standard" and "OT-3 Standard". + # https://opentrons.atlassian.net/browse/RSS-370 if protocol.robot_type != _get_robot_type(): raise RuntimeError( f'This robot is of type "{_get_robot_type()}",' @@ -415,10 +412,8 @@ def execute( # noqa: C901 ) protocol_file.seek(0) _run_file_pe( - protocol_file=protocol_file, - protocol_name=protocol_name, - extra_labware=extra_labware, - hardware_api=_get_global_hardware_controller(_get_robot_type()).wrapped(), + protocol=protocol, + hardware_api=_get_global_hardware_controller(_get_robot_type()), emit_runlog=emit_runlog, ) @@ -486,8 +481,8 @@ def main() -> int: emit_runlog=printer, ) return 0 - except _ProtocolEngineExecuteError as error: - # _ProtocolEngineExecuteError is a wrapper that's meaningless to the CLI user. + except entrypoint_util.ProtocolEngineExecuteError as error: + # This exception is a wrapper that's meaningless to the CLI user. # Take the actual protocol problem out of it and just print that. print(error.to_stderr_string(), file=sys.stderr) return 1 @@ -495,40 +490,6 @@ def main() -> int: # Just let Python show a traceback. -class _ProtocolEngineExecuteError(Exception): - def __init__(self, errors: List[ProtocolEngineErrorOccurrence]) -> None: - """Raised when there was any fatal error running a protocol through Protocol Engine. - - Protocol Engine reports errors as data, not as exceptions. - But the only way for `execute()` to signal problems to its caller is to raise something. - So we need this class to wrap them. - - Params: - errors: The errors that Protocol Engine reported. - """ - # Show the full error details if this is part of a traceback. Don't try to summarize. - super().__init__(errors) - self._error_occurrences = errors - - def to_stderr_string(self) -> str: - """Return a string suitable as the stderr output of the `opentrons_execute` CLI. - - This summarizes from the full error details. - """ - # It's unclear what exactly we should extract here. - # - # First, do we print the first element, or the last, or all of them? - # - # Second, do we print the .detail? .errorCode? .errorInfo? .wrappedErrors? - # By contract, .detail seems like it would be insufficient, but experimentally, - # it includes a lot, like: - # - # ProtocolEngineError [line 3]: Error 4000 GENERAL_ERROR (ProtocolEngineError): - # UnexpectedProtocolError: Labware "fixture_12_trough" not found with version 1 - # in namespace "fixture". - return self._error_occurrences[0].detail - - def _create_live_context_non_pe( api_version: APIVersion, hardware_api: ThreadManagedHardware, @@ -633,45 +594,40 @@ def _run_file_non_pe( def _run_file_pe( - protocol_file: Union[BinaryIO, TextIO], - protocol_name: str, - extra_labware: Dict[str, FoundLabware], - hardware_api: HardwareControlAPI, + protocol: Protocol, + hardware_api: ThreadManagedHardware, emit_runlog: Optional[_EmitRunlogCallable], ) -> None: """Run a protocol file with Protocol Engine.""" - def send_command_to_emit_runlog(event: pe_command_monitor.Event) -> None: - if emit_runlog is not None: - emit_runlog(_adapt_command(event)) - async def run(protocol_source: ProtocolSource) -> None: protocol_engine = await create_protocol_engine( - hardware_api=hardware_api, + hardware_api=hardware_api.wrapped(), config=_get_protocol_engine_config(), ) protocol_runner = create_protocol_runner( protocol_config=protocol_source.config, protocol_engine=protocol_engine, - hardware_api=hardware_api, + hardware_api=hardware_api.wrapped(), ) - with pe_command_monitor.monitor_commands( - protocol_engine, callback=send_command_to_emit_runlog - ): + unsubscribe = protocol_runner.broker.subscribe( + "command", lambda event: emit_runlog(event) if emit_runlog else None + ) + try: # TODO(mm, 2023-06-30): This will home and drop tips at the end, which is not how # things have historically behaved with PAPIv2.13 and older or JSONv5 and older. result = await protocol_runner.run(protocol_source) + finally: + unsubscribe() if result.state_summary.status != EngineStatus.SUCCEEDED: - raise _ProtocolEngineExecuteError(result.state_summary.errors) + raise entrypoint_util.ProtocolEngineExecuteError( + result.state_summary.errors + ) - with _adapt_protocol_source( - protocol_file=protocol_file, - protocol_name=protocol_name, - extra_labware=extra_labware, - ) as protocol_source: + with entrypoint_util.adapt_protocol_source(protocol) as protocol_source: asyncio.run(run(protocol_source)) @@ -691,76 +647,6 @@ def _get_protocol_engine_config() -> Config: ) -def _get_jupyter_labware() -> Dict[str, FoundLabware]: - """Return labware files in this robot's Jupyter Notebook directory.""" - if IS_ROBOT: - # JUPYTER_NOTEBOOK_LABWARE_DIR should never be None when IS_ROBOT == True. - assert JUPYTER_NOTEBOOK_LABWARE_DIR is not None - if JUPYTER_NOTEBOOK_LABWARE_DIR.is_dir(): - return labware_from_paths([JUPYTER_NOTEBOOK_LABWARE_DIR]) - - return {} - - -@contextlib.contextmanager -def _adapt_protocol_source( - protocol_file: Union[BinaryIO, TextIO], - protocol_name: str, - extra_labware: Dict[str, FoundLabware], -) -> Generator[ProtocolSource, None, None]: - """Create a `ProtocolSource` representing input protocol files.""" - with tempfile.TemporaryDirectory() as temporary_directory: - # It's not well-defined in our customer-facing interfaces whether the supplied protocol_name - # should be just the filename part, or a path with separators. In case it contains stuff - # like "../", sanitize it to just the filename part so we don't save files somewhere bad. - safe_protocol_name = Path(protocol_name).name - - temp_protocol_file = Path(temporary_directory) / safe_protocol_name - - # FIXME(mm, 2023-06-26): Copying this file is pure overhead, and it introduces encoding - # hazards. Remove this when we can parse JSONv6+ and PAPIv2.14+ protocols without going - # through the filesystem. https://opentrons.atlassian.net/browse/RSS-281 - copy_file_like(source=protocol_file, destination=temp_protocol_file) - - custom_labware_files = [labware.path for labware in extra_labware.values()] - - protocol_source = asyncio.run( - ProtocolReader().read_saved( - files=[temp_protocol_file] + custom_labware_files, - directory=None, - files_are_prevalidated=False, - ) - ) - - yield protocol_source - - -def _adapt_command(event: pe_command_monitor.Event) -> command_types.CommandMessage: - """Convert a Protocol Engine command event to an old-school command_types.CommandMesage.""" - before_or_after: command_types.MessageSequenceId = ( - "before" if isinstance(event, pe_command_monitor.RunningEvent) else "after" - ) - - message: command_types.CommentMessage = { - # TODO(mm, 2023-09-26): If we can without breaking the public API, remove the requirement - # to supply a "name" here. If we can't do that, consider adding a special name value - # so we don't have to lie and call every command a comment. - "name": "command.COMMENT", - "id": event.command.id, - "$": before_or_after, - # TODO(mm, 2023-09-26): Convert this machine-readable JSON into a human-readable message - # to match behavior from before Protocol Engine. - # https://opentrons.atlassian.net/browse/RSS-320 - "payload": {"text": event.command.json()}, - # As far as I know, "error" is not part of the public-facing API, so it doesn't matter - # what we put here. Leaving it as `None` to avoid difficulties in converting between - # the Protocol Engine `ErrorOccurrence` model and the regular Python `Exception` type - # that this field expects. - "error": None, - } - return message - - def _get_global_hardware_controller(robot_type: RobotType) -> ThreadManagedHardware: # Build a hardware controller in a worker thread, which is necessary # because ipython runs its notebook in asyncio but the notebook diff --git a/api/src/opentrons/hardware_control/api.py b/api/src/opentrons/hardware_control/api.py index cce958663c4..3aa91eaae5e 100644 --- a/api/src/opentrons/hardware_control/api.py +++ b/api/src/opentrons/hardware_control/api.py @@ -286,6 +286,9 @@ async def build_hardware_simulator( def __repr__(self) -> str: return "<{} using backend {}>".format(type(self), type(self._backend)) + async def get_serial_number(self) -> Optional[str]: + return await self._backend.get_serial_number() + @property def loop(self) -> asyncio.AbstractEventLoop: """The event loop used by this instance.""" @@ -509,7 +512,7 @@ async def stop(self, home_after: bool = True) -> None: robot. After this call, no further recovery is necessary. """ await self._backend.halt() # calls smoothie_driver.kill() - await self._execution_manager.cancel() + await self.cancel_execution_and_running_tasks() self._log.info("Recovering from halt") await self.reset() await self.cache_instruments() @@ -517,6 +520,15 @@ async def stop(self, home_after: bool = True) -> None: if home_after: await self.home() + def is_movement_execution_taskified(self) -> bool: + return self.taskify_movement_execution + + def should_taskify_movement_execution(self, taskify: bool) -> None: + self.taskify_movement_execution = taskify + + async def cancel_execution_and_running_tasks(self) -> None: + await self._execution_manager.cancel() + async def reset(self) -> None: """Reset the stored state of the system.""" self._pause_manager.reset() diff --git a/api/src/opentrons/hardware_control/backends/controller.py b/api/src/opentrons/hardware_control/backends/controller.py index b029e1a15c4..5525dce3105 100644 --- a/api/src/opentrons/hardware_control/backends/controller.py +++ b/api/src/opentrons/hardware_control/backends/controller.py @@ -16,6 +16,7 @@ cast, ) from typing_extensions import Final +from pathlib import Path try: import aionotify # type: ignore[import] @@ -132,6 +133,12 @@ def module_controls(self) -> AttachedModulesControl: def module_controls(self, module_controls: AttachedModulesControl) -> None: self._module_controls = module_controls + async def get_serial_number(self) -> Optional[str]: + try: + return Path("/var/serial").read_text().strip() + except OSError: + return None + def start_gpio_door_watcher( self, loop: asyncio.AbstractEventLoop, diff --git a/api/src/opentrons/hardware_control/backends/ot3controller.py b/api/src/opentrons/hardware_control/backends/ot3controller.py index 2f569521ba2..00afbd992f2 100644 --- a/api/src/opentrons/hardware_control/backends/ot3controller.py +++ b/api/src/opentrons/hardware_control/backends/ot3controller.py @@ -317,6 +317,11 @@ def __init__( ) self._current_settings: Optional[OT3AxisMap[CurrentConfig]] = None + async def get_serial_number(self) -> Optional[str]: + if not self.initialized: + return None + return self.eeprom_data.serial_number + @property def initialized(self) -> bool: """True when the hardware controller has initialized and is ready.""" diff --git a/api/src/opentrons/hardware_control/backends/ot3simulator.py b/api/src/opentrons/hardware_control/backends/ot3simulator.py index 50493b7d2da..fc769e2023d 100644 --- a/api/src/opentrons/hardware_control/backends/ot3simulator.py +++ b/api/src/opentrons/hardware_control/backends/ot3simulator.py @@ -190,6 +190,9 @@ def _sanitize_attached_instrument( self._current_settings: Optional[OT3AxisMap[CurrentConfig]] = None self._sim_jaw_state = GripperJawState.HOMED_READY + async def get_serial_number(self) -> Optional[str]: + return "simulator" + @property def initialized(self) -> bool: """True when the hardware controller has initialized and is ready.""" diff --git a/api/src/opentrons/hardware_control/backends/simulator.py b/api/src/opentrons/hardware_control/backends/simulator.py index 38b27778e86..d8bca2db353 100644 --- a/api/src/opentrons/hardware_control/backends/simulator.py +++ b/api/src/opentrons/hardware_control/backends/simulator.py @@ -169,6 +169,9 @@ def _sanitize_attached_instrument( # to the hardware api controller. self._module_controls: Optional[AttachedModulesControl] = None + async def get_serial_number(self) -> Optional[str]: + return "simulator" + @property def gpio_chardev(self) -> GPIODriverLike: return self._gpio_chardev diff --git a/api/src/opentrons/hardware_control/execution_manager.py b/api/src/opentrons/hardware_control/execution_manager.py index 0e051799fbc..5ad0f45912c 100644 --- a/api/src/opentrons/hardware_control/execution_manager.py +++ b/api/src/opentrons/hardware_control/execution_manager.py @@ -92,6 +92,15 @@ class ExecutionManagerProvider: def __init__(self, simulator: bool) -> None: self._em_simulate = simulator self._execution_manager = ExecutionManager() + self._taskify_movement_execution: bool = False + + @property + def taskify_movement_execution(self) -> bool: + return self._taskify_movement_execution + + @taskify_movement_execution.setter + def taskify_movement_execution(self, cancellable: bool) -> None: + self._taskify_movement_execution = cancellable @property def execution_manager(self) -> ExecutionManager: @@ -125,7 +134,18 @@ async def replace( ) -> DecoratedReturn: if not inst._em_simulate: await inst.execution_manager.wait_for_is_running() - return await decorated(inst, *args, **kwargs) + if inst.taskify_movement_execution: + # Running these functions inside cancellable tasks makes it easier and + # faster to cancel protocol runs. In the higher, runner & engine layers, + # a cancellation request triggers cancellation of the running move task + # and hence, prevents any further communication with hardware. + decorated_task: "asyncio.Task[DecoratedReturn]" = asyncio.create_task( + decorated(inst, *args, **kwargs) + ) + inst.execution_manager.register_cancellable_task(decorated_task) + return await decorated_task + else: + return await decorated(inst, *args, **kwargs) return cast(DecoratedMethodReturningValue, replace) 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 cfac9b3ccb4..dba4e253da6 100644 --- a/api/src/opentrons/hardware_control/instruments/ot2/pipette_handler.py +++ b/api/src/opentrons/hardware_control/instruments/ot2/pipette_handler.py @@ -262,6 +262,14 @@ def get_attached_instrument(self, mount: MountType) -> PipetteDict: def attached_instruments(self) -> Dict[MountType, PipetteDict]: return self.get_attached_instruments() + @property + def attached_pipettes(self) -> Dict[MountType, PipetteDict]: + return self.get_attached_instruments() + + @property + def get_attached_pipettes(self) -> Dict[MountType, PipetteDict]: + return self.get_attached_instruments() + @property def hardware_instruments(self) -> InstrumentsByMount[MountType]: """Do not write new code that uses this.""" diff --git a/api/src/opentrons/hardware_control/module_control.py b/api/src/opentrons/hardware_control/module_control.py index 20b8017a262..d7c5f391ea1 100644 --- a/api/src/opentrons/hardware_control/module_control.py +++ b/api/src/opentrons/hardware_control/module_control.py @@ -231,10 +231,10 @@ def get_module_by_module_id( return found_module def load_module_offset( - self, module_type: ModuleType, module_id: str, slot: Optional[str] = None - ) -> ModuleCalibrationOffset: + self, module_type: ModuleType, module_id: str + ) -> Optional[ModuleCalibrationOffset]: log.info(f"Loading module offset for {module_type} {module_id}") - return load_module_calibration_offset(module_type, module_id, slot) + return load_module_calibration_offset(module_type, module_id) def save_module_offset( self, @@ -244,9 +244,9 @@ def save_module_offset( slot: str, offset: Point, instrument_id: Optional[str] = None, - ) -> ModuleCalibrationOffset: + ) -> Optional[ModuleCalibrationOffset]: log.info(f"Saving module {module} {module_id} offset: {offset} for slot {slot}") save_module_calibration_offset( offset, mount, slot, module, module_id, instrument_id ) - return load_module_calibration_offset(module, module_id, slot) + return load_module_calibration_offset(module, module_id) diff --git a/api/src/opentrons/hardware_control/modules/module_calibration.py b/api/src/opentrons/hardware_control/modules/module_calibration.py index 9fcafd28e08..3976a6c8816 100644 --- a/api/src/opentrons/hardware_control/modules/module_calibration.py +++ b/api/src/opentrons/hardware_control/modules/module_calibration.py @@ -9,7 +9,6 @@ save_module_calibration, ) from opentrons.calibration_storage.types import SourceType -from opentrons.config.robot_configs import default_module_calibration_offset from opentrons.hardware_control.modules.types import ModuleType from opentrons.hardware_control.types import OT3Mount @@ -26,7 +25,7 @@ class ModuleCalibrationOffset: module: ModuleType source: SourceType status: CalibrationStatus - slot: Optional[str] = None + slot: str mount: Optional[OT3Mount] = None instrument_id: Optional[str] = None last_modified: Optional[datetime] = None @@ -35,37 +34,26 @@ class ModuleCalibrationOffset: def load_module_calibration_offset( module_type: ModuleType, module_id: str, - slot: Optional[str] = None, -) -> ModuleCalibrationOffset: +) -> Optional[ModuleCalibrationOffset]: """Loads the calibration offset for a module.""" - # load default if module offset data do not exist - module_cal_obj = ModuleCalibrationOffset( - slot=slot, - offset=Point(*default_module_calibration_offset()), + module_offset_data = get_module_offset(module_type, module_id) + if not module_offset_data: + return None + return ModuleCalibrationOffset( module=module_type, module_id=module_id, - source=SourceType.default, - status=CalibrationStatus(), + slot=module_offset_data.slot, + mount=module_offset_data.mount, + offset=module_offset_data.offset, + last_modified=module_offset_data.lastModified, + instrument_id=module_offset_data.instrument_id, + source=module_offset_data.source, + status=CalibrationStatus( + markedAt=module_offset_data.status.markedAt, + markedBad=module_offset_data.status.markedBad, + source=module_offset_data.status.source, + ), ) - if module_id: - module_offset_data = get_module_offset(module_type, module_id) - if module_offset_data: - return ModuleCalibrationOffset( - module=module_type, - module_id=module_id, - slot=module_offset_data.slot, - mount=module_offset_data.mount, - offset=module_offset_data.offset, - last_modified=module_offset_data.lastModified, - instrument_id=module_offset_data.instrument_id, - source=module_offset_data.source, - status=CalibrationStatus( - markedAt=module_offset_data.status.markedAt, - markedBad=module_offset_data.status.markedBad, - source=module_offset_data.status.source, - ), - ) - return module_cal_obj def save_module_calibration_offset( @@ -77,8 +65,7 @@ def save_module_calibration_offset( instrument_id: Optional[str] = None, ) -> None: """Save the calibration offset for a given module.""" - if module_id: - save_module_calibration(offset, mount, slot, module, module_id, instrument_id) + save_module_calibration(offset, mount, slot, module, module_id, instrument_id) def load_all_module_calibrations() -> List[ModuleCalibrationOffset]: diff --git a/api/src/opentrons/hardware_control/ot3api.py b/api/src/opentrons/hardware_control/ot3api.py index 83054c9e192..db896bc3ef8 100644 --- a/api/src/opentrons/hardware_control/ot3api.py +++ b/api/src/opentrons/hardware_control/ot3api.py @@ -285,6 +285,9 @@ async def set_gantry_load(self, gantry_load: GantryLoad) -> None: ) await self._backend.update_to_default_current_settings(gantry_load) + async def get_serial_number(self) -> Optional[str]: + return await self._backend.get_serial_number() + async def set_system_constraints_for_calibration(self) -> None: self._move_manager.update_constraints( get_system_constraints_for_calibration( @@ -762,12 +765,17 @@ async def _chained_calls() -> None: asyncio.run_coroutine_threadsafe(_chained_calls(), self._loop) + def is_movement_execution_taskified(self) -> bool: + return self.taskify_movement_execution + + def should_taskify_movement_execution(self, taskify: bool) -> None: + self.taskify_movement_execution = taskify + async def _stop_motors(self) -> None: """Immediately stop motors.""" await self._backend.halt() - async def _cancel_execution_and_running_tasks(self) -> None: - """Cancel execution manager and all running (hardware module) tasks.""" + async def cancel_execution_and_running_tasks(self) -> None: await self._execution_manager.cancel() async def halt(self, disengage_before_stopping: bool = False) -> None: @@ -781,7 +789,7 @@ async def halt(self, disengage_before_stopping: bool = False) -> None: async def stop(self, home_after: bool = True) -> None: """Stop motion as soon as possible, reset, and optionally home.""" await self._stop_motors() - await self._cancel_execution_and_running_tasks() + await self.cancel_execution_and_running_tasks() self._log.info("Resetting OT3API") await self.reset() if home_after: @@ -2225,11 +2233,15 @@ async def save_module_offset( self._log.warning(f"Could not save calibration: unknown module {module_id}") return None # TODO (ba, 2023-03-22): gripper_id and pipette_id should probably be combined to instrument_id - instrument_id = None - if self._gripper_handler.has_gripper(): - instrument_id = self._gripper_handler.get_gripper().gripper_id - elif self._pipette_handler.has_pipette(mount): + if self._pipette_handler.has_pipette(mount): instrument_id = self._pipette_handler.get_pipette(mount).pipette_id + elif mount == OT3Mount.GRIPPER and self._gripper_handler.has_gripper(): + instrument_id = self._gripper_handler.get_gripper().gripper_id + else: + self._log.warning( + f"Could not save calibration: no instrument found for {mount}" + ) + return None module_type = module.MODULE_TYPE self._log.info( f"Saving module offset: {offset} for module {module_type.name} {module_id}." diff --git a/api/src/opentrons/hardware_control/protocols/hardware_manager.py b/api/src/opentrons/hardware_control/protocols/hardware_manager.py index 2227c792c74..ee0228ae3b8 100644 --- a/api/src/opentrons/hardware_control/protocols/hardware_manager.py +++ b/api/src/opentrons/hardware_control/protocols/hardware_manager.py @@ -1,4 +1,4 @@ -from typing import Dict +from typing import Dict, Optional from typing_extensions import Protocol from ..types import SubSystem, SubSystemState @@ -41,3 +41,7 @@ def attached_subsystems(self) -> Dict[SubSystem, SubSystemState]: whether or not the hardware is operating properly. """ ... + + async def get_serial_number(self) -> Optional[str]: + """Get the robot serial number, if provisioned. If not provisioned, will be None.""" + ... diff --git a/api/src/opentrons/hardware_control/protocols/instrument_configurer.py b/api/src/opentrons/hardware_control/protocols/instrument_configurer.py index b4b95627321..820757e5e6b 100644 --- a/api/src/opentrons/hardware_control/protocols/instrument_configurer.py +++ b/api/src/opentrons/hardware_control/protocols/instrument_configurer.py @@ -77,6 +77,18 @@ def get_attached_instrument(self, mount: Mount) -> PipetteDict: def attached_instruments(self) -> Dict[Mount, PipetteDict]: return self.get_attached_instruments() + def get_attached_pipettes(self) -> Dict[Mount, PipetteDict]: + """Get the status dicts of cached attached pipettes. + + Works like get_attached_instruments but for pipettes only - on the Flex, + there will be no gripper information here. + """ + ... + + @property + def attached_pipettes(self) -> Dict[Mount, PipetteDict]: + return self.get_attached_pipettes() + def calibrate_plunger( self, mount: Mount, diff --git a/api/src/opentrons/hardware_control/protocols/motion_controller.py b/api/src/opentrons/hardware_control/protocols/motion_controller.py index ba2e4913f60..8d89bb7abc1 100644 --- a/api/src/opentrons/hardware_control/protocols/motion_controller.py +++ b/api/src/opentrons/hardware_control/protocols/motion_controller.py @@ -213,3 +213,15 @@ async def retract(self, mount: Mount, margin: float = 10) -> None: async def retract_axis(self, axis: Axis) -> None: """Retract the specified axis to its home position.""" ... + + def is_movement_execution_taskified(self) -> bool: + """Get whether move functions are being executed inside cancellable tasks.""" + ... + + def should_taskify_movement_execution(self, taskify: bool) -> None: + """Specify whether move functions should be executed inside cancellable tasks.""" + ... + + async def cancel_execution_and_running_tasks(self) -> None: + """Cancel all tasks and set execution manager state to Cancelled.""" + ... diff --git a/api/src/opentrons/hardware_control/types.py b/api/src/opentrons/hardware_control/types.py index b99f6a17e6c..0c22a5145a3 100644 --- a/api/src/opentrons/hardware_control/types.py +++ b/api/src/opentrons/hardware_control/types.py @@ -8,8 +8,6 @@ MODULE_LOG = logging.getLogger(__name__) -MachineType = Literal["ot2", "ot3"] - class MotionChecks(enum.Enum): NONE = 0 diff --git a/api/src/opentrons/protocol_api/core/engine/stringify.py b/api/src/opentrons/protocol_api/core/engine/stringify.py new file mode 100644 index 00000000000..fd4a90817cd --- /dev/null +++ b/api/src/opentrons/protocol_api/core/engine/stringify.py @@ -0,0 +1,56 @@ +from opentrons.protocol_engine.clients.sync_client import SyncClient +from opentrons.protocol_engine.types import ( + DeckSlotLocation, + LabwareLocation, + ModuleLocation, + OnLabwareLocation, +) + + +def well(engine_client: SyncClient, well_name: str, labware_id: str) -> str: + """Return a human-readable string representing a well and its location. + + For example: "A1 of My Cool Labware on C2". + """ + labware_location = OnLabwareLocation(labwareId=labware_id) + return f"{well_name} of {_labware_location_string(engine_client, labware_location)}" + + +def _labware_location_string( + engine_client: SyncClient, location: LabwareLocation +) -> str: + if isinstance(location, DeckSlotLocation): + # TODO(mm, 2023-10-11): + # Ideally, we might want to use the display name specified by the deck definition? + return f"slot {location.slotName.id}" + + elif isinstance(location, ModuleLocation): + module_name = engine_client.state.modules.get_definition( + module_id=location.moduleId + ).displayName + module_on = engine_client.state.modules.get_location( + module_id=location.moduleId + ) + module_on_string = _labware_location_string(engine_client, module_on) + return f"{module_name} on {module_on_string}" + + elif isinstance(location, OnLabwareLocation): + labware_name = _labware_name(engine_client, location.labwareId) + labware_on = engine_client.state.labware.get_location( + labware_id=location.labwareId + ) + labware_on_string = _labware_location_string(engine_client, labware_on) + return f"{labware_name} on {labware_on_string}" + + elif location == "offDeck": + return "[off-deck]" + + +def _labware_name(engine_client: SyncClient, labware_id: str) -> str: + """Return the user-specified labware label, or fall back to the display name from the def.""" + user_name = engine_client.state.labware.get_display_name(labware_id=labware_id) + definition_name = engine_client.state.labware.get_definition( + labware_id=labware_id + ).metadata.displayName + + return user_name if user_name is not None else definition_name diff --git a/api/src/opentrons/protocol_api/core/engine/well.py b/api/src/opentrons/protocol_api/core/engine/well.py index 487101d3600..42f6f483f33 100644 --- a/api/src/opentrons/protocol_api/core/engine/well.py +++ b/api/src/opentrons/protocol_api/core/engine/well.py @@ -9,6 +9,7 @@ from opentrons.types import Point from . import point_calculations +from . import stringify from ..well import AbstractWellCore from ..._liquid import Liquid @@ -72,9 +73,12 @@ def set_has_tip(self, value: bool) -> None: ) def get_display_name(self) -> str: - """Get the well's full display name.""" - parent = self._engine_client.state.labware.get_display_name(self._labware_id) - return f"{self._name} of {parent}" + """Get the full display name of the well (e.g. "A1 of Some Labware on 5").""" + return stringify.well( + engine_client=self._engine_client, + well_name=self._name, + labware_id=self._labware_id, + ) def get_name(self) -> str: """Get the name of the well (e.g. "A1").""" 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 c050cd1203b..81780f1006a 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 @@ -75,7 +75,7 @@ def set_has_tip(self, value: bool) -> None: self._has_tip = value def get_display_name(self) -> str: - """Get the well's full display name.""" + """Get the full display name of the well (e.g. "A1 of Some Labware on 5").""" return self._display_name def get_name(self) -> str: diff --git a/api/src/opentrons/protocol_api/core/well.py b/api/src/opentrons/protocol_api/core/well.py index 580cbf802dd..bd58963a59c 100644 --- a/api/src/opentrons/protocol_api/core/well.py +++ b/api/src/opentrons/protocol_api/core/well.py @@ -41,7 +41,7 @@ def set_has_tip(self, value: bool) -> None: @abstractmethod def get_display_name(self) -> str: - """Get the full display name of the well (e.g. "A1 of Some Labware").""" + """Get the full display name of the well (e.g. "A1 of Some Labware on 5").""" @abstractmethod def get_name(self) -> str: diff --git a/api/src/opentrons/protocol_engine/command_monitor.py b/api/src/opentrons/protocol_engine/command_monitor.py deleted file mode 100644 index 9f2985f59b0..00000000000 --- a/api/src/opentrons/protocol_engine/command_monitor.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Monitor the execution of commands in a `ProtocolEngine`.""" - - -from dataclasses import dataclass -import typing -import contextlib - - -from opentrons.protocol_engine import Command, ProtocolEngine - - -@dataclass -class RunningEvent: - """Emitted when a command starts running.""" - - command: Command - - -@dataclass -class NoLongerRunningEvent: - """Emitted when a command stops running--either because it succeeded, or failed.""" - - command: Command - - -Event = typing.Union[RunningEvent, NoLongerRunningEvent] -Callback = typing.Callable[[Event], None] - - -@contextlib.contextmanager -def monitor_commands( - protocol_engine: ProtocolEngine, - callback: Callback, -) -> typing.Generator[None, None, None]: - """Monitor the execution of commands in `protocol_engine`. - - While this context manager is open, `callback` will be called any time `protocol_engine` - starts or stops a command. - """ - # Subscribe to all state updates in protocol_engine. - # On every update, diff the new state against the last state and see if the currently - # running command has changed. If it has, emit the appropriate events. - - last_running_id: typing.Optional[str] = None - - def handle_state_update(_message_from_broker: None) -> None: - nonlocal last_running_id - - running_id = protocol_engine.state_view.commands.get_running() - if running_id != last_running_id: - if last_running_id is not None: - callback( - NoLongerRunningEvent( - protocol_engine.state_view.commands.get(last_running_id) - ) - ) - - if running_id is not None: - callback( - RunningEvent(protocol_engine.state_view.commands.get(running_id)) - ) - last_running_id = running_id - - with protocol_engine.state_update_broker.subscribed(handle_state_update): - yield 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 bd89c931d27..a3e8da549a7 100644 --- a/api/src/opentrons/protocol_engine/commands/calibration/calibrate_module.py +++ b/api/src/opentrons/protocol_engine/commands/calibration/calibrate_module.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: from ...state import StateView -from ...types import ModuleOffsetVector +from ...types import ModuleOffsetVector, DeckSlotLocation from opentrons.hardware_control import HardwareControlAPI from opentrons.hardware_control.types import OT3Mount @@ -46,6 +46,10 @@ class CalibrateModuleResult(BaseModel): ..., description="Offset of calibrated module." ) + location: DeckSlotLocation = Field( + ..., description="The deck slot this module was calibrated in." + ) + class CalibrateModuleImplementation( AbstractCommandImpl[CalibrateModuleParams, CalibrateModuleResult] @@ -67,7 +71,7 @@ async def execute(self, params: CalibrateModuleParams) -> CalibrateModuleResult: self._hardware_api, ) ot3_mount = OT3Mount.from_mount(params.mount) - slot = self._state_view.modules.get_location(params.moduleId).slotName.id + slot = self._state_view.modules.get_location(params.moduleId) module_serial = self._state_view.modules.get_serial_number(params.moduleId) # NOTE (ba, 2023-03-31): There are two wells for calibration labware definitions # well A1 represents the location calibration square center relative to the adapters bottom-left corner @@ -78,13 +82,14 @@ async def execute(self, params: CalibrateModuleParams) -> CalibrateModuleResult: # start the calibration module_offset = await calibration.calibrate_module( - ot3_api, ot3_mount, slot, module_serial, nominal_position + ot3_api, ot3_mount, slot.slotName.id, module_serial, nominal_position ) return CalibrateModuleResult( moduleOffset=ModuleOffsetVector( x=module_offset.x, y=module_offset.y, z=module_offset.z - ) + ), + location=slot, ) diff --git a/api/src/opentrons/protocol_engine/protocol_engine.py b/api/src/opentrons/protocol_engine/protocol_engine.py index 9ad7cb3a27c..857d787dcd4 100644 --- a/api/src/opentrons/protocol_engine/protocol_engine.py +++ b/api/src/opentrons/protocol_engine/protocol_engine.py @@ -12,8 +12,6 @@ EnumeratedError, ) -from opentrons.util.broker import ReadOnlyBroker - from .errors import ProtocolCommandFailedError, ErrorOccurrence from .errors.exceptions import EStopActivatedError from . import commands, slot_standardization @@ -131,36 +129,6 @@ def state_view(self) -> StateView: """Get an interface to retrieve calculated state values.""" return self._state_store - @property - def state_update_broker(self) -> ReadOnlyBroker[None]: - """Return a broker that you can use to get notified of all state updates. - - For example, you can use this to do something any time a new command starts running. - - `ProtocolEngine` will publish a message to this broker (with the placeholder value `None`) - any time its state updates. Then, when you receive that message, you can get the latest - state through `state_view` and inspect it to see whether something happened that you care - about. - - Warning: - Use this mechanism sparingly, because it has several footguns: - - * Your callbacks will run synchronously, on every state update. - If they take a long time, they will harm analysis and run speed. - - * Your callbacks will run in the thread and asyncio event loop that own this - `ProtocolEngine`. (See the concurrency notes in the `ProtocolEngine` docstring.) - If your callbacks interact with things in other threads or event loops, - take appropriate precautions to keep them concurrency-safe. - - * Currently, if your callback raises an exception, it will propagate into - `ProtocolEngine` and be treated like any other internal error. This will probably - stop the run. If you expect your code to raise exceptions and don't want - that to happen, consider catching and logging them at the top level of your callback, - before they propagate into `ProtocolEngine`. - """ - return self._state_store.update_broker - def add_plugin(self, plugin: AbstractPlugin) -> None: """Add a plugin to the engine to customize behavior.""" self._plugin_starter.start(plugin) @@ -324,6 +292,15 @@ async def stop(self) -> None: action = self._state_store.commands.validate_action_allowed(StopAction()) self._action_dispatcher.dispatch(action) self._queue_worker.cancel() + if self._hardware_api.is_movement_execution_taskified(): + # We 'taskify' hardware controller movement functions when running protocols + # that are not backed by the engine. Such runs cannot be stopped by cancelling + # the queue worker and hence need to be stopped via the execution manager. + # `cancel_execution_and_running_tasks()` sets the execution manager in a CANCELLED state + # and cancels the running tasks, which raises an error and gets us out of the + # run function execution, just like `_queue_worker.cancel()` does for + # engine-backed runs. + await self._hardware_api.cancel_execution_and_running_tasks() async def wait_until_complete(self) -> None: """Wait until there are no more commands to execute. @@ -412,7 +389,6 @@ async def finish( # order will be backwards because the stack is first-in-last-out. exit_stack = AsyncExitStack() exit_stack.push_async_callback(self._plugin_starter.stop) # Last step. - exit_stack.push_async_callback( # Cleanup after hardware halt and reset the hardware controller self._hardware_stopper.do_stop_and_recover, @@ -432,7 +408,6 @@ async def finish( disengage_before_stopping=disengage_before_stopping, ) exit_stack.push_async_callback(self._queue_worker.join) # First step. - try: # If any teardown steps failed, this will raise something. await exit_stack.aclose() diff --git a/api/src/opentrons/protocol_engine/resources/module_data_provider.py b/api/src/opentrons/protocol_engine/resources/module_data_provider.py index 634104cbedc..a12b85ee5b3 100644 --- a/api/src/opentrons/protocol_engine/resources/module_data_provider.py +++ b/api/src/opentrons/protocol_engine/resources/module_data_provider.py @@ -5,7 +5,14 @@ ) from opentrons_shared_data.module import load_definition -from ..types import ModuleModel, ModuleDefinition, ModuleOffsetVector +from opentrons.types import DeckSlotName +from ..types import ( + ModuleModel, + ModuleDefinition, + ModuleOffsetVector, + ModuleOffsetData, + DeckSlotLocation, +) class ModuleDataProvider: @@ -18,15 +25,20 @@ def get_definition(model: ModuleModel) -> ModuleDefinition: return ModuleDefinition.parse_obj(data) @staticmethod - def load_module_calibrations() -> Dict[str, ModuleOffsetVector]: + def load_module_calibrations() -> Dict[str, ModuleOffsetData]: """Load the module calibration offsets.""" - module_calibrations: Dict[str, ModuleOffsetVector] = dict() + module_calibrations: Dict[str, ModuleOffsetData] = dict() calibration_data = load_all_module_calibrations() for calibration in calibration_data: # NOTE module_id is really the module serial number, change this - module_calibrations[calibration.module_id] = ModuleOffsetVector( - x=calibration.offset.x, - y=calibration.offset.y, - z=calibration.offset.z, + module_calibrations[calibration.module_id] = ModuleOffsetData( + moduleOffsetVector=ModuleOffsetVector( + x=calibration.offset.x, + y=calibration.offset.y, + z=calibration.offset.z, + ), + location=DeckSlotLocation( + slotName=DeckSlotName.from_primitive(calibration.slot), + ), ) return module_calibrations diff --git a/api/src/opentrons/protocol_engine/state/change_notifier.py b/api/src/opentrons/protocol_engine/state/change_notifier.py index 629cb89f368..3c72f277913 100644 --- a/api/src/opentrons/protocol_engine/state/change_notifier.py +++ b/api/src/opentrons/protocol_engine/state/change_notifier.py @@ -1,8 +1,6 @@ """Simple state change notification interface.""" import asyncio -from opentrons.util.broker import Broker, ReadOnlyBroker - class ChangeNotifier: """An interface tto emit or subscribe to state change notifications.""" @@ -10,22 +8,12 @@ class ChangeNotifier: def __init__(self) -> None: """Initialize the ChangeNotifier with an internal Event.""" self._event = asyncio.Event() - self._broker = Broker[None]() def notify(self) -> None: """Notify all `wait`'ers that the state has changed.""" self._event.set() - self._broker.publish(None) async def wait(self) -> None: """Wait until the next state change notification.""" self._event.clear() await self._event.wait() - - @property - def broker(self) -> ReadOnlyBroker[None]: - """Return a broker that you can use to get notified of all changes. - - This is an alternative interface to `wait()`. - """ - return self._broker diff --git a/api/src/opentrons/protocol_engine/state/commands.py b/api/src/opentrons/protocol_engine/state/commands.py index ae0ba7898cb..b4301c22920 100644 --- a/api/src/opentrons/protocol_engine/state/commands.py +++ b/api/src/opentrons/protocol_engine/state/commands.py @@ -507,17 +507,13 @@ def get_error(self) -> Optional[ErrorOccurrence]: else: return run_error or finish_error - def get_running(self) -> Optional[str]: - """Return the ID of the command that's currently running, if any.""" - return self._state.running_command_id - def get_current(self) -> Optional[CurrentCommand]: """Return the "current" command, if any. The "current" command is the command that is currently executing, or the most recent command to have completed. """ - if self._state.running_command_id is not None: + if self._state.running_command_id: entry = self._state.commands_by_id[self._state.running_command_id] return CurrentCommand( command_id=entry.command.id, diff --git a/api/src/opentrons/protocol_engine/state/geometry.py b/api/src/opentrons/protocol_engine/state/geometry.py index 6c90a9c670d..7c26be23098 100644 --- a/api/src/opentrons/protocol_engine/state/geometry.py +++ b/api/src/opentrons/protocol_engine/state/geometry.py @@ -1,5 +1,6 @@ """Geometry state getters.""" import enum +from numpy import array, dot from typing import Optional, List, Set, Tuple, Union, cast from opentrons.types import Point, DeckSlotName, MountType @@ -18,9 +19,10 @@ DeckSlotLocation, ModuleLocation, OnLabwareLocation, - ModuleOffsetVector, LabwareLocation, LabwareOffsetVector, + ModuleOffsetVector, + ModuleOffsetData, DeckType, CurrentWell, TipGeometry, @@ -186,13 +188,45 @@ def _get_labware_position_offset( f"Either it has been loaded off-deck or its been moved off-deck." ) + def _normalize_module_calibration_offset( + self, + module_location: DeckSlotLocation, + offset_data: Optional[ModuleOffsetData], + ) -> ModuleOffsetVector: + """Normalize the module calibration offset depending on the module location.""" + if not offset_data: + return ModuleOffsetVector(x=0, y=0, z=0) + offset = offset_data.moduleOffsetVector + calibrated_slot = offset_data.location.slotName + calibrated_slot_column = self.get_slot_column(calibrated_slot) + current_slot_column = self.get_slot_column(module_location.slotName) + # make sure that we have valid colums since we cant have modules in the middle of the deck + assert set([calibrated_slot_column, current_slot_column]).issubset( + {1, 3} + ), f"Module calibration offset is an invalid slot {calibrated_slot}" + + # Check if the module has moved from one side of the deck to the other + if calibrated_slot_column != current_slot_column: + # Since the module was rotated, the calibration offset vector needs to be rotated by 180 degrees along the z axis + saved_offset = array([offset.x, offset.y, offset.z]) + rotation_matrix = array([[-1, 0, 0], [0, -1, 0], [0, 0, 1]]) + new_offset = dot(saved_offset, rotation_matrix) # type: ignore[no-untyped-call] + offset = ModuleOffsetVector( + x=new_offset[0], y=new_offset[1], z=new_offset[2] + ) + return offset + def _get_calibrated_module_offset( self, location: LabwareLocation ) -> ModuleOffsetVector: """Get a labware location's underlying calibrated module offset, if it is on a module.""" if isinstance(location, ModuleLocation): module_id = location.moduleId - return self._modules.get_module_calibration_offset(module_id) + module_location = self._modules.get_location(module_id) + offset_data = self._modules.get_module_calibration_offset(module_id) + return self._normalize_module_calibration_offset( + module_location, offset_data + ) elif isinstance(location, DeckSlotLocation): return ModuleOffsetVector(x=0, y=0, z=0) elif isinstance(location, OnLabwareLocation): diff --git a/api/src/opentrons/protocol_engine/state/labware.py b/api/src/opentrons/protocol_engine/state/labware.py index b567eaa2f5c..c56edf4de5f 100644 --- a/api/src/opentrons/protocol_engine/state/labware.py +++ b/api/src/opentrons/protocol_engine/state/labware.py @@ -560,12 +560,27 @@ def get_module_overlap_offsets( """Get the labware's overlap with requested module model.""" definition = self.get_definition(labware_id) stacking_overlap = definition.stackingOffsetWithModule.get( - str(module_model.value), OverlapOffset(x=0, y=0, z=0) + str(module_model.value) ) + if not stacking_overlap: + if self._is_thermocycler_on_ot2(module_model): + return OverlapOffset(x=0, y=0, z=10.7) + else: + return OverlapOffset(x=0, y=0, z=0) + return OverlapOffset( x=stacking_overlap.x, y=stacking_overlap.y, z=stacking_overlap.z ) + def _is_thermocycler_on_ot2(self, module_model: ModuleModel) -> bool: + """Whether the given module is a thermocycler with the current deck being an OT2 deck.""" + robot_model = self.get_deck_definition()["robot"]["model"] + return ( + module_model + in [ModuleModel.THERMOCYCLER_MODULE_V1, ModuleModel.THERMOCYCLER_MODULE_V2] + and robot_model == "OT-2 Standard" + ) + def get_default_magnet_height(self, module_id: str, offset: float) -> float: """Return a labware's default Magnetic Module engage height with added offset, if supplied. diff --git a/api/src/opentrons/protocol_engine/state/modules.py b/api/src/opentrons/protocol_engine/state/modules.py index 438fba9aaa0..6ac289a6b79 100644 --- a/api/src/opentrons/protocol_engine/state/modules.py +++ b/api/src/opentrons/protocol_engine/state/modules.py @@ -35,6 +35,7 @@ LoadedModule, ModuleModel, ModuleOffsetVector, + ModuleOffsetData, ModuleType, ModuleDefinition, DeckSlotLocation, @@ -144,7 +145,7 @@ class ModuleState: substate_by_module_id: Dict[str, ModuleSubStateType] """Information about each module that's specific to the module type.""" - module_offset_by_serial: Dict[str, ModuleOffsetVector] + module_offset_by_serial: Dict[str, ModuleOffsetData] """Information about each modules offsets.""" @@ -154,7 +155,7 @@ class ModuleStore(HasState[ModuleState], HandlesActions): _state: ModuleState def __init__( - self, module_calibration_offsets: Optional[Dict[str, ModuleOffsetVector]] = None + self, module_calibration_offsets: Optional[Dict[str, ModuleOffsetData]] = None ) -> None: """Initialize a ModuleStore and its state.""" self._state = ModuleState( @@ -195,6 +196,7 @@ def _handle_command(self, command: Command) -> None: self._update_module_calibration( module_id=command.params.moduleId, module_offset=command.result.moduleOffset, + location=command.result.location, ) if isinstance( @@ -289,7 +291,10 @@ def _add_module_substate( ) def _update_module_calibration( - self, module_id: str, module_offset: ModuleOffsetVector + self, + module_id: str, + module_offset: ModuleOffsetVector, + location: DeckSlotLocation, ) -> None: module = self._state.hardware_by_module_id.get(module_id) if module: @@ -297,7 +302,10 @@ def _update_module_calibration( assert ( module_serial is not None ), "Expected a module SN and got None instead." - self._state.module_offset_by_serial[module_serial] = module_offset + self._state.module_offset_by_serial[module_serial] = ModuleOffsetData( + moduleOffsetVector=module_offset, + location=location, + ) def _handle_heater_shaker_commands( self, @@ -650,19 +658,10 @@ def get_dimensions(self, module_id: str) -> ModuleDimensions: """Get the specified module's dimensions.""" return self.get_definition(module_id).dimensions - def get_module_calibration_offset(self, module_id: str) -> ModuleOffsetVector: - """Get the stored module calibration offset.""" - module_serial = self.get(module_id).serialNumber - if module_serial is not None: - offset = self._state.module_offset_by_serial.get(module_serial) - if offset: - return offset - return ModuleOffsetVector(x=0, y=0, z=0) - def get_nominal_module_offset( self, module_id: str, deck_type: DeckType ) -> LabwareOffsetVector: - """Get the module's offset vector computed with slot transform.""" + """Get the module's nominal offset vector computed with slot transform.""" definition = self.get_definition(module_id) slot = self.get_location(module_id).slotName.id @@ -689,19 +688,14 @@ def get_nominal_module_offset( z=xformed[2], ) - def get_module_offset( - self, module_id: str, deck_type: DeckType - ) -> LabwareOffsetVector: - """Get the module's offset vector computed with slot transform and calibrated module offsets.""" - offset_vector = self.get_nominal_module_offset(module_id, deck_type) - - # add the calibrated module offset if there is one - cal_offset = self.get_module_calibration_offset(module_id) - return LabwareOffsetVector( - x=offset_vector.x + cal_offset.x, - y=offset_vector.y + cal_offset.y, - z=offset_vector.z + cal_offset.z, - ) + def get_module_calibration_offset( + self, module_id: str + ) -> Optional[ModuleOffsetData]: + """Get the calibration module offset.""" + module_serial = self.get(module_id).serialNumber + if module_serial: + return self._state.module_offset_by_serial.get(module_serial) + return None def get_overall_height(self, module_id: str) -> float: """Get the height of the module, excluding any labware loaded atop it.""" diff --git a/api/src/opentrons/protocol_engine/state/state.py b/api/src/opentrons/protocol_engine/state/state.py index 761056bdc87..7e4695e15e6 100644 --- a/api/src/opentrons/protocol_engine/state/state.py +++ b/api/src/opentrons/protocol_engine/state/state.py @@ -7,8 +7,7 @@ from opentrons_shared_data.deck.dev_types import DeckDefinitionV3 -from opentrons.protocol_engine.types import ModuleOffsetVector -from opentrons.util.broker import ReadOnlyBroker +from opentrons.protocol_engine.types import ModuleOffsetData from ..resources import DeckFixedLabware from ..actions import Action, ActionHandler @@ -131,7 +130,7 @@ def __init__( deck_fixed_labware: Sequence[DeckFixedLabware], is_door_open: bool, change_notifier: Optional[ChangeNotifier] = None, - module_calibration_offsets: Optional[Dict[str, ModuleOffsetVector]] = None, + module_calibration_offsets: Optional[Dict[str, ModuleOffsetData]] = None, ) -> None: """Initialize a StateStore and its substores. @@ -240,17 +239,6 @@ async def wait_for( return is_done - # We return ReadOnlyBroker[None] instead of ReadOnlyBroker[StateView] in order to avoid - # confusion with state mutability. If a caller needs to know the new state, they can - # retrieve it explicitly with `ProtocolEngine.state_view`. - @property - def update_broker(self) -> ReadOnlyBroker[None]: - """Return a broker that you can use to get notified of all state updates. - - This is an alternative interface to `wait_for()`. - """ - return self._change_notifier.broker - def _get_next_state(self) -> State: """Get a new instance of the state value object.""" return State( diff --git a/api/src/opentrons/protocol_engine/types.py b/api/src/opentrons/protocol_engine/types.py index 746e40f6949..228cb066aaf 100644 --- a/api/src/opentrons/protocol_engine/types.py +++ b/api/src/opentrons/protocol_engine/types.py @@ -378,6 +378,14 @@ class ModuleOffsetVector(BaseModel): z: float +@dataclass +class ModuleOffsetData: + """Module calibration offset data.""" + + moduleOffsetVector: ModuleOffsetVector + location: DeckSlotLocation + + class OverlapOffset(Vec3f): """Offset representing overlap space of one labware on top of another labware or module.""" diff --git a/api/src/opentrons/protocol_runner/protocol_runner.py b/api/src/opentrons/protocol_runner/protocol_runner.py index 1b49a159087..56669077efb 100644 --- a/api/src/opentrons/protocol_runner/protocol_runner.py +++ b/api/src/opentrons/protocol_runner/protocol_runner.py @@ -59,6 +59,21 @@ class AbstractRunner(ABC): def __init__(self, protocol_engine: ProtocolEngine) -> None: self._protocol_engine = protocol_engine + self._broker = LegacyBroker() + + # TODO(mm, 2023-10-03): `LegacyBroker` is specific to Python protocols and JSON protocols ≤v5. + # We'll need to extend this in order to report progress from newer JSON protocols. + # + # TODO(mm, 2023-10-04): When we switch this to return a new `Broker` instead of a + # `LegacyBroker`, we should annotate the return type as a `ReadOnlyBroker`. + @property + def broker(self) -> LegacyBroker: + """Return a broker that you can subscribe to in order to monitor protocol progress. + + Currently, this only returns messages for `PythonAndLegacyRunner`. + Otherwise, it's a no-op. + """ + return self._broker def was_started(self) -> bool: """Whether the run has been started. @@ -136,20 +151,22 @@ async def load( protocol = self._legacy_file_reader.read( protocol_source, labware_definitions, python_parse_mode ) - broker = None equipment_broker = None if protocol.api_level < LEGACY_PYTHON_API_VERSION_CUTOFF: - broker = LegacyBroker() equipment_broker = Broker[LegacyLoadInfo]() - self._protocol_engine.add_plugin( - LegacyContextPlugin(broker=broker, equipment_broker=equipment_broker) + LegacyContextPlugin( + broker=self._broker, equipment_broker=equipment_broker + ) ) + self._hardware_api.should_taskify_movement_execution(taskify=True) + else: + self._hardware_api.should_taskify_movement_execution(taskify=False) context = self._legacy_context_creator.create( protocol=protocol, - broker=broker, + broker=self._broker, equipment_broker=equipment_broker, ) initial_home_command = pe_commands.HomeCreate( @@ -205,6 +222,7 @@ def __init__( # TODO(mc, 2022-01-11): replace task queue with specific implementations # of runner interface self._task_queue = task_queue or TaskQueue(cleanup_func=protocol_engine.finish) + self._hardware_api.should_taskify_movement_execution(taskify=False) async def load(self, protocol_source: ProtocolSource) -> None: """Load a JSONv6+ ProtocolSource into managed ProtocolEngine.""" @@ -292,6 +310,7 @@ def __init__( # of runner interface self._hardware_api = hardware_api self._task_queue = task_queue or TaskQueue(cleanup_func=protocol_engine.finish) + self._hardware_api.should_taskify_movement_execution(taskify=False) def prepare(self) -> None: """Set the task queue to wait until all commands are executed.""" diff --git a/api/src/opentrons/protocols/execution/execute_python.py b/api/src/opentrons/protocols/execution/execute_python.py index 894af6dd6e2..cf5f3303cbe 100644 --- a/api/src/opentrons/protocols/execution/execute_python.py +++ b/api/src/opentrons/protocols/execution/execute_python.py @@ -47,10 +47,19 @@ def run_python(proto: PythonProtocol, context: ProtocolContext): # If the protocol is written correctly, it will have defined a function # like run(context: ProtocolContext). If so, that function is now in the # current scope. + + # TODO(mm, 2023-10-11): This coupling to opentrons.protocols.parse is fragile. + # Can we get the correct filename directly from proto.contents? if proto.filename and proto.filename.endswith("zip"): + # The ".zip" extension needs to match what opentrons.protocols.parse recognizes as a bundle, + # and the "protocol.ot2.py" fallback needs to match what opentrons.protocol.py sets as the + # AST filename. filename = "protocol.ot2.py" else: + # "" needs to match what opentrons.protocols.parse sets as the fallback + # AST filename. filename = proto.filename or "" + try: _runfunc_ok(new_globs.get("run")) except SyntaxError as se: diff --git a/api/src/opentrons/protocols/parse.py b/api/src/opentrons/protocols/parse.py index c757ab6d26d..ee868912ed7 100644 --- a/api/src/opentrons/protocols/parse.py +++ b/api/src/opentrons/protocols/parse.py @@ -217,11 +217,16 @@ def _parse_python( extra_labware: Optional[Dict[str, "LabwareDefinition"]] = None, ) -> PythonProtocol: """Parse a protocol known or at least suspected to be python""" - filename_checked = filename or "" - if filename_checked.endswith(".zip"): + if filename is None: + # The fallback "" needs to match what opentrons.protocols.execution.execute_python + # looks for when it extracts tracebacks. + ast_filename = "" + elif filename.endswith(".zip"): + # The extension ".zip" and the fallback "protocol.ot2.py" need to match what + # opentrons.protocols.execution.execute_python looks for when it extracts tracebacks. ast_filename = "protocol.ot2.py" else: - ast_filename = filename_checked + ast_filename = filename # todo(mm, 2021-09-13): By default, ast.parse will inherit compiler options # and future features from this module. This may not be appropriate. @@ -244,7 +249,7 @@ def _parse_python( static_info = _extract_static_python_info(parsed) protocol = compile(parsed, filename=ast_filename, mode="exec") - version = _get_version(static_info, parsed, filename_checked) + version = _get_version(static_info, parsed, ast_filename) robot_type = _robot_type_from_static_python_info(static_info) if version >= APIVersion(2, 0): @@ -257,7 +262,7 @@ def _parse_python( result = PythonProtocol( text=protocol_contents, - filename=getattr(protocol, "co_filename", ""), + filename=filename, contents=protocol, metadata=static_info.metadata, api_level=version, diff --git a/api/src/opentrons/protocols/types.py b/api/src/opentrons/protocols/types.py index 45b60c8f4ab..792951efbfa 100644 --- a/api/src/opentrons/protocols/types.py +++ b/api/src/opentrons/protocols/types.py @@ -32,11 +32,22 @@ class StaticPythonInfo: @dataclass(frozen=True) class _ProtocolCommon: text: str + filename: Optional[str] + """The original name of the main protocol file, if it had a name. + + For JSON protocols, this will be the name of the .json file. + For Python protocols, this will be the name of the .py file. + For bundled protocols, this will be the name of the .zip file. + + This can be `None` if, for example, we've parsed the protocol from an in-memory text stream. + """ + # TODO(mm, 2023-06-22): Move api_level out of _ProtocolCommon and into PythonProtocol. # JSON protocols do not have an intrinsic api_level, especially since JSONv6, # where they are no longer executed via the Python Protocol API. api_level: "APIVersion" + robot_type: RobotType diff --git a/api/src/opentrons/simulate.py b/api/src/opentrons/simulate.py index 0ce49687cfe..d677d6e93fe 100644 --- a/api/src/opentrons/simulate.py +++ b/api/src/opentrons/simulate.py @@ -4,13 +4,17 @@ a protocol from the command line. """ import argparse +import asyncio +import atexit +from contextlib import ExitStack, contextmanager import sys import logging import os import pathlib import queue from typing import ( - cast, + TYPE_CHECKING, + Generator, Any, Dict, List, @@ -21,6 +25,9 @@ Optional, Union, ) +from typing_extensions import Literal + +from opentrons_shared_data.robot.dev_types import RobotType import opentrons from opentrons import should_use_ot3 @@ -29,38 +36,47 @@ ThreadManager, ThreadManagedHardware, ) -from opentrons.hardware_control.types import MachineType from opentrons.hardware_control.simulator_setup import load_simulator -from opentrons.protocol_api import MAX_SUPPORTED_VERSION +from opentrons.protocol_api.core.engine import ENGINE_CORE_API_VERSION +from opentrons.protocol_api.protocol_context import ProtocolContext +from opentrons.protocol_engine import create_protocol_engine +from opentrons.protocol_engine.create_protocol_engine import ( + create_protocol_engine_in_thread, +) +from opentrons.protocol_engine.state.config import Config +from opentrons.protocol_engine.types import DeckType, EngineStatus, PostRunHardwareState +from opentrons.protocol_reader.protocol_source import ProtocolSource +from opentrons.protocol_runner.protocol_runner import create_protocol_runner from opentrons.protocols.duration import DurationEstimator from opentrons.protocols.execution import execute from opentrons.legacy_broker import LegacyBroker -from opentrons.config import IS_ROBOT, JUPYTER_NOTEBOOK_LABWARE_DIR +from opentrons.config import IS_ROBOT from opentrons import protocol_api from opentrons.commands import types as command_types from opentrons.protocols import parse, bundle -from opentrons.protocols.types import PythonProtocol, BundleContents +from opentrons.protocols.types import ( + ApiDeprecationError, + Protocol, + PythonProtocol, + BundleContents, +) from opentrons.protocols.api_support.deck_type import ( - guess_from_global_config as guess_deck_type_from_global_config, + for_simulation as deck_type_for_simulation, ) from opentrons.protocols.api_support.types import APIVersion -from opentrons_shared_data.labware.dev_types import LabwareDefinition +from opentrons_shared_data.labware.labware_definition import LabwareDefinition -from .util.entrypoint_util import labware_from_paths, datafiles_from_paths +from .util import entrypoint_util + +if TYPE_CHECKING: + from opentrons_shared_data.labware.dev_types import ( + LabwareDefinition as LabwareDefinitionDict, + ) # See Jira RCORE-535. -_PYTHON_TOO_NEW_MESSAGE = ( - "Python protocols with apiLevels higher than 2.13" - " cannot currently be simulated with" - " the opentrons_simulate command-line tool," - " the opentrons.simulate.simulate() function," - " or the opentrons.simulate.get_protocol_api() function." - " Use a lower apiLevel" - " or use the Opentrons App instead." -) _JSON_TOO_NEW_MESSAGE = ( "Protocols created by recent versions of Protocol Designer" " cannot currently be simulated with" @@ -70,11 +86,41 @@ ) -class AccumulatingHandler(logging.Handler): +# When a ProtocolContext is using a ProtocolEngine to control the robot, +# it requires some long-lived resources. There's a background thread, +# an asyncio event loop in that thread, and some ProtocolEngine-controlled background tasks in that +# event loop. +# +# When we're executing a protocol file beginning-to-end, we can clean up those resources after it +# completes. However, when someone gets a live ProtocolContext through get_protocol_api(), we have +# no way of knowing when they're done with it. So, as a hack, we keep these resources open +# indefinitely, letting them leak. +# +# We keep this at module scope so that the contained context managers aren't garbage-collected. +# If they're garbage collected, they can close their resources prematurely. +# https://stackoverflow.com/a/69155026/497934 +_LIVE_PROTOCOL_ENGINE_CONTEXTS = ExitStack() + + +# TODO(mm, 2023-10-05): Deduplicate this with opentrons.protocols.parse(). +_UserSpecifiedRobotType = Literal["OT-2", "Flex"] +"""The user-facing robot type specifier. + +This should match what `opentrons.protocols.parse()` accepts in a protocol's `requirements` dict. +""" + + +# TODO(mm, 2023-10-05): Type _SimulateResultRunLog more precisely by using TypedDicts from +# opentrons.commands. +_SimulateResultRunLog = List[Mapping[str, Any]] +_SimulateResult = Tuple[_SimulateResultRunLog, Optional[BundleContents]] + + +class _AccumulatingHandler(logging.Handler): def __init__( self, level: str, - command_queue: "queue.Queue[Any]", + command_queue: "queue.Queue[object]", ) -> None: """Create the handler @@ -84,86 +130,96 @@ def __init__( self._command_queue = command_queue super().__init__(level) - def emit(self, record: Any) -> None: + def emit(self, record: object) -> None: self._command_queue.put(record) -class CommandScraper: - """An object that handles scraping the broker for commands - - This should be instantiated with the logger to integrate - messages from (e.g. ``logging.getLogger('opentrons')``), the - level to scrape, and the opentrons broker object to subscribe to. - - The :py:attr:`commands` property contains the list of commands - and log messages integrated together. Each element of the list is - a dict following the pattern in the docs of :py:obj:`simulate`. - """ - +class _CommandScraper: def __init__( self, logger: logging.Logger, level: str, broker: LegacyBroker ) -> None: - """Build the scraper. + """An object that handles scraping the broker for commands and integrating log messages + with them. - :param logger: The :py:class:`logging.logger` to scrape - :param level: The log level to scrape - :param broker: Which broker to subscribe to + Params: + logger: The logger to integrate messages from, e.g. ``logging.getLogger("opentrons")``. + level: The log level to scrape. + broker: The broker to subscribe to for commands. """ self._logger = logger + self._level = level self._broker = broker - self._queue = queue.Queue() # type: ignore - if level != "none": - level = getattr(logging, level.upper(), logging.WARNING) - self._logger.setLevel(level) - self._handler: Optional[AccumulatingHandler] = AccumulatingHandler( - level, self._queue - ) - logger.addHandler(self._handler) - else: - self._handler = None - self._depth = 0 - self._commands: List[Mapping[str, Any]] = [] - self._unsub = self._broker.subscribe( - command_types.COMMAND, self._command_callback - ) + self._commands: _SimulateResultRunLog = [] @property - def commands(self) -> List[Mapping[str, Mapping[str, Any]]]: - """The list of commands. See :py:obj:`simulate`""" + def commands(self) -> _SimulateResultRunLog: + """The list of commands scraped while `.scrape()` was open, integrated with log messages. + + See :py:obj:`simulate` for the return type. + """ return self._commands - def __del__(self) -> None: - if getattr(self, "_handler", None): - try: - self._logger.removeHandler(self._handler) # type: ignore - except Exception: - pass - if hasattr(self, "_unsub"): - self._unsub() - - def _command_callback(self, message: command_types.CommandMessage) -> None: - """The callback subscribed to the broker""" - payload = message["payload"] - if message["$"] == "before": - self._commands.append( - {"level": self._depth, "payload": payload, "logs": []} + @contextmanager + def scrape(self) -> Generator[None, None, None]: + """While this context manager is open, scrape the broker for commands and integrate log + messages with them. The accumulated commands will be accessible through `.commands`. + """ + log_queue: "queue.Queue[object]" = queue.Queue() + + depth = 0 + + def handle_command(message: command_types.CommandMessage) -> None: + """The callback that we will subscribe to the broker.""" + nonlocal depth + payload = message["payload"] + if message["$"] == "before": + self._commands.append({"level": depth, "payload": payload, "logs": []}) + depth += 1 + else: + while not log_queue.empty(): + self._commands[-1]["logs"].append(log_queue.get()) + depth = max(depth - 1, 0) + + if self._level != "none": + # The simulation entry points probably leave logging unconfigured, so the level will be + # Python's default. Set it to what the user asked to make sure we see the expected + # records. + # + # TODO(mm, 2023-10-03): This is a bit too intrusive for something whose job is just to + # "scrape." The entry point function should be responsible for setting the underlying + # logger's level. + level = getattr(logging, self._level.upper(), logging.WARNING) + self._logger.setLevel(level) + + log_handler: Optional[_AccumulatingHandler] = _AccumulatingHandler( + level, log_queue ) - self._depth += 1 else: - while not self._queue.empty(): - self._commands[-1]["logs"].append(self._queue.get()) - self._depth = max(self._depth - 1, 0) + log_handler = None + + with ExitStack() as exit_stack: + if log_handler is not None: + self._logger.addHandler(log_handler) + exit_stack.callback(self._logger.removeHandler, log_handler) + + unsubscribe_from_broker = self._broker.subscribe( + command_types.COMMAND, handle_command + ) + exit_stack.callback(unsubscribe_from_broker) + + yield def get_protocol_api( version: Union[str, APIVersion], - bundled_labware: Optional[Dict[str, LabwareDefinition]] = None, + bundled_labware: Optional[Dict[str, "LabwareDefinitionDict"]] = None, bundled_data: Optional[Dict[str, bytes]] = None, - extra_labware: Optional[Dict[str, LabwareDefinition]] = None, + extra_labware: Optional[Dict[str, "LabwareDefinitionDict"]] = None, hardware_simulator: Optional[ThreadManagedHardware] = None, - # TODO(mm, 2022-12-14): The name and type of this parameter should be unified with - # robotType in a standalone Python protocol's `requirements` dict. Jira RCORE-318. - machine: Optional[MachineType] = None, + # Additional arguments are kw-only to make mistakes harder in environments without + # type checking, like Jupyter Notebook. + *, + robot_type: Optional[_UserSpecifiedRobotType] = None, ) -> protocol_api.ProtocolContext: """ Build and return a ``protocol_api.ProtocolContext`` @@ -198,8 +254,9 @@ def get_protocol_api( it will look for labware in the ``labware`` subdirectory of the Jupyter data directory. :param hardware_simulator: If specified, a hardware simulator instance. - :param machine: Either `"ot2"` or `"ot3"`. If `None`, machine will be - determined from persistent settings. + :param robot_type: The type of robot to simulate: either ``"Flex"`` or ``"OT-2"``. + If you're running this function on a robot, the default is the type of that + robot. Otherwise, the default is ``"OT-2"``, for backwards compatibility. :return: The protocol context. """ if isinstance(version, str): @@ -208,72 +265,124 @@ def get_protocol_api( raise TypeError("version must be either a string or an APIVersion") else: checked_version = version - if ( - extra_labware is None - and IS_ROBOT - and JUPYTER_NOTEBOOK_LABWARE_DIR.is_dir() # type: ignore[union-attr] - ): + + current_robot_type = _get_current_robot_type() + if robot_type is None: + if current_robot_type is None: + parsed_robot_type: RobotType = "OT-2 Standard" + else: + parsed_robot_type = current_robot_type + else: + # TODO(mm, 2023-10-09): This raises a slightly wrong error message, mentioning the camelCase + # `robotType` field in Python files instead of the snake_case `robot_type` argument for this + # function. + parsed_robot_type = parse.robot_type_from_python_identifier(robot_type) + _validate_can_simulate_for_robot_type(parsed_robot_type) + deck_type = deck_type_for_simulation(parsed_robot_type) + + if extra_labware is None: extra_labware = { uri: details.definition - for uri, details in labware_from_paths( - [str(JUPYTER_NOTEBOOK_LABWARE_DIR)] - ).items() + for uri, details in (entrypoint_util.find_jupyter_labware() or {}).items() } - checked_hardware = _check_hardware_simulator(hardware_simulator, machine) - return _build_protocol_context( - version=checked_version, - hardware_simulator=checked_hardware, - bundled_labware=bundled_labware, - bundled_data=bundled_data, - extra_labware=extra_labware, + checked_hardware = _make_hardware_simulator( + override=hardware_simulator, robot_type=parsed_robot_type ) + if checked_version < ENGINE_CORE_API_VERSION: + context = _create_live_context_non_pe( + api_version=checked_version, + deck_type=deck_type, + hardware_api=checked_hardware, + bundled_labware=bundled_labware, + bundled_data=bundled_data, + extra_labware=extra_labware, + ) + else: + if bundled_labware is not None: + # Protocol Engine has a deep assumption that standard labware definitions are always + # implicitly loadable. + raise NotImplementedError( + f"The bundled_labware argument is not currently supported for Python protocols" + f" with apiLevel {ENGINE_CORE_API_VERSION} or newer." + ) + context = _create_live_context_pe( + api_version=checked_version, + robot_type=parsed_robot_type, + deck_type=deck_type, + hardware_api=checked_hardware, + bundled_data=bundled_data, + extra_labware=extra_labware, + ) + + # Intentional difference from execute.get_protocol_api(): + # For the caller's convenience, we home the virtual hardware so they don't get MustHomeErrors. + # Since this hardware is virtual, there's no harm in commanding this "movement" implicitly. + context.home() + + return context -def _check_hardware_simulator( - hardware_simulator: Optional[ThreadManagedHardware], machine: Optional[MachineType] + +def _make_hardware_simulator( + override: Optional[ThreadManagedHardware], robot_type: RobotType ) -> ThreadManagedHardware: - # TODO(mm, 2022-12-14): This should fail with a more descriptive error if someone - # runs this on a robot, and that robot doesn't have a matching robot type. - # Jira RCORE-318. - if hardware_simulator: - return hardware_simulator - elif machine == "ot3" or should_use_ot3(): + if override: + return override + elif robot_type == "OT-3 Standard": + # Local import because this isn't available on OT-2s. from opentrons.hardware_control.ot3api import OT3API return ThreadManager(OT3API.build_hardware_simulator) - else: + elif robot_type == "OT-2 Standard": return ThreadManager(OT2API.build_hardware_simulator) -def _build_protocol_context( - version: APIVersion, - hardware_simulator: ThreadManagedHardware, - bundled_labware: Optional[Dict[str, LabwareDefinition]], - bundled_data: Optional[Dict[str, bytes]], - extra_labware: Optional[Dict[str, LabwareDefinition]], -) -> protocol_api.ProtocolContext: - """Internal version of :py:meth:`get_protocol_api` that allows deferring - version specification for use with - :py:meth:`.protocol_api.execute.run_protocol` - """ - try: - context = protocol_api.create_protocol_context( - api_version=version, - hardware_api=hardware_simulator, - # FIXME(2022-12-02): Instead of guessing, - # match this to the robot type declared by the protocol. - # https://opentrons.atlassian.net/browse/RSS-156 - deck_type=guess_deck_type_from_global_config(), - bundled_labware=bundled_labware, - bundled_data=bundled_data, - extra_labware=extra_labware, - use_simulating_core=True, +@contextmanager +def _make_hardware_simulator_cm( + config_file_path: Optional[pathlib.Path], robot_type: RobotType +) -> Generator[ThreadManagedHardware, None, None]: + if config_file_path is not None: + result = ThreadManager( + load_simulator, + pathlib.Path(config_file_path), + ) + try: + yield result + finally: + result.clean_up() + else: + result = _make_hardware_simulator(override=None, robot_type=robot_type) + try: + yield result + finally: + result.clean_up() + + +def _get_current_robot_type() -> Optional[RobotType]: + """Return the type of robot that we're running on, or None if we're not on a robot.""" + if IS_ROBOT: + return "OT-3 Standard" if should_use_ot3() else "OT-2 Standard" + else: + return None + + +def _validate_can_simulate_for_robot_type(robot_type: RobotType) -> None: + """Raise if this device cannot simulate protocols written for the given robot type.""" + current_robot_type = _get_current_robot_type() + if current_robot_type is None: + # When installed locally, this package can simulate protocols for any robot type. + pass + elif robot_type != current_robot_type: + # Match robot server behavior: raise an early error if we're on a robot and the caller + # tries to simulate a protocol written for a different robot type. + + # FIXME: This exposes the internal strings "OT-2 Standard" and "OT-3 Standard". + # https://opentrons.atlassian.net/browse/RSS-370 + raise RuntimeError( + f'This robot is of type "{current_robot_type}",' + f' so it can\'t simulate protocols for robot type "{robot_type}"' ) - except protocol_api.ProtocolEngineCoreRequiredError as e: - raise NotImplementedError(_PYTHON_TOO_NEW_MESSAGE) from e # See Jira RCORE-535. - context.home() - return context def bundle_from_sim( @@ -283,7 +392,7 @@ def bundle_from_sim( From a protocol, and the context that has finished simulating that protocol, determine what needs to go in a bundle for the protocol. """ - bundled_labware: Dict[str, LabwareDefinition] = {} + bundled_labware: Dict[str, "LabwareDefinitionDict"] = {} for lw in context.loaded_labwares.values(): if ( isinstance(lw, opentrons.protocol_api.labware.Labware) @@ -299,7 +408,7 @@ def bundle_from_sim( ) -def simulate( # noqa: C901 +def simulate( protocol_file: Union[BinaryIO, TextIO], file_name: Optional[str] = None, custom_labware_paths: Optional[List[str]] = None, @@ -308,11 +417,7 @@ def simulate( # noqa: C901 hardware_simulator_file_path: Optional[str] = None, duration_estimator: Optional[DurationEstimator] = None, log_level: str = "warning", - # TODO(mm, 2022-12-14): Now that protocols declare their target robot types - # intrinsically, the `machine` param should be removed in favor of determining - # it automatically. - machine: Optional[MachineType] = None, -) -> Tuple[List[Mapping[str, Any]], Optional[BundleContents]]: +) -> _SimulateResult: """ Simulate the protocol itself. @@ -372,8 +477,6 @@ def simulate( # noqa: C901 :param log_level: The level of logs to capture in the runlog: ``"debug"``, ``"info"``, ``"warning"``, or ``"error"``. Defaults to ``"warning"``. - :param machine: Either `"ot2"` or `"ot3"`. If `None`, machine will be - determined from persistent settings. :returns: A tuple of a run log for user output, and possibly the required data to write to a bundle to bundle this protocol. The bundle is only emitted if bundling is allowed @@ -382,32 +485,30 @@ def simulate( # noqa: C901 """ stack_logger = logging.getLogger("opentrons") stack_logger.propagate = propagate_logs + # _CommandScraper will set the level of this logger. - contents = protocol_file.read() + # TODO(mm, 2023-10-02): Switch this truthy check to `is not None` + # to match documented behavior. + # See notes in https://github.com/Opentrons/opentrons/pull/13107 if custom_labware_paths: - extra_labware = { - uri: details.definition - for uri, details in labware_from_paths(custom_labware_paths).items() - } + extra_labware = entrypoint_util.labware_from_paths(custom_labware_paths) else: - extra_labware = {} + extra_labware = entrypoint_util.find_jupyter_labware() or {} if custom_data_paths: - extra_data = datafiles_from_paths(custom_data_paths) + extra_data = entrypoint_util.datafiles_from_paths(custom_data_paths) else: extra_data = {} - hardware_simulator = None - - if hardware_simulator_file_path: - hardware_simulator = ThreadManager( - load_simulator, - pathlib.Path(hardware_simulator_file_path), - ) - + contents = protocol_file.read() try: protocol = parse.parse( - contents, file_name, extra_labware=extra_labware, extra_data=extra_data + contents, + file_name, + extra_labware={ + uri: details.definition for uri, details in extra_labware.items() + }, + extra_data=extra_data, ) except parse.JSONSchemaVersionTooNewError as e: if e.attempted_schema_version == 6: @@ -416,42 +517,43 @@ def simulate( # noqa: C901 else: raise - bundle_contents: Optional[BundleContents] = None - - # we want a None literal rather than empty dict so get_protocol_api - # will look for custom labware if this is a robot - gpa_extras = getattr(protocol, "extra_labware", None) or None - - try: - context = get_protocol_api( - getattr(protocol, "api_level", MAX_SUPPORTED_VERSION), - bundled_labware=getattr(protocol, "bundled_labware", None), - bundled_data=getattr(protocol, "bundled_data", None), - hardware_simulator=hardware_simulator, - extra_labware=gpa_extras, - machine=machine, - ) - except protocol_api.ProtocolEngineCoreRequiredError as e: - raise NotImplementedError(_PYTHON_TOO_NEW_MESSAGE) from e # See Jira RCORE-535. - - broker = context.broker - scraper = CommandScraper(stack_logger, log_level, broker) - if duration_estimator: - broker.subscribe(command_types.COMMAND, duration_estimator.on_message) - - try: - execute.run_protocol(protocol, context) - if ( - isinstance(protocol, PythonProtocol) - and protocol.api_level >= APIVersion(2, 0) - and protocol.bundled_labware is None - and allow_bundle() - ): - bundle_contents = bundle_from_sim(protocol, context) - finally: - context.cleanup() - - return scraper.commands, bundle_contents + if protocol.api_level < APIVersion(2, 0): + raise ApiDeprecationError(version=protocol.api_level) + + _validate_can_simulate_for_robot_type(protocol.robot_type) + + with _make_hardware_simulator_cm( + config_file_path=( + None + if hardware_simulator_file_path is None + else pathlib.Path(hardware_simulator_file_path) + ), + robot_type=protocol.robot_type, + ) as hardware_simulator: + if protocol.api_level < ENGINE_CORE_API_VERSION: + return _run_file_non_pe( + protocol=protocol, + hardware_api=hardware_simulator, + logger=stack_logger, + level=log_level, + duration_estimator=duration_estimator, + ) + else: + # TODO(mm, 2023-07-06): Once these NotImplementedErrors are resolved, consider removing + # the enclosing if-else block and running everything through _run_file_pe() for simplicity. + if custom_data_paths: + raise NotImplementedError( + f"The custom_data_paths argument is not currently supported for Python protocols" + f" with apiLevel {ENGINE_CORE_API_VERSION} or newer." + ) + protocol_file.seek(0) + return _run_file_pe( + protocol=protocol, + robot_type=protocol.robot_type, + hardware_api=hardware_simulator, + stack_logger=stack_logger, + log_level=log_level, + ) def format_runlog(runlog: List[Mapping[str, Any]]) -> str: @@ -619,7 +721,6 @@ def get_arguments(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: choices=["runlog", "nothing"], default="runlog", ) - parser.add_argument("-m", "--machine", choices=["ot2", "ot3"]) return parser @@ -642,13 +743,195 @@ def _get_bundle_dest( return None +def _create_live_context_non_pe( + api_version: APIVersion, + hardware_api: ThreadManagedHardware, + deck_type: str, + extra_labware: Optional[Dict[str, "LabwareDefinitionDict"]], + bundled_labware: Optional[Dict[str, "LabwareDefinitionDict"]], + bundled_data: Optional[Dict[str, bytes]], +) -> ProtocolContext: + """Return a live ProtocolContext. + + This controls the robot through the older infrastructure, instead of through Protocol Engine. + """ + assert api_version < ENGINE_CORE_API_VERSION + return protocol_api.create_protocol_context( + api_version=api_version, + deck_type=deck_type, + hardware_api=hardware_api, + bundled_labware=bundled_labware, + bundled_data=bundled_data, + extra_labware=extra_labware, + ) + + +def _create_live_context_pe( + api_version: APIVersion, + hardware_api: ThreadManagedHardware, + robot_type: RobotType, + deck_type: str, + extra_labware: Dict[str, "LabwareDefinitionDict"], + bundled_data: Optional[Dict[str, bytes]], +) -> ProtocolContext: + """Return a live ProtocolContext that controls the robot through ProtocolEngine.""" + assert api_version >= ENGINE_CORE_API_VERSION + + global _LIVE_PROTOCOL_ENGINE_CONTEXTS + pe, loop = _LIVE_PROTOCOL_ENGINE_CONTEXTS.enter_context( + create_protocol_engine_in_thread( + hardware_api=hardware_api.wrapped(), + config=_get_protocol_engine_config(robot_type), + drop_tips_after_run=False, + post_run_hardware_state=PostRunHardwareState.STAY_ENGAGED_IN_PLACE, + ) + ) + + # `async def` so we can use loop.run_coroutine_threadsafe() to wait for its completion. + # Non-async would use call_soon_threadsafe(), which makes the waiting harder. + async def add_all_extra_labware() -> None: + for labware_definition_dict in extra_labware.values(): + labware_definition = LabwareDefinition.parse_obj(labware_definition_dict) + pe.add_labware_definition(labware_definition) + + # Add extra_labware to ProtocolEngine, being careful not to modify ProtocolEngine from this + # thread. See concurrency notes in ProtocolEngine docstring. + future = asyncio.run_coroutine_threadsafe(add_all_extra_labware(), loop) + future.result() + + return protocol_api.create_protocol_context( + api_version=api_version, + hardware_api=hardware_api, + deck_type=deck_type, + protocol_engine=pe, + protocol_engine_loop=loop, + bundled_data=bundled_data, + ) + + +def _run_file_non_pe( + protocol: Protocol, + hardware_api: ThreadManagedHardware, + logger: logging.Logger, + level: str, + duration_estimator: Optional[DurationEstimator], +) -> _SimulateResult: + """Run a protocol file without Protocol Engine, with the older infrastructure instead.""" + if isinstance(protocol, PythonProtocol): + extra_labware = protocol.extra_labware + bundled_labware = protocol.bundled_labware + bundled_data = protocol.bundled_data + else: + # JSON protocols do have "bundled labware" embedded in them, but those aren't represented in + # the parsed Protocol object and we don't need to create the ProtocolContext with them. + # execute_apiv2.run_protocol() will pull them out of the JSON and load them into the + # ProtocolContext. + extra_labware = None + bundled_labware = None + bundled_data = None + + context = _create_live_context_non_pe( + api_version=protocol.api_level, + hardware_api=hardware_api, + deck_type=deck_type_for_simulation(protocol.robot_type), + extra_labware=extra_labware, + bundled_labware=bundled_labware, + bundled_data=bundled_data, + ) + + scraper = _CommandScraper(logger=logger, level=level, broker=context.broker) + if duration_estimator: + context.broker.subscribe(command_types.COMMAND, duration_estimator.on_message) + + context.home() + with scraper.scrape(): + try: + execute.run_protocol(protocol, context) + if ( + isinstance(protocol, PythonProtocol) + and protocol.api_level >= APIVersion(2, 0) + and protocol.bundled_labware is None + and allow_bundle() + ): + bundle_contents: Optional[BundleContents] = bundle_from_sim( + protocol, context + ) + else: + bundle_contents = None + + finally: + context.cleanup() + + return scraper.commands, bundle_contents + + +def _run_file_pe( + protocol: Protocol, + robot_type: RobotType, + hardware_api: ThreadManagedHardware, + stack_logger: logging.Logger, + log_level: str, +) -> _SimulateResult: + """Run a protocol file with Protocol Engine.""" + + async def run(protocol_source: ProtocolSource) -> _SimulateResult: + protocol_engine = await create_protocol_engine( + hardware_api=hardware_api.wrapped(), + config=_get_protocol_engine_config(robot_type), + ) + + protocol_runner = create_protocol_runner( + protocol_config=protocol_source.config, + protocol_engine=protocol_engine, + hardware_api=hardware_api.wrapped(), + ) + + scraper = _CommandScraper(stack_logger, log_level, protocol_runner.broker) + with scraper.scrape(): + result = await protocol_runner.run(protocol_source) + + if result.state_summary.status != EngineStatus.SUCCEEDED: + raise entrypoint_util.ProtocolEngineExecuteError( + result.state_summary.errors + ) + + # We don't currently support returning bundle contents from protocols run through + # Protocol Engine. To get them, bundle_from_sim() requires direct access to the + # ProtocolContext, which opentrons.protocol_runner does not grant us. + bundle_contents = None + + return scraper.commands, bundle_contents + + with entrypoint_util.adapt_protocol_source(protocol) as protocol_source: + return asyncio.run(run(protocol_source)) + + +def _get_protocol_engine_config(robot_type: RobotType) -> Config: + """Return a Protocol Engine config to execute protocols on this device.""" + return Config( + robot_type=robot_type, + deck_type=DeckType(deck_type_for_simulation(robot_type)), + ignore_pause=True, + use_virtual_pipettes=True, + use_virtual_modules=True, + use_virtual_gripper=True, + ) + + +@atexit.register +def _clear_live_protocol_engine_contexts() -> None: + global _LIVE_PROTOCOL_ENGINE_CONTEXTS + _LIVE_PROTOCOL_ENGINE_CONTEXTS.close() + + # Note - this script is also set up as a setuptools entrypoint and thus does # an absolute minimum of work since setuptools does something odd generating # the scripts def main() -> int: """Run the simulation""" parser = argparse.ArgumentParser( - prog="opentrons_simulate", description="Simulate an OT-2 protocol" + prog="opentrons_simulate", + description="Simulate a protocol for an Opentrons robot", ) parser = get_arguments(parser) @@ -658,16 +941,21 @@ def main() -> int: # TODO(mm, 2022-12-01): Configure the DurationEstimator with the correct deck type. duration_estimator = DurationEstimator() if args.estimate_duration else None # type: ignore[no-untyped-call] - runlog, maybe_bundle = simulate( - protocol_file=args.protocol, - file_name=args.protocol.name, - custom_labware_paths=args.custom_labware_path, - custom_data_paths=(args.custom_data_path + args.custom_data_file), - duration_estimator=duration_estimator, - hardware_simulator_file_path=getattr(args, "custom_hardware_simulator_file"), - log_level=args.log_level, - machine=cast(Optional[MachineType], args.machine), - ) + try: + runlog, maybe_bundle = simulate( + protocol_file=args.protocol, + file_name=args.protocol.name, + custom_labware_paths=args.custom_labware_path, + custom_data_paths=(args.custom_data_path + args.custom_data_file), + duration_estimator=duration_estimator, + hardware_simulator_file_path=getattr( + args, "custom_hardware_simulator_file" + ), + log_level=args.log_level, + ) + except entrypoint_util.ProtocolEngineExecuteError as error: + print(error.to_stderr_string(), file=sys.stderr) + return 1 if maybe_bundle: bundle_name = getattr(args, "bundle", None) diff --git a/api/src/opentrons/util/entrypoint_util.py b/api/src/opentrons/util/entrypoint_util.py index 954d837c2f3..442b0686ebe 100644 --- a/api/src/opentrons/util/entrypoint_util.py +++ b/api/src/opentrons/util/entrypoint_util.py @@ -1,20 +1,39 @@ """ opentrons.util.entrypoint_util: functions common to entrypoints """ +import asyncio +import contextlib from dataclasses import dataclass +import json import logging from json import JSONDecodeError import pathlib -import shutil -from typing import BinaryIO, Dict, Sequence, TextIO, Union, TYPE_CHECKING +import tempfile +from typing import ( + Dict, + Generator, + List, + Optional, + Sequence, + Union, + TYPE_CHECKING, +) from jsonschema import ValidationError # type: ignore +from opentrons.config import IS_ROBOT, JUPYTER_NOTEBOOK_LABWARE_DIR from opentrons.protocol_api import labware from opentrons.calibration_storage import helpers +from opentrons.protocol_engine.errors.error_occurrence import ( + ErrorOccurrence as ProtocolEngineErrorOccurrence, +) +from opentrons.protocol_reader import ProtocolReader, ProtocolSource +from opentrons.protocols.types import JsonProtocol, Protocol, PythonProtocol if TYPE_CHECKING: from opentrons_shared_data.labware.dev_types import LabwareDefinition + + log = logging.getLogger(__name__) @@ -64,6 +83,24 @@ def labware_from_paths( return labware_defs +def find_jupyter_labware() -> Optional[Dict[str, FoundLabware]]: + """Return labware files in this robot's Jupyter Notebook directory. + + Returns: + If we're running on an Opentrons robot: + A dict, keyed by labware URI, where each value has the file path and the parsed def. + + Otherwise: None. + """ + if IS_ROBOT: + # JUPYTER_NOTEBOOK_LABWARE_DIR should never be None when IS_ROBOT == True. + assert JUPYTER_NOTEBOOK_LABWARE_DIR is not None + if JUPYTER_NOTEBOOK_LABWARE_DIR.is_dir(): + return labware_from_paths([JUPYTER_NOTEBOOK_LABWARE_DIR]) + + return None + + def datafiles_from_paths(paths: Sequence[Union[str, pathlib.Path]]) -> Dict[str, bytes]: datafiles: Dict[str, bytes] = {} for strpath in paths: @@ -86,35 +123,96 @@ def datafiles_from_paths(paths: Sequence[Union[str, pathlib.Path]]) -> Dict[str, return datafiles -# HACK(mm, 2023-06-29): This function is attempting to do something fundamentally wrong. -# Remove it when we fix https://opentrons.atlassian.net/browse/RSS-281. -def copy_file_like(source: Union[BinaryIO, TextIO], destination: pathlib.Path) -> None: - """Copy a file-like object to a path. +@contextlib.contextmanager +def adapt_protocol_source(protocol: Protocol) -> Generator[ProtocolSource, None, None]: + """Convert a `Protocol` to a `ProtocolSource`. - Limitations: - If `source` is text, the new file's encoding may not correctly match its original encoding. - This can matter if it's a Python file and it has an encoding declaration - (https://docs.python.org/3.7/reference/lexical_analysis.html#encoding-declarations). - Also, its newlines may get translated. - """ - # When we read from the source stream, will it give us bytes, or text? - try: - # Experimentally, this is present (but possibly None) on text-mode streams, - # and not present on binary-mode streams. - getattr(source, "encoding") - except AttributeError: - source_is_text = False - else: - source_is_text = True + `Protocol` and `ProtocolSource` do basically the same thing. `Protocol` is the traditional + interface. `ProtocolSource` is the newer, but not necessarily better, interface that's required + to run stuff through Protocol Engine. Ideally, the two would be unified. Until then, we have + this shim. - if source_is_text: - destination_mode = "wt" + This is a context manager because it needs to keep some temp files around. + """ + # ProtocolReader needs to know the filename of the main protocol file so it can infer from its + # extension whether it's a JSON or Python protocol. But that filename doesn't necessarily exist, + # like when a user passes a text stream to opentrons.simulate.simulate(). As a hack, work + # backwards and synthesize a dummy filename with the correct extension. + if protocol.filename is not None: + # We were given a filename, so no need to guess. + # + # It's not well-defined in our customer-facing interfaces whether the supplied protocol_name + # should be just the filename part, or a path with separators. In case it contains stuff + # like "../", sanitize it to just the filename part so we don't save files somewhere bad. + main_file_name = pathlib.Path(protocol.filename).name + elif isinstance(protocol, JsonProtocol): + main_file_name = "protocol.json" else: - destination_mode = "wb" - - with open( - destination, - mode=destination_mode, - ) as destination_file: - # Use copyfileobj() to limit memory usage. - shutil.copyfileobj(fsrc=source, fdst=destination_file) + main_file_name = "protocol.py" + + with tempfile.TemporaryDirectory() as temporary_directory: + # FIXME(mm, 2023-06-26): Copying these files is pure overhead, and it introduces encoding + # hazards. Remove this when we can parse JSONv6+ and PAPIv2.14+ protocols without going + # through the filesystem. https://opentrons.atlassian.net/browse/RSS-281 + + main_file = pathlib.Path(temporary_directory) / main_file_name + main_file.write_text(protocol.text, encoding="utf-8") + + labware_files: List[pathlib.Path] = [] + if isinstance(protocol, PythonProtocol) and protocol.extra_labware is not None: + for labware_index, labware_definition in enumerate( + protocol.extra_labware.values() + ): + new_labware_file = ( + pathlib.Path(temporary_directory) / f"{labware_index}.json" + ) + new_labware_file.write_text( + json.dumps(labware_definition), encoding="utf-8" + ) + labware_files.append(new_labware_file) + + protocol_source = asyncio.run( + ProtocolReader().read_saved( + files=[main_file] + labware_files, + directory=None, + files_are_prevalidated=False, + ) + ) + + yield protocol_source + + +class ProtocolEngineExecuteError(Exception): + def __init__(self, errors: List[ProtocolEngineErrorOccurrence]) -> None: + """Raised when there was any fatal error running a protocol through Protocol Engine. + + Protocol Engine reports errors as data, not as exceptions. + But the only way for `opentrons.execute.execute()` and `opentrons.simulate.simulate()` + to signal problems to their callers is to raise something. + So we need this class to wrap them. + + Params: + errors: The errors that Protocol Engine reported. + """ + # Show the full error details if this is part of a traceback. Don't try to summarize. + super().__init__(errors) + + self._error_occurrences = errors + + def to_stderr_string(self) -> str: + """Return a string suitable as the stderr output of the `opentrons_execute` CLI. + + This summarizes from the full error details. + """ + # It's unclear what exactly we should extract here. + # + # First, do we print the first element, or the last, or all of them? + # + # Second, do we print the .detail? .errorCode? .errorInfo? .wrappedErrors? + # By contract, .detail seems like it would be insufficient, but experimentally, + # it includes a lot, like: + # + # ProtocolEngineError [line 3]: Error 4000 GENERAL_ERROR (ProtocolEngineError): + # UnexpectedProtocolError: Labware "fixture_12_trough" not found with version 1 + # in namespace "fixture". + return self._error_occurrences[0].detail diff --git a/api/tests/opentrons/conftest.py b/api/tests/opentrons/conftest.py index b25eb9049f7..9d9d2c8d25c 100755 --- a/api/tests/opentrons/conftest.py +++ b/api/tests/opentrons/conftest.py @@ -32,6 +32,7 @@ except (OSError, ModuleNotFoundError): aionotify = None +from opentrons_shared_data.robot.dev_types import RobotTypeEnum from opentrons_shared_data.protocol.dev_types import JsonProtocol from opentrons_shared_data.labware.dev_types import LabwareDefinition from opentrons_shared_data.module.dev_types import ModuleDefinitionV3 @@ -104,8 +105,12 @@ def is_robot(monkeypatch: pytest.MonkeyPatch) -> None: @pytest.fixture def mock_feature_flags(decoy: Decoy, monkeypatch: pytest.MonkeyPatch) -> None: for name, func in inspect.getmembers(config.feature_flags, inspect.isfunction): + params = inspect.getfullargspec(func) mock_get_ff = decoy.mock(func=func) - decoy.when(mock_get_ff()).then_return(False) + if any("robot_type" in p for p in params.args): + decoy.when(mock_get_ff(RobotTypeEnum.FLEX)).then_return(False) + else: + decoy.when(mock_get_ff()).then_return(False) monkeypatch.setattr(config.feature_flags, name, mock_get_ff) @@ -129,16 +134,11 @@ def protocol_file() -> str: @pytest.fixture() def protocol(protocol_file: str) -> Generator[Protocol, None, None]: - root = protocol_file - filename = os.path.join(os.path.dirname(__file__), "data", root) - - file = open(filename) - text = "".join(list(file)) - file.seek(0) - - yield Protocol(text=text, filename=filename, filelike=file) - - file.close() + filename = os.path.join(os.path.dirname(__file__), "data", protocol_file) + with open(filename, encoding="utf-8") as file: + text = file.read() + file.seek(0) + yield Protocol(text=text, filename=filename, filelike=file) @pytest.fixture() diff --git a/api/tests/opentrons/protocol_api/core/engine/test_stringify.py b/api/tests/opentrons/protocol_api/core/engine/test_stringify.py new file mode 100644 index 00000000000..2ba44a36b97 --- /dev/null +++ b/api/tests/opentrons/protocol_api/core/engine/test_stringify.py @@ -0,0 +1,108 @@ +"""Unit tests for `stringify`.""" + + +from decoy import Decoy +from opentrons_shared_data.labware.labware_definition import LabwareDefinition + +from opentrons.protocol_api.core.engine import stringify as subject +from opentrons.protocol_engine.clients.sync_client import SyncClient +from opentrons.protocol_engine.types import ( + OFF_DECK_LOCATION, + DeckSlotLocation, + ModuleDefinition, + ModuleLocation, + OnLabwareLocation, +) +from opentrons.types import DeckSlotName + + +def _make_dummy_labware_definition( + decoy: Decoy, display_name: str +) -> LabwareDefinition: + mock = decoy.mock(cls=LabwareDefinition) + decoy.when(mock.metadata.displayName).then_return(display_name) + return mock + + +def _make_dummy_module_definition(decoy: Decoy, display_name: str) -> ModuleDefinition: + mock = decoy.mock(cls=ModuleDefinition) + decoy.when(mock.displayName).then_return(display_name) + return mock + + +def test_well_on_labware_without_user_display_name(decoy: Decoy) -> None: + """Test stringifying a well on a labware that doesn't have a user-defined label.""" + mock_client = decoy.mock(cls=SyncClient) + decoy.when(mock_client.state.labware.get_display_name("labware-id")).then_return( + None + ) + decoy.when(mock_client.state.labware.get_definition("labware-id")).then_return( + _make_dummy_labware_definition(decoy, "definition-display-name") + ) + decoy.when(mock_client.state.labware.get_location("labware-id")).then_return( + OFF_DECK_LOCATION + ) + + result = subject.well( + engine_client=mock_client, well_name="well-name", labware_id="labware-id" + ) + assert result == "well-name of definition-display-name on [off-deck]" + + +def test_well_on_labware_with_user_display_name(decoy: Decoy) -> None: + """Test stringifying a well on a labware that does have a user-defined label.""" + mock_client = decoy.mock(cls=SyncClient) + decoy.when(mock_client.state.labware.get_display_name("labware-id")).then_return( + "user-display-name" + ) + decoy.when(mock_client.state.labware.get_definition("labware-id")).then_return( + _make_dummy_labware_definition(decoy, "definition-display-name") + ) + decoy.when(mock_client.state.labware.get_location("labware-id")).then_return( + OFF_DECK_LOCATION + ) + + result = subject.well( + engine_client=mock_client, well_name="well-name", labware_id="labware-id" + ) + assert result == "well-name of user-display-name on [off-deck]" + + +def test_well_on_labware_with_complicated_location(decoy: Decoy) -> None: + """Test stringifying a well on a labware with a deeply-nested location.""" + mock_client = decoy.mock(cls=SyncClient) + + decoy.when(mock_client.state.labware.get_display_name("labware-id-1")).then_return( + None + ) + decoy.when(mock_client.state.labware.get_definition("labware-id-1")).then_return( + _make_dummy_labware_definition(decoy, "lw-1-display-name") + ) + decoy.when(mock_client.state.labware.get_location("labware-id-1")).then_return( + OnLabwareLocation(labwareId="labware-id-2") + ) + + decoy.when(mock_client.state.labware.get_display_name("labware-id-2")).then_return( + None + ) + decoy.when(mock_client.state.labware.get_definition("labware-id-2")).then_return( + _make_dummy_labware_definition(decoy, "lw-2-display-name") + ) + decoy.when(mock_client.state.labware.get_location("labware-id-2")).then_return( + ModuleLocation(moduleId="module-id") + ) + + decoy.when(mock_client.state.modules.get_definition("module-id")).then_return( + _make_dummy_module_definition(decoy, "module-display-name") + ) + decoy.when(mock_client.state.modules.get_location("module-id")).then_return( + DeckSlotLocation(slotName=DeckSlotName.SLOT_C2) + ) + + result = subject.well( + engine_client=mock_client, well_name="well-name", labware_id="labware-id-1" + ) + assert ( + result + == "well-name of lw-1-display-name on lw-2-display-name on module-display-name on slot C2" + ) diff --git a/api/tests/opentrons/protocol_api/core/engine/test_well_core.py b/api/tests/opentrons/protocol_api/core/engine/test_well_core.py index cefce273fe5..5fd33fb01ed 100644 --- a/api/tests/opentrons/protocol_api/core/engine/test_well_core.py +++ b/api/tests/opentrons/protocol_api/core/engine/test_well_core.py @@ -14,7 +14,7 @@ from opentrons.types import Point from opentrons.protocol_api._liquid import Liquid -from opentrons.protocol_api.core.engine import WellCore, point_calculations +from opentrons.protocol_api.core.engine import WellCore, point_calculations, stringify @pytest.fixture(autouse=True) @@ -26,6 +26,13 @@ def patch_mock_point_calculations( monkeypatch.setattr(point_calculations, name, decoy.mock(func=func)) +@pytest.fixture(autouse=True) +def patch_mock_stringify(decoy: Decoy, monkeypatch: pytest.MonkeyPatch) -> None: + """Mock out stringify.py functions.""" + for name, func in inspect.getmembers(stringify, inspect.isfunction): + monkeypatch.setattr(stringify, name, decoy.mock(func=func)) + + @pytest.fixture def mock_engine_client(decoy: Decoy) -> EngineClient: """Get a mock ProtocolEngine synchronous client.""" @@ -73,10 +80,14 @@ def test_display_name( ) -> None: """It should have a display name.""" decoy.when( - mock_engine_client.state.labware.get_display_name("labware-id") - ).then_return("Cool Labware") + stringify.well( + engine_client=mock_engine_client, + well_name="well-name", + labware_id="labware-id", + ) + ).then_return("Matthew Zwimpfer") - assert subject.get_display_name() == "well-name of Cool Labware" + assert subject.get_display_name() == "Matthew Zwimpfer" @pytest.mark.parametrize( 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 9907473f0b9..a7821bd80e0 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 @@ -17,6 +17,7 @@ from opentrons.protocol_engine.types import ( DeckSlotLocation, ModuleOffsetVector, + ModuleOffsetData, ) from opentrons.hardware_control.types import OT3Mount @@ -61,7 +62,12 @@ async def test_calibrate_module_implementation( ) decoy.when( subject._state_view.modules.get_module_calibration_offset(module_id) - ).then_return(ModuleOffsetVector(x=0, y=0, z=0)) + ).then_return( + ModuleOffsetData( + moduleOffsetVector=ModuleOffsetVector(x=0, y=0, z=0), + location=location, + ) + ) decoy.when( subject._state_view.geometry.get_nominal_well_position( labware_id=labware_id, well_name="B1" @@ -80,7 +86,12 @@ async def test_calibrate_module_implementation( result = await subject.execute(params) assert result == CalibrateModuleResult( - moduleOffset=ModuleOffsetVector(x=3, y=4, z=6) + moduleOffset=ModuleOffsetVector( + x=3, + y=4, + z=6, + ), + location=location, ) diff --git a/api/tests/opentrons/protocol_engine/state/test_change_notifier.py b/api/tests/opentrons/protocol_engine/state/test_change_notifier.py index ec62362d6da..4967e6d254e 100644 --- a/api/tests/opentrons/protocol_engine/state/test_change_notifier.py +++ b/api/tests/opentrons/protocol_engine/state/test_change_notifier.py @@ -54,20 +54,3 @@ async def _do_task_3() -> None: await asyncio.gather(task_1, task_2, task_3) assert results == [1, 2, 3] - - -async def test_broker() -> None: - """Test that notifications are available synchronously through `ChangeNotifier.broker`.""" - notify_count = 5 - - subject = ChangeNotifier() - received = 0 - - def callback(_message_from_broker: None) -> None: - nonlocal received - received += 1 - - with subject.broker.subscribed(callback): - for notify_number in range(notify_count): - subject.notify() - assert received == notify_number + 1 diff --git a/api/tests/opentrons/protocol_engine/state/test_command_monitor.py b/api/tests/opentrons/protocol_engine/state/test_command_monitor.py deleted file mode 100644 index dec820a97f6..00000000000 --- a/api/tests/opentrons/protocol_engine/state/test_command_monitor.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Unit tests for `opentrons.protocol_engine.command_monitor`.""" - - -from datetime import datetime -from typing import List - -from decoy import Decoy - -from opentrons.protocol_engine import CommandStatus, ProtocolEngine, commands -from opentrons.protocol_engine.command_monitor import ( - Event, - NoLongerRunningEvent, - RunningEvent, - monitor_commands as subject, -) -from opentrons.util.broker import Broker - - -def _make_dummy_command(id: str, completed: bool) -> commands.Command: - if completed: - return commands.Comment( - id=id, - key=id, - status=CommandStatus.SUCCEEDED, - createdAt=datetime(2023, 9, 26), - params=commands.CommentParams(message=""), - result=None, - ) - else: - return commands.Comment( - id=id, - key=id, - status=CommandStatus.RUNNING, - createdAt=datetime(2023, 9, 26), - completedAt=datetime(2023, 9, 26), - params=commands.CommentParams(message=""), - result=commands.CommentResult(), - ) - - -def test_monitor_commands(decoy: Decoy) -> None: - """Test that it translates state updates into command running/no-longer-running events.""" - mock_protocol_engine = decoy.mock(cls=ProtocolEngine) - mock_command_view = mock_protocol_engine.state_view.commands - state_update_broker = Broker[None]() - decoy.when(mock_protocol_engine.state_update_broker).then_return( - state_update_broker - ) - - command_1_running = _make_dummy_command(id="command-1", completed=False) - command_1_completed = _make_dummy_command(id="command-1", completed=True) - command_2_running = _make_dummy_command(id="command-2", completed=False) - command_2_completed = _make_dummy_command(id="command-2", completed=True) - - received_events: List[Event] = [] - - def callback(event: Event) -> None: - received_events.append(event) - - with subject(mock_protocol_engine, callback): - # Feed the subject these states, in sequence: - # 1. No command running - # 2. "command-1" running - # 3. "command-2" running - # 4. No command running - # Between each state, notify the subject by publishing a message to the broker that it's - # subscribed to. - - decoy.when(mock_command_view.get_running()).then_return(None) - state_update_broker.publish(message=None) - - decoy.when(mock_command_view.get_running()).then_return("command-1") - decoy.when(mock_command_view.get("command-1")).then_return(command_1_running) - state_update_broker.publish(message=None) - - decoy.when(mock_command_view.get_running()).then_return("command-2") - decoy.when(mock_command_view.get("command-1")).then_return(command_1_completed) - decoy.when(mock_command_view.get("command-2")).then_return(command_2_running) - state_update_broker.publish(message=None) - - decoy.when(mock_command_view.get_running()).then_return(None) - decoy.when(mock_command_view.get("command-2")).then_return(command_2_completed) - state_update_broker.publish(message=None) - - # Make sure the callback converted the sequence of state updates into the expected sequence - # of events. - assert received_events == [ - RunningEvent(command_1_running), - NoLongerRunningEvent(command_1_completed), - RunningEvent(command_2_running), - NoLongerRunningEvent(command_2_completed), - ] diff --git a/api/tests/opentrons/protocol_engine/state/test_command_view.py b/api/tests/opentrons/protocol_engine/state/test_command_view.py index d4f77db8dbe..b9cc6835ce3 100644 --- a/api/tests/opentrons/protocol_engine/state/test_command_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_command_view.py @@ -660,15 +660,6 @@ def test_get_okay_to_clear(subject: CommandView, expected_is_okay: bool) -> None assert subject.get_is_okay_to_clear() is expected_is_okay -def test_get_running() -> None: - """It should return the command that's currently running.""" - subject = get_command_view(running_command_id=None) - assert subject.get_running() is None - - subject = get_command_view(running_command_id="command-id") - assert subject.get_running() == "command-id" - - def test_get_current() -> None: """It should return the "current" command.""" subject = get_command_view( 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 b6740482d04..0e1204688af 100644 --- a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py @@ -20,6 +20,7 @@ ModuleLocation, OnLabwareLocation, ModuleOffsetVector, + ModuleOffsetData, LoadedLabware, LoadedModule, ModuleModel, @@ -162,7 +163,10 @@ def test_get_labware_parent_position_on_module( ) ).then_return(OverlapOffset(x=1, y=2, z=3)) decoy.when(module_view.get_module_calibration_offset("module-id")).then_return( - ModuleOffsetVector(x=2, y=3, z=4) + ModuleOffsetData( + moduleOffsetVector=ModuleOffsetVector(x=2, y=3, z=4), + location=DeckSlotLocation(slotName=DeckSlotName.SLOT_3), + ) ) result = subject.get_labware_parent_position("labware-id") @@ -199,9 +203,6 @@ def test_get_labware_parent_position_on_labware( decoy.when(labware_view.get_slot_position(DeckSlotName.SLOT_3)).then_return( Point(1, 2, 3) ) - decoy.when(labware_view.get_slot_position(DeckSlotName.SLOT_3)).then_return( - Point(1, 2, 3) - ) decoy.when(labware_view.get("adapter-id")).then_return(adapter_data) decoy.when(labware_view.get_dimensions("adapter-id")).then_return( Dimensions(x=123, y=456, z=5) @@ -227,7 +228,10 @@ def test_get_labware_parent_position_on_labware( ).then_return(OverlapOffset(x=-3, y=-2, z=-1)) decoy.when(module_view.get_module_calibration_offset("module-id")).then_return( - ModuleOffsetVector(x=3, y=4, z=5) + ModuleOffsetData( + moduleOffsetVector=ModuleOffsetVector(x=3, y=4, z=5), + location=DeckSlotLocation(slotName=DeckSlotName.SLOT_3), + ) ) result = subject.get_labware_parent_position("labware-id") @@ -235,6 +239,62 @@ def test_get_labware_parent_position_on_labware( assert result == Point(9, 12, 15) +def test_module_calibration_offset_rotation( + decoy: Decoy, + labware_view: LabwareView, + module_view: ModuleView, + ot2_standard_deck_def: DeckDefinitionV3, + subject: GeometryView, +) -> None: + """Return the rotated module calibration offset if the module was moved from one side of the deck to the other.""" + labware_data = LoadedLabware( + id="labware-id", + loadName="b", + definitionUri=uri_from_details(namespace="a", load_name="b", version=1), + location=ModuleLocation(moduleId="module-id"), + offsetId=None, + ) + + decoy.when(labware_view.get("labware-id")).then_return(labware_data) + decoy.when(module_view.get_location("module-id")).then_return( + DeckSlotLocation(slotName=DeckSlotName.SLOT_D1) + ) + decoy.when(module_view.get_connected_model("module-id")).then_return( + ModuleModel.TEMPERATURE_MODULE_V2 + ) + decoy.when(module_view.get_module_calibration_offset("module-id")).then_return( + ModuleOffsetData( + moduleOffsetVector=ModuleOffsetVector(x=2, y=3, z=4), + location=DeckSlotLocation(slotName=DeckSlotName.SLOT_D1), + ) + ) + + # the module has not changed location after calibration, so there is no rotation + result = subject._get_calibrated_module_offset(ModuleLocation(moduleId="module-id")) + assert result == ModuleOffsetVector(x=2, y=3, z=4) + + # the module has changed from slot D1 to D3, so we should rotate the calibration offset 180 degrees along the z axis + decoy.when(module_view.get_location("module-id")).then_return( + DeckSlotLocation(slotName=DeckSlotName.SLOT_D3) + ) + result = subject._get_calibrated_module_offset(ModuleLocation(moduleId="module-id")) + assert result == ModuleOffsetVector(x=-2, y=-3, z=4) + + # attempting to load the module calibration offset from an invalid slot in the middle of the deck (A2, B2, C2, D2) + # is not be allowed since you can't even load a module in the middle to perform a module calibration in the + # first place. So if someone manually edits the stored module calibration offset we will throw an assert error. + decoy.when(module_view.get_module_calibration_offset("module-id")).then_return( + ModuleOffsetData( + moduleOffsetVector=ModuleOffsetVector(x=2, y=3, z=4), + location=DeckSlotLocation(slotName=DeckSlotName.SLOT_D2), + ) + ) + with pytest.raises(AssertionError): + result = subject._get_calibrated_module_offset( + ModuleLocation(moduleId="module-id") + ) + + def test_get_labware_origin_position( decoy: Decoy, well_plate_def: LabwareDefinition, @@ -338,7 +398,10 @@ def test_get_module_labware_highest_z( ).then_return(LabwareOffsetVector(x=4, y=5, z=6)) decoy.when(module_view.get_height_over_labware("module-id")).then_return(0.5) decoy.when(module_view.get_module_calibration_offset("module-id")).then_return( - ModuleOffsetVector(x=0, y=0, z=0) + ModuleOffsetData( + moduleOffsetVector=ModuleOffsetVector(x=0, y=0, z=0), + location=DeckSlotLocation(slotName=DeckSlotName.SLOT_3), + ) ) decoy.when(module_view.get_connected_model("module-id")).then_return( ModuleModel.MAGNETIC_MODULE_V2 @@ -642,7 +705,10 @@ def test_get_module_labware_well_position( ) ).then_return(LabwareOffsetVector(x=4, y=5, z=6)) decoy.when(module_view.get_module_calibration_offset("module-id")).then_return( - ModuleOffsetVector(x=0, y=0, z=0) + ModuleOffsetData( + moduleOffsetVector=ModuleOffsetVector(x=0, y=0, z=0), + location=DeckSlotLocation(slotName=DeckSlotName.SLOT_3), + ) ) decoy.when(module_view.get_connected_model("module-id")).then_return( ModuleModel.MAGNETIC_MODULE_V2 @@ -1174,7 +1240,10 @@ def test_get_labware_grip_point_for_labware_on_module( ) ).then_return(OverlapOffset(x=10, y=20, z=30)) decoy.when(module_view.get_module_calibration_offset("module-id")).then_return( - ModuleOffsetVector(x=100, y=200, z=300) + ModuleOffsetData( + moduleOffsetVector=ModuleOffsetVector(x=100, y=200, z=300), + location=DeckSlotLocation(slotName=DeckSlotName.SLOT_4), + ) ) decoy.when(labware_view.get_slot_center_position(DeckSlotName.SLOT_4)).then_return( Point(100, 200, 300) 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 7d277d93b5a..6de6ba0d191 100644 --- a/api/tests/opentrons/protocol_engine/state/test_labware_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_labware_view.py @@ -1,9 +1,10 @@ """Labware state store tests.""" import pytest from datetime import datetime -from typing import Dict, Optional, cast, ContextManager, Any, Union +from typing import Dict, Optional, cast, ContextManager, Any, Union, NamedTuple, List from contextlib import nullcontext as does_not_raise +from opentrons_shared_data.deck import load as load_deck from opentrons_shared_data.deck.dev_types import DeckDefinitionV3 from opentrons_shared_data.pipette.dev_types import LabwareUri from opentrons_shared_data.labware.labware_definition import ( @@ -13,6 +14,11 @@ GripperOffsets, OffsetVector, ) + +from opentrons.protocols.api_support.deck_type import ( + STANDARD_OT2_DECK, + STANDARD_OT3_DECK, +) from opentrons.protocols.models import LabwareDefinition from opentrons.types import DeckSlotName, Point, MountType @@ -39,7 +45,6 @@ LabwareLoadParams, ) - plate = LoadedLabware( id="plate-id", loadName="plate-load-name", @@ -686,26 +691,91 @@ def test_get_labware_overlap_offsets() -> None: assert result == OverlapOffset(x=1, y=2, z=3) -def test_get_module_overlap_offsets() -> None: +class ModuleOverlapSpec(NamedTuple): + """Spec data to test LabwareView.get_module_overlap_offsets.""" + + spec_deck_definition: DeckDefinitionV3 + module_model: ModuleModel + stacking_offset_with_module: Dict[str, SharedDataOverlapOffset] + expected_offset: OverlapOffset + + +module_overlap_specs: List[ModuleOverlapSpec] = [ + ModuleOverlapSpec( + # Labware on temp module on OT2, with stacking overlap for temp module + spec_deck_definition=load_deck(STANDARD_OT2_DECK, 3), + module_model=ModuleModel.TEMPERATURE_MODULE_V2, + stacking_offset_with_module={ + str(ModuleModel.TEMPERATURE_MODULE_V2.value): SharedDataOverlapOffset( + x=1, y=2, z=3 + ), + }, + expected_offset=OverlapOffset(x=1, y=2, z=3), + ), + ModuleOverlapSpec( + # Labware on TC Gen1 on OT2, with stacking overlap for TC Gen1 + spec_deck_definition=load_deck(STANDARD_OT2_DECK, 3), + module_model=ModuleModel.THERMOCYCLER_MODULE_V1, + stacking_offset_with_module={ + str(ModuleModel.THERMOCYCLER_MODULE_V1.value): SharedDataOverlapOffset( + x=11, y=22, z=33 + ), + }, + expected_offset=OverlapOffset(x=11, y=22, z=33), + ), + ModuleOverlapSpec( + # Labware on TC Gen2 on OT2, with no stacking overlap + spec_deck_definition=load_deck(STANDARD_OT2_DECK, 3), + module_model=ModuleModel.THERMOCYCLER_MODULE_V2, + stacking_offset_with_module={}, + expected_offset=OverlapOffset(x=0, y=0, z=10.7), + ), + ModuleOverlapSpec( + # Labware on TC Gen2 on Flex, with no stacking overlap + spec_deck_definition=load_deck(STANDARD_OT3_DECK, 3), + module_model=ModuleModel.THERMOCYCLER_MODULE_V2, + stacking_offset_with_module={}, + expected_offset=OverlapOffset(x=0, y=0, z=0), + ), + ModuleOverlapSpec( + # Labware on TC Gen2 on Flex, with stacking overlap for TC Gen2 + spec_deck_definition=load_deck(STANDARD_OT3_DECK, 3), + module_model=ModuleModel.THERMOCYCLER_MODULE_V2, + stacking_offset_with_module={ + str(ModuleModel.THERMOCYCLER_MODULE_V2.value): SharedDataOverlapOffset( + x=111, y=222, z=333 + ), + }, + expected_offset=OverlapOffset(x=111, y=222, z=333), + ), +] + + +@pytest.mark.parametrize( + argnames=ModuleOverlapSpec._fields, + argvalues=module_overlap_specs, +) +def test_get_module_overlap_offsets( + spec_deck_definition: DeckDefinitionV3, + module_model: ModuleModel, + stacking_offset_with_module: Dict[str, SharedDataOverlapOffset], + expected_offset: OverlapOffset, +) -> None: """It should get the labware overlap offsets.""" subject = get_labware_view( + deck_definition=spec_deck_definition, labware_by_id={"plate-id": plate}, definitions_by_uri={ "some-plate-uri": LabwareDefinition.construct( # type: ignore[call-arg] - stackingOffsetWithModule={ - str( - ModuleModel.TEMPERATURE_MODULE_V2.value - ): SharedDataOverlapOffset(x=1, y=2, z=3) - } + stackingOffsetWithModule=stacking_offset_with_module ) }, ) - result = subject.get_module_overlap_offsets( - labware_id="plate-id", module_model=ModuleModel.TEMPERATURE_MODULE_V2 + labware_id="plate-id", module_model=module_model ) - assert result == OverlapOffset(x=1, y=2, z=3) + assert result == expected_offset def test_get_default_magnet_height( diff --git a/api/tests/opentrons/protocol_engine/state/test_module_view.py b/api/tests/opentrons/protocol_engine/state/test_module_view.py index 80f053e12a0..5b83cda94f0 100644 --- a/api/tests/opentrons/protocol_engine/state/test_module_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_module_view.py @@ -16,7 +16,7 @@ ModuleLocation, LabwareOffsetVector, DeckType, - ModuleOffsetVector, + ModuleOffsetData, HeaterShakerLatchStatus, LabwareMovementOffsetData, ) @@ -44,7 +44,7 @@ def make_module_view( requested_model_by_module_id: Optional[Dict[str, Optional[ModuleModel]]] = None, hardware_by_module_id: Optional[Dict[str, HardwareModule]] = None, substate_by_module_id: Optional[Dict[str, ModuleSubStateType]] = None, - module_offset_by_serial: Optional[Dict[str, ModuleOffsetVector]] = None, + module_offset_by_serial: Optional[Dict[str, ModuleOffsetData]] = None, ) -> ModuleView: """Get a module view test subject with the specified state.""" state = ModuleState( @@ -325,7 +325,8 @@ def test_get_module_offset_for_ot2_standard( }, ) assert ( - subject.get_module_offset("module-id", DeckType.OT2_STANDARD) == expected_offset + subject.get_nominal_module_offset("module-id", DeckType.OT2_STANDARD) + == expected_offset ) @@ -379,7 +380,9 @@ def test_get_module_offset_for_ot3_standard( ) }, ) - result_offset = subject.get_module_offset("module-id", DeckType.OT3_STANDARD) + result_offset = subject.get_nominal_module_offset( + "module-id", DeckType.OT3_STANDARD + ) assert (result_offset.x, result_offset.y, result_offset.z) == pytest.approx( (expected_offset.x, expected_offset.y, expected_offset.z) ) diff --git a/api/tests/opentrons/protocol_engine/test_protocol_engine.py b/api/tests/opentrons/protocol_engine/test_protocol_engine.py index 8ae068e7480..d8928126495 100644 --- a/api/tests/opentrons/protocol_engine/test_protocol_engine.py +++ b/api/tests/opentrons/protocol_engine/test_protocol_engine.py @@ -670,7 +670,7 @@ async def test_stop( state_store: StateStore, subject: ProtocolEngine, ) -> None: - """It should be able to stop the engine and halt the hardware.""" + """It should be able to stop the engine and run execution.""" expected_action = StopAction() decoy.when( @@ -685,6 +685,33 @@ async def test_stop( ) +async def test_stop_for_legacy_core_protocols( + decoy: Decoy, + action_dispatcher: ActionDispatcher, + queue_worker: QueueWorker, + hardware_stopper: HardwareStopper, + hardware_api: HardwareControlAPI, + state_store: StateStore, + subject: ProtocolEngine, +) -> None: + """It should be able to stop the engine & run execution and cancel movement tasks.""" + expected_action = StopAction() + + decoy.when( + state_store.commands.validate_action_allowed(expected_action), + ).then_return(expected_action) + + decoy.when(hardware_api.is_movement_execution_taskified()).then_return(True) + + await subject.stop() + + decoy.verify( + action_dispatcher.dispatch(expected_action), + queue_worker.cancel(), + await hardware_api.cancel_execution_and_running_tasks(), + ) + + @pytest.mark.parametrize("maintenance_run", [True, False]) async def test_estop_during_command( decoy: Decoy, diff --git a/api/tests/opentrons/protocol_runner/test_protocol_runner.py b/api/tests/opentrons/protocol_runner/test_protocol_runner.py index 8e31384311f..7965fc3bc1f 100644 --- a/api/tests/opentrons/protocol_runner/test_protocol_runner.py +++ b/api/tests/opentrons/protocol_runner/test_protocol_runner.py @@ -444,10 +444,11 @@ async def test_load_legacy_python( python_parse_mode=PythonParseMode.ALLOW_LEGACY_METADATA_AND_REQUIREMENTS, ) ).then_return(legacy_protocol) + broker_captor = matchers.Captor() decoy.when( legacy_context_creator.create( protocol=legacy_protocol, - broker=matchers.IsA(LegacyBroker), + broker=broker_captor, equipment_broker=matchers.IsA(Broker), ) ).then_return(legacy_context) @@ -469,6 +470,7 @@ async def test_load_legacy_python( context=legacy_context, ), ) + assert broker_captor.value is legacy_python_runner_subject.broker async def test_load_python_with_pe_papi_core( @@ -514,9 +516,10 @@ async def test_load_python_with_pe_papi_core( python_parse_mode=PythonParseMode.ALLOW_LEGACY_METADATA_AND_REQUIREMENTS, ) ).then_return(legacy_protocol) + broker_captor = matchers.Captor() decoy.when( legacy_context_creator.create( - protocol=legacy_protocol, broker=None, equipment_broker=None + protocol=legacy_protocol, broker=broker_captor, equipment_broker=None ) ).then_return(legacy_context) @@ -526,6 +529,7 @@ async def test_load_python_with_pe_papi_core( ) decoy.verify(protocol_engine.add_plugin(matchers.IsA(LegacyContextPlugin)), times=0) + assert broker_captor.value is legacy_python_runner_subject.broker async def test_load_legacy_json( diff --git a/api/tests/opentrons/protocols/test_parse.py b/api/tests/opentrons/protocols/test_parse.py index 54899de6117..cc86621601a 100644 --- a/api/tests/opentrons/protocols/test_parse.py +++ b/api/tests/opentrons/protocols/test_parse.py @@ -426,14 +426,19 @@ def test_parse_python_details( assert parsed.text == protocol_source assert isinstance(parsed.text, str) - fname = filename if filename is not None else "" - - assert parsed.filename == fname + assert parsed.filename == filename + assert parsed.contents.co_filename == ( + filename if filename is not None else "" + ) assert parsed.api_level == expected_api_level assert expected_robot_type == expected_robot_type assert parsed.metadata == expected_metadata - assert parsed.contents == compile(protocol_source, filename=fname, mode="exec") + assert parsed.contents == compile( + protocol_source, + filename="", + mode="exec", + ) @pytest.mark.parametrize( @@ -481,7 +486,7 @@ def test_parse_bundle_details(get_bundle_fixture: Callable[..., Any]) -> None: parsed = parse(fixture["binary_zipfile"], filename) assert isinstance(parsed, PythonProtocol) - assert parsed.filename == "protocol.ot2.py" + assert parsed.filename == filename assert parsed.bundled_labware == fixture["bundled_labware"] assert parsed.bundled_python == fixture["bundled_python"] assert parsed.bundled_data == fixture["bundled_data"] diff --git a/api/tests/opentrons/test_execute.py b/api/tests/opentrons/test_execute.py index d233914af24..2cfbb940618 100644 --- a/api/tests/opentrons/test_execute.py +++ b/api/tests/opentrons/test_execute.py @@ -6,7 +6,7 @@ import textwrap import mock from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, Generator, TextIO, cast +from typing import TYPE_CHECKING, Any, Callable, Generator, List, TextIO, cast import pytest @@ -21,6 +21,7 @@ from opentrons.hardware_control import Controller, api from opentrons.protocol_api.core.engine import ENGINE_CORE_API_VERSION from opentrons.protocols.api_support.types import APIVersion +from opentrons.util import entrypoint_util if TYPE_CHECKING: from tests.opentrons.conftest import Bundle, Protocol @@ -58,60 +59,33 @@ async def dummy_delay(self: Any, duration_s: float) -> None: return gai_mock -@pytest.mark.parametrize("protocol_file", ["testosaur_v2.py"]) -def test_execute_function_apiv2( - protocol: Protocol, - protocol_file: str, - virtual_smoothie_env: None, - mock_get_attached_instr: mock.AsyncMock, -) -> None: - """Test `execute()` with a Python file.""" - converted_model_v15 = pipette_load_name.convert_pipette_model( - cast(PipetteModel, "p10_single_v1.5") - ) - converted_model_v1 = pipette_load_name.convert_pipette_model( - cast(PipetteModel, "p1000_single_v1") - ) - - mock_get_attached_instr.return_value[types.Mount.LEFT] = { - "config": load_pipette_data.load_definition( - converted_model_v15.pipette_type, - converted_model_v15.pipette_channels, - converted_model_v15.pipette_version, +@pytest.mark.parametrize( + ("protocol_file", "expected_entries"), + [ + ( + "testosaur_v2.py", + [ + "Picking up tip from A1 of Opentrons 96 Tip Rack 1000 µL on 1", + "Aspirating 100.0 uL from A1 of Corning 96 Well Plate 360 µL Flat on 2 at 500.0 uL/sec", + "Dispensing 100.0 uL into B1 of Corning 96 Well Plate 360 µL Flat on 2 at 1000.0 uL/sec", + "Dropping tip into H12 of Opentrons 96 Tip Rack 1000 µL on 1", + ], ), - "id": "testid", - } - mock_get_attached_instr.return_value[types.Mount.RIGHT] = { - "config": load_pipette_data.load_definition( - converted_model_v1.pipette_type, - converted_model_v1.pipette_channels, - converted_model_v1.pipette_version, + ( + "testosaur_v2_14.py", + [ + "Picking up tip from A1 of Opentrons 96 Tip Rack 1000 µL on slot 1", + "Aspirating 100.0 uL from A1 of Corning 96 Well Plate 360 µL Flat on slot 2 at 500.0 uL/sec", + "Dispensing 100.0 uL into B1 of Corning 96 Well Plate 360 µL Flat on slot 2 at 1000.0 uL/sec", + "Dropping tip into H12 of Opentrons 96 Tip Rack 1000 µL on slot 1", + ], ), - "id": "testid2", - } - entries = [] - - def emit_runlog(entry: Any) -> None: - nonlocal entries - entries.append(entry) - - execute.execute(protocol.filelike, protocol.filename, emit_runlog=emit_runlog) - - assert [item["payload"]["text"] for item in entries if item["$"] == "before"] == [ - "Picking up tip from A1 of Opentrons 96 Tip Rack 1000 µL on 1", - "Aspirating 100.0 uL from A1 of Corning 96 Well Plate 360 µL Flat on 2 at 500.0 uL/sec", - "Dispensing 100.0 uL into B1 of Corning 96 Well Plate 360 µL Flat on 2 at 1000.0 uL/sec", - "Dropping tip into H12 of Opentrons 96 Tip Rack 1000 µL on 1", - ] - - -# TODO(mm, 2023-09-26): Merge this with the above test_execute_apiv2_14() function when -# we resolve https://opentrons.atlassian.net/browse/RSS-320 and PAPIv≥2.14 protocols emit -# human-readable run log text. -@pytest.mark.parametrize("protocol_file", ["testosaur_v2_14.py"]) -def test_execute_function_apiv2_14( + ], +) +def test_execute_function_apiv2( protocol: Protocol, protocol_file: str, + expected_entries: List[str], virtual_smoothie_env: None, mock_get_attached_instr: mock.AsyncMock, ) -> None: @@ -147,27 +121,9 @@ def emit_runlog(entry: Any) -> None: execute.execute(protocol.filelike, protocol.filename, emit_runlog=emit_runlog) - # https://opentrons.atlassian.net/browse/RSS-320: - # PAPIv≥2.14 protocols currently emit JSON run log text, not human-readable text. - # Their exact contents can't be tested here because they're too verbose and they have - # unpredictable fields like `createdAt` and `id`. So as an approximation, we just test - # the command types. - command_types = [ - json.loads(item["payload"]["text"])["commandType"] - for item in entries - if item["$"] == "before" - ] - assert command_types == [ - "home", - "home", - "loadLabware", - "loadPipette", - "loadLabware", - "pickUpTip", - "aspirate", - "dispense", - "dropTip", - ] + assert [ + item["payload"]["text"] for item in entries if item["$"] == "before" + ] == expected_entries def test_execute_function_json_v3( @@ -402,8 +358,12 @@ def test_jupyter( monkeypatch: pytest.MonkeyPatch, ) -> None: """Putting labware in the Jupyter directory should make it available.""" - monkeypatch.setattr(execute, "IS_ROBOT", True) - monkeypatch.setattr(execute, "JUPYTER_NOTEBOOK_LABWARE_DIR", self.LW_DIR) + # TODO(mm, 2023-10-06): This is monkeypatching a dependency of a dependency, + # which is too deep. + monkeypatch.setattr(entrypoint_util, "IS_ROBOT", True) + monkeypatch.setattr( + entrypoint_util, "JUPYTER_NOTEBOOK_LABWARE_DIR", self.LW_DIR + ) execute.execute(protocol_file=protocol_filelike, protocol_name=protocol_name) @pytest.mark.xfail( @@ -416,8 +376,12 @@ def test_jupyter_override( monkeypatch: pytest.MonkeyPatch, ) -> None: """Passing any custom_labware_paths should prevent searching the Jupyter directory.""" - monkeypatch.setattr(execute, "IS_ROBOT", True) - monkeypatch.setattr(execute, "JUPYTER_NOTEBOOK_LABWARE_DIR", self.LW_DIR) + # TODO(mm, 2023-10-06): This is monkeypatching a dependency of a dependency, + # which is too deep. + monkeypatch.setattr(entrypoint_util, "IS_ROBOT", True) + monkeypatch.setattr( + entrypoint_util, "JUPYTER_NOTEBOOK_LABWARE_DIR", self.LW_DIR + ) with pytest.raises(Exception, match="Labware .+ not found"): execute.execute( protocol_file=protocol_filelike, @@ -432,9 +396,11 @@ def test_jupyter_not_on_filesystem( monkeypatch: pytest.MonkeyPatch, ) -> None: """It should tolerate the Jupyter labware directory not existing on the filesystem.""" - monkeypatch.setattr(execute, "IS_ROBOT", True) + # TODO(mm, 2023-10-06): This is monkeypatching a dependency of a dependency, + # which is too deep. + monkeypatch.setattr(entrypoint_util, "IS_ROBOT", True) monkeypatch.setattr( - execute, "JUPYTER_NOTEBOOK_LABWARE_DIR", HERE / "nosuchdirectory" + entrypoint_util, "JUPYTER_NOTEBOOK_LABWARE_DIR", HERE / "nosuchdirectory" ) with pytest.raises(Exception, match="Labware .+ not found"): execute.execute( @@ -480,9 +446,11 @@ def test_jupyter( self, api_version: APIVersion, monkeypatch: pytest.MonkeyPatch ) -> None: """Putting labware in the Jupyter directory should make it available.""" - monkeypatch.setattr(execute, "IS_ROBOT", True) + # TODO(mm, 2023-10-06): This is monkeypatching a dependency of a dependency, + # which is too deep. + monkeypatch.setattr(entrypoint_util, "IS_ROBOT", True) monkeypatch.setattr( - execute, + entrypoint_util, "JUPYTER_NOTEBOOK_LABWARE_DIR", get_shared_data_root() / self.LW_FIXTURE_DIR, ) @@ -491,20 +459,19 @@ def test_jupyter( load_name=self.LW_LOAD_NAME, location=1, namespace=self.LW_NAMESPACE ) - @pytest.mark.xfail( - strict=True, raises=pytest.fail.Exception - ) # TODO(mm, 2023-07-14): Fix this bug. def test_jupyter_override( self, api_version: APIVersion, monkeypatch: pytest.MonkeyPatch ) -> None: """Passing any extra_labware should prevent searching the Jupyter directory.""" - monkeypatch.setattr(execute, "IS_ROBOT", True) + # TODO(mm, 2023-10-06): This is monkeypatching a dependency of a dependency, + # which is too deep. + monkeypatch.setattr(entrypoint_util, "IS_ROBOT", True) monkeypatch.setattr( - execute, + entrypoint_util, "JUPYTER_NOTEBOOK_LABWARE_DIR", get_shared_data_root() / self.LW_FIXTURE_DIR, ) - context = execute.get_protocol_api(api_version) + context = execute.get_protocol_api(api_version, extra_labware={}) with pytest.raises(Exception, match="Labware .+ not found"): context.load_labware( load_name=self.LW_LOAD_NAME, location=1, namespace=self.LW_NAMESPACE @@ -514,8 +481,11 @@ def test_jupyter_not_on_filesystem( self, api_version: APIVersion, monkeypatch: pytest.MonkeyPatch ) -> None: """It should tolerate the Jupyter labware directory not existing on the filesystem.""" + # TODO(mm, 2023-10-06): This is monkeypatching a dependency of a dependency, + # which is too deep. + monkeypatch.setattr(entrypoint_util, "IS_ROBOT", True) monkeypatch.setattr( - execute, "JUPYTER_NOTEBOOK_LABWARE_DIR", HERE / "nosuchdirectory" + entrypoint_util, "JUPYTER_NOTEBOOK_LABWARE_DIR", HERE / "nosuchdirectory" ) with_nonexistent_jupyter_extra_labware = execute.get_protocol_api(api_version) with pytest.raises(Exception, match="Labware .+ not found"): diff --git a/api/tests/opentrons/test_simulate.py b/api/tests/opentrons/test_simulate.py index 93df57651a9..17931181361 100644 --- a/api/tests/opentrons/test_simulate.py +++ b/api/tests/opentrons/test_simulate.py @@ -5,16 +5,18 @@ import json import textwrap from pathlib import Path -from typing import TYPE_CHECKING, Callable, Generator, TextIO, cast +from typing import TYPE_CHECKING, Callable, Generator, List, TextIO, cast import pytest from opentrons_shared_data import get_shared_data_root, load_shared_data from opentrons import simulate, protocols +from opentrons.protocol_api.core.engine import ENGINE_CORE_API_VERSION from opentrons.protocols.types import ApiDeprecationError from opentrons.protocols.api_support.types import APIVersion from opentrons.protocols.execution.errors import ExceptionInProtocolError +from opentrons.util import entrypoint_util if TYPE_CHECKING: from tests.opentrons.conftest import Bundle, Protocol @@ -23,13 +25,7 @@ HERE = Path(__file__).parent -@pytest.fixture( - params=[ - APIVersion(2, 0), - # TODO(mm, 2023-07-14): Enable this for https://opentrons.atlassian.net/browse/RSS-268. - # ENGINE_CORE_API_VERSION, - ] -) +@pytest.fixture(params=[APIVersion(2, 0), ENGINE_CORE_API_VERSION]) def api_version(request: pytest.FixtureRequest) -> APIVersion: """Return an API version to test with. @@ -43,28 +39,66 @@ def api_version(request: pytest.FixtureRequest) -> APIVersion: "protocol_file", [ "testosaur_v2.py", - # TODO(mm, 2023-07-14): Resolve this xfail. https://opentrons.atlassian.net/browse/RSS-268 pytest.param( "testosaur_v2_14.py", - marks=pytest.mark.xfail(strict=True, raises=NotImplementedError), + marks=pytest.mark.xfail( + strict=True, + reason=( + "We can't currently get bundle contents" + " from protocols run through Protocol Engine." + ), + ), ), ], ) -def test_simulate_function_apiv2( +def test_simulate_function_apiv2_bundle( protocol: Protocol, protocol_file: str, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Test `simulate()` with a Python file.""" + """Test that `simulate()` returns the expected bundle contents from a Python file.""" monkeypatch.setenv("OT_API_FF_allowBundleCreation", "1") - runlog, bundle = simulate.simulate(protocol.filelike, protocol.filename) - assert isinstance(bundle, protocols.types.BundleContents) - assert [item["payload"]["text"] for item in runlog] == [ - "Picking up tip from A1 of Opentrons 96 Tip Rack 1000 µL on 1", - "Aspirating 100.0 uL from A1 of Corning 96 Well Plate 360 µL Flat on 2 at 500.0 uL/sec", - "Dispensing 100.0 uL into B1 of Corning 96 Well Plate 360 µL Flat on 2 at 1000.0 uL/sec", - "Dropping tip into H12 of Opentrons 96 Tip Rack 1000 µL on 1", - ] + _, bundle_contents = simulate.simulate(protocol.filelike, protocol.filename) + assert isinstance(bundle_contents, protocols.types.BundleContents) + + +@pytest.mark.parametrize("protocol_file", ["testosaur_v2.py", "testosaur_v2_14.py"]) +def test_simulate_without_filename(protocol: Protocol, protocol_file: str) -> None: + """`simulate()` should accept a protocol without a filename.""" + simulate.simulate(protocol.filelike) # Should not raise. + + +@pytest.mark.parametrize( + ("protocol_file", "expected_entries"), + [ + ( + "testosaur_v2.py", + [ + "Picking up tip from A1 of Opentrons 96 Tip Rack 1000 µL on 1", + "Aspirating 100.0 uL from A1 of Corning 96 Well Plate 360 µL Flat on 2 at 500.0 uL/sec", + "Dispensing 100.0 uL into B1 of Corning 96 Well Plate 360 µL Flat on 2 at 1000.0 uL/sec", + "Dropping tip into H12 of Opentrons 96 Tip Rack 1000 µL on 1", + ], + ), + ( + "testosaur_v2_14.py", + [ + "Picking up tip from A1 of Opentrons 96 Tip Rack 1000 µL on slot 1", + "Aspirating 100.0 uL from A1 of Corning 96 Well Plate 360 µL Flat on slot 2 at 500.0 uL/sec", + "Dispensing 100.0 uL into B1 of Corning 96 Well Plate 360 µL Flat on slot 2 at 1000.0 uL/sec", + "Dropping tip into H12 of Opentrons 96 Tip Rack 1000 µL on slot 1", + ], + ), + ], +) +def test_simulate_function_apiv2_run_log( + protocol: Protocol, + protocol_file: str, + expected_entries: List[str], +) -> None: + """Test that `simulate()` returns the expected run log from a Python file.""" + run_log, _ = simulate.simulate(protocol.filelike, protocol.filename) + assert [item["payload"]["text"] for item in run_log] == expected_entries def test_simulate_function_json( @@ -195,8 +229,12 @@ def test_jupyter( monkeypatch: pytest.MonkeyPatch, ) -> None: """Putting labware in the Jupyter directory should make it available.""" - monkeypatch.setattr(simulate, "IS_ROBOT", True) - monkeypatch.setattr(simulate, "JUPYTER_NOTEBOOK_LABWARE_DIR", self.LW_DIR) + # TODO(mm, 2023-10-06): This is monkeypatching a dependency of a dependency, + # which is too deep. + monkeypatch.setattr(entrypoint_util, "IS_ROBOT", True) + monkeypatch.setattr( + entrypoint_util, "JUPYTER_NOTEBOOK_LABWARE_DIR", self.LW_DIR + ) simulate.simulate(protocol_file=protocol_filelike, file_name=file_name) @pytest.mark.xfail( @@ -209,8 +247,12 @@ def test_jupyter_override( monkeypatch: pytest.MonkeyPatch, ) -> None: """Passing any custom_labware_paths should prevent searching the Jupyter directory.""" - monkeypatch.setattr(simulate, "IS_ROBOT", True) - monkeypatch.setattr(simulate, "JUPYTER_NOTEBOOK_LABWARE_DIR", self.LW_DIR) + # TODO(mm, 2023-10-06): This is monkeypatching a dependency of a dependency, + # which is too deep. + monkeypatch.setattr(entrypoint_util, "IS_ROBOT", True) + monkeypatch.setattr( + entrypoint_util, "JUPYTER_NOTEBOOK_LABWARE_DIR", self.LW_DIR + ) with pytest.raises(Exception, match="Labware .+ not found"): simulate.simulate( protocol_file=protocol_filelike, @@ -225,14 +267,27 @@ def test_jupyter_not_on_filesystem( monkeypatch: pytest.MonkeyPatch, ) -> None: """It should tolerate the Jupyter labware directory not existing on the filesystem.""" - monkeypatch.setattr(simulate, "IS_ROBOT", True) + # TODO(mm, 2023-10-06): This is monkeypatching a dependency of a dependency, + # which is too deep. + monkeypatch.setattr(entrypoint_util, "IS_ROBOT", True) monkeypatch.setattr( - simulate, "JUPYTER_NOTEBOOK_LABWARE_DIR", HERE / "nosuchdirectory" + entrypoint_util, "JUPYTER_NOTEBOOK_LABWARE_DIR", HERE / "nosuchdirectory" ) with pytest.raises(Exception, match="Labware .+ not found"): simulate.simulate(protocol_file=protocol_filelike, file_name=file_name) +def test_get_protocol_api_usable_without_homing(api_version: APIVersion) -> None: + """You should be able to move the simulated hardware without having to home explicitly. + + https://opentrons.atlassian.net/browse/RQA-1801 + """ + protocol = simulate.get_protocol_api(api_version) + pipette = protocol.load_instrument("p300_single_gen2", mount="left") + tip_rack = protocol.load_labware("opentrons_96_tiprack_300ul", 1) + pipette.pick_up_tip(tip_rack["A1"]) # Should not raise. + + class TestGetProtocolAPILabware: """Tests for making sure get_protocol_api() handles extra labware correctly.""" @@ -268,9 +323,11 @@ def test_jupyter( self, api_version: APIVersion, monkeypatch: pytest.MonkeyPatch ) -> None: """Putting labware in the Jupyter directory should make it available.""" - monkeypatch.setattr(simulate, "IS_ROBOT", True) + # TODO(mm, 2023-10-06): This is monkeypatching a dependency of a dependency, + # which is too deep. + monkeypatch.setattr(entrypoint_util, "IS_ROBOT", True) monkeypatch.setattr( - simulate, + entrypoint_util, "JUPYTER_NOTEBOOK_LABWARE_DIR", get_shared_data_root() / self.LW_FIXTURE_DIR, ) @@ -279,20 +336,19 @@ def test_jupyter( load_name=self.LW_LOAD_NAME, location=1, namespace=self.LW_NAMESPACE ) - @pytest.mark.xfail( - strict=True, raises=pytest.fail.Exception - ) # TODO(mm, 2023-07-14): Fix this bug. def test_jupyter_override( self, api_version: APIVersion, monkeypatch: pytest.MonkeyPatch ) -> None: """Passing any extra_labware should prevent searching the Jupyter directory.""" - monkeypatch.setattr(simulate, "IS_ROBOT", True) + # TODO(mm, 2023-10-06): This is monkeypatching a dependency of a dependency, + # which is too deep. + monkeypatch.setattr(entrypoint_util, "IS_ROBOT", True) monkeypatch.setattr( - simulate, + entrypoint_util, "JUPYTER_NOTEBOOK_LABWARE_DIR", get_shared_data_root() / self.LW_FIXTURE_DIR, ) - context = simulate.get_protocol_api(api_version) + context = simulate.get_protocol_api(api_version, extra_labware={}) with pytest.raises(Exception, match="Labware .+ not found"): context.load_labware( load_name=self.LW_LOAD_NAME, location=1, namespace=self.LW_NAMESPACE @@ -302,8 +358,11 @@ def test_jupyter_not_on_filesystem( self, api_version: APIVersion, monkeypatch: pytest.MonkeyPatch ) -> None: """It should tolerate the Jupyter labware directory not existing on the filesystem.""" + # TODO(mm, 2023-10-06): This is monkeypatching a dependency of a dependency, + # which is too deep. + monkeypatch.setattr(entrypoint_util, "IS_ROBOT", True) monkeypatch.setattr( - simulate, "JUPYTER_NOTEBOOK_LABWARE_DIR", HERE / "nosuchdirectory" + entrypoint_util, "JUPYTER_NOTEBOOK_LABWARE_DIR", HERE / "nosuchdirectory" ) with_nonexistent_jupyter_extra_labware = simulate.get_protocol_api(api_version) with pytest.raises(Exception, match="Labware .+ not found"): diff --git a/api/tests/opentrons/util/test_entrypoint_util.py b/api/tests/opentrons/util/test_entrypoint_util.py index 4e82ce0e80f..c30351dec3b 100644 --- a/api/tests/opentrons/util/test_entrypoint_util.py +++ b/api/tests/opentrons/util/test_entrypoint_util.py @@ -1,17 +1,13 @@ -import io import json import os from pathlib import Path from typing import Callable -import pytest - from opentrons_shared_data.labware.dev_types import LabwareDefinition as LabwareDefDict from opentrons.util.entrypoint_util import ( FoundLabware, labware_from_paths, datafiles_from_paths, - copy_file_like, ) @@ -79,70 +75,3 @@ def test_datafiles_from_paths(tmp_path: Path) -> None: "test1": "wait theres a second file???".encode(), "test-file": "this isnt even in a directory".encode(), } - - -class TestCopyFileLike: - """Tests for `copy_file_like()`.""" - - @pytest.fixture(params=["abc", "µ"]) - def source_text(self, request: pytest.FixtureRequest) -> str: - return request.param # type: ignore[attr-defined,no-any-return] - - @pytest.fixture - def source_bytes(self, source_text: str) -> bytes: - return b"\x00\x01\x02\x03\x04" - - @pytest.fixture - def source_path(self, tmp_path: Path) -> Path: - return tmp_path / "source" - - @pytest.fixture - def destination_path(self, tmp_path: Path) -> Path: - return tmp_path / "destination" - - def test_from_text_file( - self, - source_text: str, - source_path: Path, - destination_path: Path, - ) -> None: - """Test that it correctly copies from a text-mode `open()`.""" - source_path.write_text(source_text) - - with open( - source_path, - mode="rt", - ) as source_file: - copy_file_like(source=source_file, destination=destination_path) - - assert destination_path.read_text() == source_text - - def test_from_binary_file( - self, - source_bytes: bytes, - source_path: Path, - destination_path: Path, - ) -> None: - """Test that it correctly copies from a binary-mode `open()`.""" - source_path.write_bytes(source_bytes) - - with open(source_path, mode="rb") as source_file: - copy_file_like(source=source_file, destination=destination_path) - - assert destination_path.read_bytes() == source_bytes - - def test_from_stringio(self, source_text: str, destination_path: Path) -> None: - """Test that it correctly copies from an `io.StringIO`.""" - stringio = io.StringIO(source_text) - - copy_file_like(source=stringio, destination=destination_path) - - assert destination_path.read_text() == source_text - - def test_from_bytesio(self, source_bytes: bytes, destination_path: Path) -> None: - """Test that it correctly copies from an `io.BytesIO`.""" - bytesio = io.BytesIO(source_bytes) - - copy_file_like(source=bytesio, destination=destination_path) - - assert destination_path.read_bytes() == source_bytes diff --git a/app-shell-odd/Makefile b/app-shell-odd/Makefile index 5dd7ca92736..309beca156d 100644 --- a/app-shell-odd/Makefile +++ b/app-shell-odd/Makefile @@ -48,7 +48,7 @@ setup: .PHONY: clean clean: - shx rm -rf lib dist + shx rm -rf lib dist opentrons-robot-app.tar.gz # artifacts ##################################################################### @@ -75,7 +75,7 @@ push-ot3: dist-ot3 scp $(if $(ssh_key),-i $(ssh_key)) $(ssh_opts) -r ./opentrons-robot-app.tar.gz root@$(host): ssh $(if $(ssh_key),-i $(ssh_key)) $(ssh_opts) root@$(host) "mount -o remount,rw / && systemctl stop opentrons-robot-app && rm -rf /opt/opentrons-app && mkdir -p /opt/opentrons-app" ssh $(if $(ssh_key),-i $(ssh_key)) $(ssh_opts) root@$(host) "tar -xvf opentrons-robot-app.tar.gz -C /opt/opentrons-app/ && mount -o remount,ro / && systemctl start opentrons-robot-app && rm -rf opentrons-robot-app.tar.gz" - rm -rf opentrons-robot-app.tar.gz + # development ##################################################################### diff --git a/app-shell-odd/src/config/__fixtures__/index.ts b/app-shell-odd/src/config/__fixtures__/index.ts index b6f6d211253..08725e1cd2d 100644 --- a/app-shell-odd/src/config/__fixtures__/index.ts +++ b/app-shell-odd/src/config/__fixtures__/index.ts @@ -7,6 +7,7 @@ import type { ConfigV17, ConfigV18, ConfigV19, + ConfigV20, } from '@opentrons/app/src/redux/config/types' export const MOCK_CONFIG_V12: ConfigV12 = { @@ -118,3 +119,13 @@ export const MOCK_CONFIG_V19: ConfigV19 = { hasJustUpdated: false, }, } + +export const MOCK_CONFIG_V20: ConfigV20 = { + ...MOCK_CONFIG_V19, + version: 20, + robotSystemUpdate: { + manifestUrls: { + OT2: 'not-used-on-ODD', + }, + }, +} diff --git a/app-shell-odd/src/config/__tests__/migrate.test.ts b/app-shell-odd/src/config/__tests__/migrate.test.ts index bb197986621..b752b9437de 100644 --- a/app-shell-odd/src/config/__tests__/migrate.test.ts +++ b/app-shell-odd/src/config/__tests__/migrate.test.ts @@ -8,10 +8,11 @@ import { MOCK_CONFIG_V17, MOCK_CONFIG_V18, MOCK_CONFIG_V19, + MOCK_CONFIG_V20, } from '../__fixtures__' import { migrate } from '../migrate' -const NEWEST_VERSION = 19 +const NEWEST_VERSION = 20 describe('config migration', () => { it('should migrate version 12 to latest', () => { @@ -19,7 +20,7 @@ describe('config migration', () => { const result = migrate(v12Config) expect(result.version).toBe(NEWEST_VERSION) - expect(result).toEqual(MOCK_CONFIG_V19) + expect(result).toEqual(MOCK_CONFIG_V20) }) it('should migrate version 13 to latest', () => { @@ -27,7 +28,7 @@ describe('config migration', () => { const result = migrate(v13Config) expect(result.version).toBe(NEWEST_VERSION) - expect(result).toEqual(MOCK_CONFIG_V19) + expect(result).toEqual(MOCK_CONFIG_V20) }) it('should migrate version 14 to latest', () => { @@ -35,7 +36,7 @@ describe('config migration', () => { const result = migrate(v14Config) expect(result.version).toBe(NEWEST_VERSION) - expect(result).toEqual(MOCK_CONFIG_V19) + expect(result).toEqual(MOCK_CONFIG_V20) }) it('should migrate version 15 to latest', () => { @@ -43,7 +44,7 @@ describe('config migration', () => { const result = migrate(v15Config) expect(result.version).toBe(NEWEST_VERSION) - expect(result).toEqual(MOCK_CONFIG_V19) + expect(result).toEqual(MOCK_CONFIG_V20) }) it('should migrate version 16 to latest', () => { @@ -51,7 +52,7 @@ describe('config migration', () => { const result = migrate(v16Config) expect(result.version).toBe(NEWEST_VERSION) - expect(result).toEqual(MOCK_CONFIG_V19) + expect(result).toEqual(MOCK_CONFIG_V20) }) it('should migrate version 17 to latest', () => { @@ -59,22 +60,30 @@ describe('config migration', () => { const result = migrate(v17Config) expect(result.version).toBe(NEWEST_VERSION) - expect(result).toEqual(MOCK_CONFIG_V19) + expect(result).toEqual(MOCK_CONFIG_V20) }) - it('should keep version 18', () => { + it('should migration version 18 to latest', () => { const v18Config = MOCK_CONFIG_V18 const result = migrate(v18Config) expect(result.version).toBe(NEWEST_VERSION) - expect(result).toEqual(MOCK_CONFIG_V19) + expect(result).toEqual(MOCK_CONFIG_V20) }) - it('should keep version 19', () => { + it('should migration version 19 to latest', () => { const v19Config = MOCK_CONFIG_V19 const result = migrate(v19Config) expect(result.version).toBe(NEWEST_VERSION) - expect(result).toEqual(v19Config) + expect(result).toEqual(MOCK_CONFIG_V20) + }) + + it('should keep version 20', () => { + const v20Config = MOCK_CONFIG_V20 + const result = migrate(v20Config) + + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(v20Config) }) }) diff --git a/app-shell-odd/src/config/migrate.ts b/app-shell-odd/src/config/migrate.ts index 48d45f5cc3c..4aed0cdf1bf 100644 --- a/app-shell-odd/src/config/migrate.ts +++ b/app-shell-odd/src/config/migrate.ts @@ -13,6 +13,7 @@ import type { ConfigV17, ConfigV18, ConfigV19, + ConfigV20, } from '@opentrons/app/src/redux/config/types' // format // base config v12 defaults @@ -156,6 +157,18 @@ const toVersion19 = (prevConfig: ConfigV18): ConfigV19 => { return nextConfig } +const toVersion20 = (prevConfig: ConfigV19): ConfigV20 => { + return { + ...prevConfig, + version: 20 as const, + robotSystemUpdate: { + manifestUrls: { + OT2: 'not-used-on-ODD', + }, + }, + } +} + const MIGRATIONS: [ (prevConfig: ConfigV12) => ConfigV13, (prevConfig: ConfigV13) => ConfigV14, @@ -163,7 +176,8 @@ const MIGRATIONS: [ (prevConfig: ConfigV15) => ConfigV16, (prevConfig: ConfigV16) => ConfigV17, (prevConfig: ConfigV17) => ConfigV18, - (prevConfig: ConfigV18) => ConfigV19 + (prevConfig: ConfigV18) => ConfigV19, + (prevConfig: ConfigV19) => ConfigV20 ] = [ toVersion13, toVersion14, @@ -172,6 +186,7 @@ const MIGRATIONS: [ toVersion17, toVersion18, toVersion19, + toVersion20, ] export const DEFAULTS: Config = migrate(DEFAULTS_V12) @@ -186,6 +201,7 @@ export function migrate( | ConfigV17 | ConfigV18 | ConfigV19 + | ConfigV20 ): Config { let result = prevConfig // loop through the migrations, skipping any migrations that are unnecessary diff --git a/app-shell-odd/src/system-update/index.ts b/app-shell-odd/src/system-update/index.ts index d7d3aa7660d..5359707b6dd 100644 --- a/app-shell-odd/src/system-update/index.ts +++ b/app-shell-odd/src/system-update/index.ts @@ -29,7 +29,8 @@ let updateSet: ReleaseSetFilepaths | null = null const readFileInfoAndDispatch = ( dispatch: Dispatch, - fileName: string + fileName: string, + isManualFile: boolean = false ): Promise => readUserFileInfo(fileName) .then(fileInfo => ({ @@ -37,6 +38,7 @@ const readFileInfoAndDispatch = ( payload: { systemFile: fileInfo.systemFile, version: fileInfo.versionInfo.opentrons_api_version, + isManualFile, }, })) .catch((error: Error) => ({ @@ -99,7 +101,7 @@ export function registerRobotSystemUpdate(dispatch: Dispatch): Dispatch { case 'robotUpdate:READ_USER_FILE': { const { systemFile } = action.payload as { systemFile: string } // eslint-disable-next-line @typescript-eslint/no-floating-promises - readFileInfoAndDispatch(dispatch, systemFile) + readFileInfoAndDispatch(dispatch, systemFile, true) break } case 'robotUpdate:READ_SYSTEM_FILE': { diff --git a/app-shell/build/release-notes.md b/app-shell/build/release-notes.md index 90e1aa8d85f..611a07c5ed1 100644 --- a/app-shell/build/release-notes.md +++ b/app-shell/build/release-notes.md @@ -6,6 +6,21 @@ log][]. For a list of currently known issues, please see the [Opentrons issue tr --- +## Opentrons App Changes in 7.0.1 + +Welcome to the v7.0.1 release of the Opentrons App! This release builds on the major release that added support for Opentrons Flex. + +### Improved Features + +- Pipettes move higher during Labware Position Check to avoid crashes in all deck slots, not just those with labware loaded in the protocol. + +### Bug Fixes + +- The app no longer blocks running valid protocols due to "not valid JSON" or "apiLevel not declared" errors. +- Robot commands, like turning the lights on or off, no longer take a long time to execute. + +--- + ## Opentrons App Changes in 7.0.0 Welcome to the v7.0.0 release of the Opentrons App! This release adds support for the Opentrons Flex robot, instruments, modules, and labware. diff --git a/app-shell/src/config/__fixtures__/index.ts b/app-shell/src/config/__fixtures__/index.ts index 5225d825c74..848753aa993 100644 --- a/app-shell/src/config/__fixtures__/index.ts +++ b/app-shell/src/config/__fixtures__/index.ts @@ -19,6 +19,7 @@ import type { ConfigV17, ConfigV18, ConfigV19, + ConfigV20, } from '@opentrons/app/src/redux/config/types' export const MOCK_CONFIG_V0: ConfigV0 = { @@ -250,3 +251,14 @@ export const MOCK_CONFIG_V19: ConfigV19 = { hasJustUpdated: false, }, } + +export const MOCK_CONFIG_V20: ConfigV20 = { + ...MOCK_CONFIG_V19, + version: 20, + robotSystemUpdate: { + manifestUrls: { + OT2: + 'https://opentrons-buildroot-ci.s3.us-east-2.amazonaws.com/releases.json', + }, + }, +} diff --git a/app-shell/src/config/__tests__/migrate.test.ts b/app-shell/src/config/__tests__/migrate.test.ts index 0287fbb9e20..38bc6381f40 100644 --- a/app-shell/src/config/__tests__/migrate.test.ts +++ b/app-shell/src/config/__tests__/migrate.test.ts @@ -20,10 +20,11 @@ import { MOCK_CONFIG_V17, MOCK_CONFIG_V18, MOCK_CONFIG_V19, + MOCK_CONFIG_V20, } from '../__fixtures__' import { migrate } from '../migrate' -const NEWEST_VERSION = 19 +const NEWEST_VERSION = 20 describe('config migration', () => { it('should migrate version 0 to latest', () => { @@ -31,7 +32,7 @@ describe('config migration', () => { const result = migrate(v0Config) expect(result.version).toBe(NEWEST_VERSION) - expect(result).toEqual(MOCK_CONFIG_V19) + expect(result).toEqual(MOCK_CONFIG_V20) }) it('should migrate version 1 to latest', () => { @@ -39,7 +40,7 @@ describe('config migration', () => { const result = migrate(v1Config) expect(result.version).toBe(NEWEST_VERSION) - expect(result).toEqual(MOCK_CONFIG_V19) + expect(result).toEqual(MOCK_CONFIG_V20) }) it('should migrate version 2 to latest', () => { @@ -47,7 +48,7 @@ describe('config migration', () => { const result = migrate(v2Config) expect(result.version).toBe(NEWEST_VERSION) - expect(result).toEqual(MOCK_CONFIG_V19) + expect(result).toEqual(MOCK_CONFIG_V20) }) it('should migrate version 3 to latest', () => { @@ -55,7 +56,7 @@ describe('config migration', () => { const result = migrate(v3Config) expect(result.version).toBe(NEWEST_VERSION) - expect(result).toEqual(MOCK_CONFIG_V19) + expect(result).toEqual(MOCK_CONFIG_V20) }) it('should migrate version 4 to latest', () => { @@ -63,7 +64,7 @@ describe('config migration', () => { const result = migrate(v4Config) expect(result.version).toBe(NEWEST_VERSION) - expect(result).toEqual(MOCK_CONFIG_V19) + expect(result).toEqual(MOCK_CONFIG_V20) }) it('should migrate version 5 to latest', () => { @@ -71,7 +72,7 @@ describe('config migration', () => { const result = migrate(v5Config) expect(result.version).toBe(NEWEST_VERSION) - expect(result).toEqual(MOCK_CONFIG_V19) + expect(result).toEqual(MOCK_CONFIG_V20) }) it('should migrate version 6 to latest', () => { @@ -79,7 +80,7 @@ describe('config migration', () => { const result = migrate(v6Config) expect(result.version).toBe(NEWEST_VERSION) - expect(result).toEqual(MOCK_CONFIG_V19) + expect(result).toEqual(MOCK_CONFIG_V20) }) it('should migrate version 7 to latest', () => { @@ -87,7 +88,7 @@ describe('config migration', () => { const result = migrate(v7Config) expect(result.version).toBe(NEWEST_VERSION) - expect(result).toEqual(MOCK_CONFIG_V19) + expect(result).toEqual(MOCK_CONFIG_V20) }) it('should migrate version 8 to latest', () => { @@ -95,7 +96,7 @@ describe('config migration', () => { const result = migrate(v8Config) expect(result.version).toBe(NEWEST_VERSION) - expect(result).toEqual(MOCK_CONFIG_V19) + expect(result).toEqual(MOCK_CONFIG_V20) }) it('should migrate version 9 to latest', () => { @@ -103,7 +104,7 @@ describe('config migration', () => { const result = migrate(v9Config) expect(result.version).toBe(NEWEST_VERSION) - expect(result).toEqual(MOCK_CONFIG_V19) + expect(result).toEqual(MOCK_CONFIG_V20) }) it('should migrate version 10 to latest', () => { @@ -111,7 +112,7 @@ describe('config migration', () => { const result = migrate(v10Config) expect(result.version).toBe(NEWEST_VERSION) - expect(result).toEqual(MOCK_CONFIG_V19) + expect(result).toEqual(MOCK_CONFIG_V20) }) it('should migrate version 11 to latest', () => { @@ -119,7 +120,7 @@ describe('config migration', () => { const result = migrate(v11Config) expect(result.version).toBe(NEWEST_VERSION) - expect(result).toEqual(MOCK_CONFIG_V19) + expect(result).toEqual(MOCK_CONFIG_V20) }) it('should migrate version 12 to latest', () => { @@ -127,7 +128,7 @@ describe('config migration', () => { const result = migrate(v12Config) expect(result.version).toBe(NEWEST_VERSION) - expect(result).toEqual(MOCK_CONFIG_V19) + expect(result).toEqual(MOCK_CONFIG_V20) }) it('should migrate version 13 to latest', () => { @@ -135,7 +136,7 @@ describe('config migration', () => { const result = migrate(v13Config) expect(result.version).toBe(NEWEST_VERSION) - expect(result).toEqual(MOCK_CONFIG_V19) + expect(result).toEqual(MOCK_CONFIG_V20) }) it('should migrate version 14 to latest', () => { @@ -143,7 +144,7 @@ describe('config migration', () => { const result = migrate(v14Config) expect(result.version).toBe(NEWEST_VERSION) - expect(result).toEqual(MOCK_CONFIG_V19) + expect(result).toEqual(MOCK_CONFIG_V20) }) it('should migrate version 15 to latest', () => { @@ -151,7 +152,7 @@ describe('config migration', () => { const result = migrate(v15Config) expect(result.version).toBe(NEWEST_VERSION) - expect(result).toEqual(MOCK_CONFIG_V19) + expect(result).toEqual(MOCK_CONFIG_V20) }) it('should migrate version 16 to latest', () => { @@ -159,7 +160,7 @@ describe('config migration', () => { const result = migrate(v16Config) expect(result.version).toBe(NEWEST_VERSION) - expect(result).toEqual(MOCK_CONFIG_V19) + expect(result).toEqual(MOCK_CONFIG_V20) }) it('should migrate version 17 to latest', () => { @@ -167,20 +168,26 @@ describe('config migration', () => { const result = migrate(v17Config) expect(result.version).toBe(NEWEST_VERSION) - expect(result).toEqual(MOCK_CONFIG_V19) + expect(result).toEqual(MOCK_CONFIG_V20) }) it('should migrate version 18 to latest', () => { const v18Config = MOCK_CONFIG_V18 const result = migrate(v18Config) expect(result.version).toBe(NEWEST_VERSION) - expect(result).toEqual(MOCK_CONFIG_V19) + expect(result).toEqual(MOCK_CONFIG_V20) }) - it('should keep version 19', () => { + it('should keep migrate version 19 to latest', () => { const v19Config = MOCK_CONFIG_V19 const result = migrate(v19Config) expect(result.version).toBe(NEWEST_VERSION) - expect(result).toEqual(v19Config) + expect(result).toEqual(MOCK_CONFIG_V20) + }) + it('should keep version 20', () => { + const v20Config = MOCK_CONFIG_V20 + const result = migrate(v20Config) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(v20Config) }) }) diff --git a/app-shell/src/config/migrate.ts b/app-shell/src/config/migrate.ts index 0a4616e7b14..d13b26ba7a6 100644 --- a/app-shell/src/config/migrate.ts +++ b/app-shell/src/config/migrate.ts @@ -25,6 +25,7 @@ import type { ConfigV17, ConfigV18, ConfigV19, + ConfigV20, } from '@opentrons/app/src/redux/config/types' // format // base config v0 defaults @@ -42,7 +43,9 @@ export const DEFAULTS_V0: ConfigV0 = { }, buildroot: { - manifestUrl: 'not-used', + // do not rely on this value; it is present only for back compatibility + manifestUrl: + 'https://opentrons-buildroot-ci.s3.us-east-2.amazonaws.com/releases.json', }, // logging config @@ -258,7 +261,9 @@ const toVersion12 = (prevConfig: ConfigV11): ConfigV12 => { version: 12 as const, robotSystemUpdate: { manifestUrls: { - OT2: 'not-used', + // do not rely on this value; it is present only for back compatibility + OT2: + 'https://opentrons-buildroot-ci.s3.us-east-2.amazonaws.com/releases.json', OT3: 'not-used', }, }, @@ -354,6 +359,21 @@ const toVersion19 = (prevConfig: ConfigV18): ConfigV19 => { return nextConfig } +const toVersion20 = (prevConfig: ConfigV19): ConfigV20 => { + const nextConfig = { + ...prevConfig, + version: 20 as const, + robotSystemUpdate: { + manifestUrls: { + // do not rely on this value; it is present only for back compatibility + OT2: + 'https://opentrons-buildroot-ci.s3.us-east-2.amazonaws.com/releases.json', + }, + }, + } + return nextConfig +} + const MIGRATIONS: [ (prevConfig: ConfigV0) => ConfigV1, (prevConfig: ConfigV1) => ConfigV2, @@ -373,7 +393,8 @@ const MIGRATIONS: [ (prevConfig: ConfigV15) => ConfigV16, (prevConfig: ConfigV16) => ConfigV17, (prevConfig: ConfigV17) => ConfigV18, - (prevConfig: ConfigV18) => ConfigV19 + (prevConfig: ConfigV18) => ConfigV19, + (prevConfig: ConfigV19) => ConfigV20 ] = [ toVersion1, toVersion2, @@ -394,6 +415,7 @@ const MIGRATIONS: [ toVersion17, toVersion18, toVersion19, + toVersion20, ] export const DEFAULTS: Config = migrate(DEFAULTS_V0) @@ -420,6 +442,7 @@ export function migrate( | ConfigV17 | ConfigV18 | ConfigV19 + | ConfigV20 ): Config { const prevVersion = prevConfig.version let result = prevConfig diff --git a/app-shell/src/http.ts b/app-shell/src/http.ts index dfa4e6fab3c..8efbf9dc2b2 100644 --- a/app-shell/src/http.ts +++ b/app-shell/src/http.ts @@ -1,5 +1,6 @@ // fetch wrapper to throw if response is not ok import fs from 'fs' +import fsPromises from 'fs/promises' import { Transform, Readable } from 'stream' import pump from 'pump' import _fetch from 'node-fetch' @@ -86,31 +87,67 @@ export function postFile( input: RequestInput, name: string, source: string, - init?: RequestInit + init?: RequestInit, + progress?: (progress: number) => void ): Promise { - return createReadStream(source).then(readStream => { + return createReadStream(source, progress ?? null).then(readStream => { const body = new FormData() body.append(name, readStream) return fetch(input, { ...init, body, method: 'POST' }) }) } -// create a read stream, handling errors that `fetch` is unable to catch -function createReadStream(source: string): Promise { +function createReadStreamWithSize( + source: string, + size: number, + progress: ((progress: number) => void) | null +): Promise { return new Promise((resolve, reject) => { const readStream = fs.createReadStream(source) const scheduledResolve = setTimeout(handleSuccess, 0) + let seenDataLength = 0 + let notifiedDataLength = 0 + + const onData = (chunk: Buffer): void => { + seenDataLength += chunk.length + if ( + size !== Infinity && + seenDataLength / size > notifiedDataLength / size + 0.01 + ) { + progress?.(seenDataLength / size) + notifiedDataLength = seenDataLength + } + + if (seenDataLength === size) { + readStream.removeListener('data', onData) + readStream.removeListener('error', handleError) + } + } readStream.once('error', handleError) + readStream.on('data', onData) function handleSuccess(): void { - readStream.removeListener('error', handleError) resolve(readStream) } function handleError(error: Error): void { clearTimeout(scheduledResolve) + readStream.removeListener('data', onData) reject(error) } }) } + +// create a read stream, handling errors that `fetch` is unable to catch +function createReadStream( + source: string, + progress: ((progress: number) => void) | null +): Promise { + return fsPromises + .stat(source) + .then(filestats => + createReadStreamWithSize(source, filestats.size, progress) + ) + .catch(() => createReadStreamWithSize(source, Infinity, progress)) +} diff --git a/app-shell/src/protocol-storage/file-system.ts b/app-shell/src/protocol-storage/file-system.ts index cf3c36f3c5f..0dbbf290860 100644 --- a/app-shell/src/protocol-storage/file-system.ts +++ b/app-shell/src/protocol-storage/file-system.ts @@ -24,13 +24,11 @@ import { analyzeProtocolSource } from '../protocol-analysis' * │ ├─ analysis/ * │ │ ├─ 1646303906.json */ -// TODO(jh, 2023-09-11): remove OLD_PROTOCOLS_DIRECTORY_PATH after -// OT-2 parity work is completed and move all protocols back to "protocols" directory. -export const OLD_PROTOCOLS_DIRECTORY_PATH = path.join( +export const PRE_V7_PARITY_DIRECTORY_PATH = path.join( app.getPath('userData'), - 'protocols' + 'protocols_v7.0-supported' ) -export const PROTOCOLS_DIRECTORY_NAME = 'protocols_v7.0-supported' +export const PROTOCOLS_DIRECTORY_NAME = 'protocols' export const PROTOCOLS_DIRECTORY_PATH = path.join( app.getPath('userData'), PROTOCOLS_DIRECTORY_NAME diff --git a/app-shell/src/protocol-storage/index.ts b/app-shell/src/protocol-storage/index.ts index a195ff2900f..0ffcf9795c6 100644 --- a/app-shell/src/protocol-storage/index.ts +++ b/app-shell/src/protocol-storage/index.ts @@ -42,25 +42,35 @@ export const getProtocolSrcFilePaths = ( }) } -// TODO(jh, 2023-09-11): remove migrateProtocolsToNewDirectory after -// OT-2 parity work is completed. -const migrateProtocols = migrateProtocolsToNewDirectory() -function migrateProtocolsToNewDirectory(): () => Promise { +// Revert a v7.0.0 pre-parity stop-gap solution. +const migrateProtocolsFromTempDirectory = preParityMigrateProtocolsFrom( + FileSystem.PRE_V7_PARITY_DIRECTORY_PATH, + FileSystem.PROTOCOLS_DIRECTORY_PATH +) +export function preParityMigrateProtocolsFrom( + src: string, + dest: string +): () => Promise { let hasCheckedForMigration = false + return function (): Promise { return new Promise((resolve, reject) => { if (hasCheckedForMigration) resolve() hasCheckedForMigration = true - console.log( - `Performing protocol migration to ${FileSystem.PROTOCOLS_DIRECTORY_NAME}...` - ) - copyProtocols( - FileSystem.OLD_PROTOCOLS_DIRECTORY_PATH, - FileSystem.PROTOCOLS_DIRECTORY_PATH - ) - .then(() => { - console.log('Protocol migration complete.') - resolve() + + fse + .stat(src) + .then(doesSrcExist => { + if (!doesSrcExist.isDirectory()) resolve() + + console.log( + `Performing protocol migration to ${FileSystem.PROTOCOLS_DIRECTORY_NAME}...` + ) + + return migrateProtocols(src, dest).then(() => { + console.log('Protocol migration complete.') + resolve() + }) }) .catch(e => { console.log( @@ -71,27 +81,27 @@ function migrateProtocolsToNewDirectory(): () => Promise { }) } - function copyProtocols(src: string, dest: string): Promise { + function migrateProtocols(src: string, dest: string): Promise { return fse - .stat(src) - .then(doesSrcExist => { - if (!doesSrcExist.isDirectory()) return Promise.resolve() - - return fse.readdir(src).then(items => { - const protocols = items.map(item => { - const srcItem = path.join(src, item) - const destItem = path.join(dest, item) - - return fse.copy(srcItem, destItem, { - overwrite: false, - }) + .readdir(src) + .then(items => { + const protocols = items.map(item => { + const srcItem = path.join(src, item) + const destItem = path.join(dest, item) + + return fse.copy(srcItem, destItem, { + overwrite: false, }) - return Promise.all(protocols).then(() => Promise.resolve()) }) + // Delete the tmp directory. + return Promise.all(protocols).then(() => + fse.rm(src, { + recursive: true, + force: true, + }) + ) }) - .catch(e => { - return Promise.reject(e) - }) + .catch(e => Promise.reject(e)) } } @@ -100,7 +110,7 @@ export const fetchProtocols = ( source: ListSource ): Promise => { return ensureDir(FileSystem.PROTOCOLS_DIRECTORY_PATH) - .then(() => migrateProtocols()) + .then(() => migrateProtocolsFromTempDirectory()) .then(() => FileSystem.readDirectoriesWithinDirectory( FileSystem.PROTOCOLS_DIRECTORY_PATH @@ -201,22 +211,14 @@ export function registerProtocolStorage(dispatch: Dispatch): Dispatch { }) break } - // TODO(jh, 2023-09-15): remove the secondary removeProtocolByKey() after - // OT-2 parity work is completed. + case ProtocolStorageActions.REMOVE_PROTOCOL: { FileSystem.removeProtocolByKey( action.payload.protocolKey, FileSystem.PROTOCOLS_DIRECTORY_PATH + ).then(() => + fetchProtocols(dispatch, ProtocolStorageActions.PROTOCOL_ADDITION) ) - .then(() => - fetchProtocols(dispatch, ProtocolStorageActions.PROTOCOL_ADDITION) - ) - .then(() => - FileSystem.removeProtocolByKey( - action.payload.protocolKey, - FileSystem.OLD_PROTOCOLS_DIRECTORY_PATH - ) - ) break } diff --git a/app-shell/src/robot-update/index.ts b/app-shell/src/robot-update/index.ts index cc222fa1ef0..b71eab6d44b 100644 --- a/app-shell/src/robot-update/index.ts +++ b/app-shell/src/robot-update/index.ts @@ -41,7 +41,8 @@ const updateSet: Record = { const readFileAndDispatchInfo = ( dispatch: Dispatch, - filename: string + filename: string, + isManualFile: boolean = false ): Promise => readUpdateFileInfo(filename) .then(fileInfo => ({ @@ -49,6 +50,7 @@ const readFileAndDispatchInfo = ( payload: { systemFile: fileInfo.systemFile, version: fileInfo.versionInfo.opentrons_api_version, + isManualFile, }, })) .catch((error: Error) => ({ @@ -104,7 +106,12 @@ export function registerRobotUpdate(dispatch: Dispatch): Dispatch { } // eslint-disable-next-line @typescript-eslint/no-floating-promises - uploadSystemFile(host, path, systemFile) + uploadSystemFile(host, path, systemFile, progress => + dispatch({ + type: 'robotUpdate:FILE_UPLOAD_PROGRESS', + payload: progress, + }) + ) .then(() => ({ type: 'robotUpdate:FILE_UPLOAD_DONE' as const, payload: host.name, @@ -130,7 +137,7 @@ export function registerRobotUpdate(dispatch: Dispatch): Dispatch { case 'robotUpdate:READ_USER_FILE': { const { systemFile } = action.payload as { systemFile: string } - readFileAndDispatchInfo(dispatch, systemFile) + readFileAndDispatchInfo(dispatch, systemFile, true) break } diff --git a/app-shell/src/robot-update/update.ts b/app-shell/src/robot-update/update.ts index dccce045f09..f3b0eca15df 100644 --- a/app-shell/src/robot-update/update.ts +++ b/app-shell/src/robot-update/update.ts @@ -62,7 +62,8 @@ export function startPremigration(robot: RobotHost): Promise { export function uploadSystemFile( robot: ViewableRobot, urlPath: string, - file: string + file: string, + progressCallback: (progress: number) => void ): Promise { const isUsbUpload = robot.ip === OPENTRONS_USB @@ -77,6 +78,7 @@ export function uploadSystemFile( ? { agent: serialPortHttpAgent, } - : {} + : {}, + progressCallback ) } diff --git a/app/package.json b/app/package.json index d2f8dd09ff1..e67b872ccfe 100644 --- a/app/package.json +++ b/app/package.json @@ -18,6 +18,7 @@ }, "homepage": "https://github.com/Opentrons/opentrons", "dependencies": { + "@ebay/nice-modal-react": "1.2.13", "@fontsource/dejavu-sans": "5.0.3", "@fontsource/public-sans": "5.0.3", "@hot-loader/react-dom": "17.0.1", diff --git a/app/src/App/OnDeviceDisplayApp.tsx b/app/src/App/OnDeviceDisplayApp.tsx index f0b50f0782c..87b0d00d86f 100644 --- a/app/src/App/OnDeviceDisplayApp.tsx +++ b/app/src/App/OnDeviceDisplayApp.tsx @@ -48,9 +48,15 @@ import { useCurrentRunRoute, useProtocolReceiptToast } from './hooks' import { OnDeviceDisplayAppFallback } from './OnDeviceDisplayAppFallback' +import { hackWindowNavigatorOnLine } from './hacks' + import type { Dispatch } from '../redux/types' import type { RouteProps } from './types' +// forces electron to think we're online which means axios won't elide +// network calls to localhost. see ./hacks.ts for more. +hackWindowNavigatorOnLine() + export const onDeviceDisplayRoutes: RouteProps[] = [ { Component: InitialLoadingScreen, @@ -264,7 +270,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 ( - + {isIdle ? ( diff --git a/app/src/App/__mocks__/hacks.ts b/app/src/App/__mocks__/hacks.ts new file mode 100644 index 00000000000..842209dfcba --- /dev/null +++ b/app/src/App/__mocks__/hacks.ts @@ -0,0 +1 @@ +export const hackWindowNavigatorOnLine = (): void => {} diff --git a/app/src/App/hacks.ts b/app/src/App/hacks.ts new file mode 100644 index 00000000000..696e7baec9a --- /dev/null +++ b/app/src/App/hacks.ts @@ -0,0 +1,16 @@ +// If the system boots while no network connection is available, then some requests to localhost +// hang eternally while connecting (so no request timeouts work either) and things get +// generally weird. Overriding the browser API to pretend to always be "online" fixes this. +// It makes sense; if "onLine" is false, that means that any network call is _guaranteed_ to fail +// so middlewares probably elide them; but we really want it to be true basically always because +// most of what we do is via localhost. +// +// This function is exposed in its own module so it can be mocked in testing +// since jest really doesn't like you doing this. + +export const hackWindowNavigatorOnLine = (): void => { + Object.defineProperty(window.navigator, 'onLine', { + get: () => true, + }) + window.dispatchEvent(new Event('online')) +} diff --git a/app/src/App/hooks.ts b/app/src/App/hooks.ts index 429f5a14283..77e52119ca2 100644 --- a/app/src/App/hooks.ts +++ b/app/src/App/hooks.ts @@ -5,7 +5,7 @@ import { useQueryClient } from 'react-query' import { useRouteMatch } from 'react-router-dom' import { useDispatch } from 'react-redux' -import { useInterval } from '@opentrons/components' +import { useInterval, truncateString } from '@opentrons/components' import { useAllProtocolIdsQuery, useAllRunsQuery, @@ -91,12 +91,13 @@ export function useProtocolReceiptToast(): void { protocolNames.forEach(name => { makeToast( t('protocol_added', { - protocol_name: name, + protocol_name: truncateString(name, 30), }), 'success', { closeButton: true, disableTimeout: true, + displayType: 'odd', } ) }) diff --git a/app/src/App/index.tsx b/app/src/App/index.tsx index 752308ca0ff..671660e0a29 100644 --- a/app/src/App/index.tsx +++ b/app/src/App/index.tsx @@ -1,6 +1,7 @@ import * as React from 'react' import { useSelector } from 'react-redux' import { hot } from 'react-hot-loader/root' +import NiceModal from '@ebay/nice-modal-react' import { Flex, POSITION_FIXED, DIRECTION_ROW } from '@opentrons/components' @@ -29,7 +30,9 @@ export const AppComponent = (): JSX.Element | null => { onDrop={stopEvent} > - {isOnDevice ? : } + + {isOnDevice ? : } + ) : null diff --git a/app/src/assets/localization/en/app_settings.json b/app/src/assets/localization/en/app_settings.json index 71ff14f1345..9629ae37879 100644 --- a/app/src/assets/localization/en/app_settings.json +++ b/app/src/assets/localization/en/app_settings.json @@ -58,8 +58,8 @@ "opentrons_app_update_available_variation": "An Opentrons App update is available.", "opentrons_app_will_use_interpreter": "If specified, the Opentrons App will use the Python interpreter at this path instead of the default bundled Python interpreter.", "opentrons_cares_about_privacy": "Opentrons cares about your privacy. We anonymize all data and only use it to improve our products.", - "opt_in_description": "Automatically send us anonymous diagnostics and usage data. We only use this information to improve our products.", "opt_in": "Opt in", + "opt_in_description": "Automatically send us anonymous diagnostics and usage data. We only use this information to improve our products.", "opt_out": "Opt out", "ot2_advanced_settings": "OT-2 Advanced Settings", "override_path": "override path", @@ -68,6 +68,7 @@ "prevent_robot_caching_description": "The app will immediately clear unavailable robots and will not remember unavailable robots while this is enabled. On networks with many robots, preventing caching may improve network performance at the expense of slower and less reliable robot discovery on app launch.", "previous_releases": "View previous Opentrons releases", "privacy": "Privacy", + "problem_during_update": "This update is taking longer than usual.", "prompt": "Always show the prompt to choose calibration block or trash bin", "receive_alert": "Receive an alert when an Opentrons software update is available.", "remind_later": "Remind me later", @@ -80,10 +81,10 @@ "setup_connection": "Set up connection", "share_app_analytics": "Share App Analytics with Opentrons", "share_app_analytics_description": "Help Opentrons improve its products and services by automatically sending anonymous diagnostics and usage data.", - "share_display_usage_description": "Data on how you interact with the touchscreen on Flex.", "share_display_usage": "Share display usage", - "share_robot_logs_description": "Data on actions the robot does, like running protocols.", + "share_display_usage_description": "Data on how you interact with the touchscreen on Flex.", "share_robot_logs": "Share robot logs", + "share_robot_logs_description": "Data on actions the robot does, like running protocols.", "show_labware_offset_snippets": "Show Labware Offset data code snippets", "show_labware_offset_snippets_description": "Only for users who need to apply Labware Offset data outside of the Opentrons App. When enabled, code snippets for Jupyter Notebook and SSH are available during protocol setup.", "software_update_available": "Software Update Available", @@ -91,6 +92,7 @@ "successfully_deleted_unavail_robots": "Successfully deleted unavailable robots", "tip_length_cal_method": "Tip Length Calibration Method", "trash_bin": "Always use trash bin to calibrate", + "try_restarting_the_update": "Try restarting the update.", "turn_off_updates": "Turn off software update notifications in App Settings.", "up_to_date": "Up to date", "update_alerts": "Software Update Alerts", diff --git a/app/src/assets/localization/en/device_settings.json b/app/src/assets/localization/en/device_settings.json index 554f1c2aadc..64738982dbe 100644 --- a/app/src/assets/localization/en/device_settings.json +++ b/app/src/assets/localization/en/device_settings.json @@ -1,12 +1,12 @@ { "about_advanced": "About", - "about_calibration_description_ot3": "For the robot to move accurately and precisely, you need to calibrate it. Pipette and gripper calibration is an automated process that uses a calibration probe or pin.After calibration is complete, you can save the calibration data to your computer as a JSON file.", "about_calibration_description": "For the robot to move accurately and precisely, you need to calibrate it. Positional calibration happens in three parts: deck calibration, pipette offset calibration and tip length calibration.", + "about_calibration_description_ot3": "For the robot to move accurately and precisely, you need to calibrate it. Pipette and gripper calibration is an automated process that uses a calibration probe or pin.After calibration is complete, you can save the calibration data to your computer as a JSON file.", "about_calibration_title": "About Calibration", "advanced": "Advanced", "alpha_description": "Warning: alpha releases are feature-complete but may contain significant bugs.", - "alternative_security_types_description": "The Opentrons App supports connecting Flex to various enterprise access points. Connect via USB and finish setup in the app.", "alternative_security_types": "Alternative security types", + "alternative_security_types_description": "The Opentrons App supports connecting Flex to various enterprise access points. Connect via USB and finish setup in the app.", "app_change_in": "App Changes in {{version}}", "apply_historic_offsets": "Apply Labware Offsets", "are_you_sure_you_want_to_disconnect": "Are you sure you want to disconnect from {{ssid}}?", @@ -14,54 +14,54 @@ "boot_scripts": "Boot scripts", "browse_file_system": "Browse file system", "bug_fixes": "Bug Fixes", + "calibrate_deck": "Calibrate deck", "calibrate_deck_description": "For pre-2019 robots that do not have crosses etched on the deck.", "calibrate_deck_to_dots": "Calibrate deck to dots", - "calibrate_deck": "Calibrate deck", "calibrate_gripper": "Calibrate gripper", "calibrate_module": "Calibrate module", "calibrate_now": "Calibrate now", "calibrate_pipette": "Calibrate Pipette Offset", + "calibration": "Calibration", "calibration_health_check_description": "Check the accuracy of key calibration points without recalibrating the robot.", "calibration_health_check_title": "Calibration Health Check", - "calibration": "Calibration", "change_network": "Change network", "characters_max": "17 characters max", "check_for_updates": "Check for updates", "checking_for_updates": "Checking for updates", + "choose": "Choose...", "choose_network_type": "Choose network type", "choose_reset_settings": "Choose reset settings", - "choose": "Choose...", "clear_all_data": "Clear all data", - "clear_all_stored_data_description": "Resets all settings. You’ll have to redo initial setup before using the robot again.", "clear_all_stored_data": "Clear all stored data", + "clear_all_stored_data_description": "Resets all settings. You’ll have to redo initial setup before using the robot again.", "clear_calibration_data": "Clear calibration data", "clear_data_and_restart_robot": "Clear data and restart robot", "clear_individual_data": "Clear individual data", "clear_option_authorized_keys": "Clear SSH public keys", - "clear_option_boot_scripts_description": "Clears scripts that modify the robot's behavior when powered on.", "clear_option_boot_scripts": "Clear custom boot scripts", + "clear_option_boot_scripts_description": "Clears scripts that modify the robot's behavior when powered on.", "clear_option_deck_calibration": "Clear deck calibration", "clear_option_gripper_calibration": "Clear gripper calibration", "clear_option_gripper_offset_calibrations": "Clear gripper calibration", "clear_option_module_calibration": "Clear module calibration", "clear_option_pipette_calibrations": "Clear pipette calibration", "clear_option_pipette_offset_calibrations": "Clear pipette offset calibrations", - "clear_option_runs_history_subtext": "Clears information about past runs of all protocols.", "clear_option_runs_history": "Clear protocol run history", + "clear_option_runs_history_subtext": "Clears information about past runs of all protocols.", "clear_option_tip_length_calibrations": "Clear tip length calibrations", "confirm_device_reset_description": "This will permanently delete all protocol, calibration, and other data. You’ll have to redo initial setup before using the robot again.", "confirm_device_reset_heading": "Are you sure you want to reset your device?", + "connect": "Connect", "connect_the_estop_to_continue": "Connect the E-stop to continue", "connect_to_wifi_network": "Connect to Wi-Fi network", + "connect_via": "Connect via {{type}}", "connect_via_usb_description_1": "1. Connect the USB A-to-B cable to the robot’s USB-B port.", "connect_via_usb_description_2": "2. Connect the cable to an open USB port on your computer.", "connect_via_usb_description_3": "3. Launch the Opentrons App on the computer to continue.", - "connect_via": "Connect via {{type}}", - "connect": "Connect", + "connected": "Connected", "connected_network": "Connected Network", "connected_to_ssid": "Connected to {{ssid}}", "connected_via": "Connected via {{networkInterface}}", - "connected": "Connected", "connecting_to": "Connecting to {{ssid}}...", "connection_description_ethernet": "Connect to your lab's wired network.", "connection_description_usb": "Connect directly to a computer (running the Opentrons App).", @@ -69,60 +69,60 @@ "connection_lost_description": "The Opentrons App is unable to communicate with this robot right now. Double check the USB or Wifi connection to the robot, then try to reconnect.", "connection_to_robot_lost": "Connection to robot lost", "deck_calibration_description": "Calibrating the deck is required for new robots or after you relocate your robot. Recalibrating the deck will require you to also recalibrate pipette offsets.", - "deck_calibration_missing_no_pipette": "Deck calibration missing. Attach a pipette to perform deck calibration.", "deck_calibration_missing": "Deck calibration missing", + "deck_calibration_missing_no_pipette": "Deck calibration missing. Attach a pipette to perform deck calibration.", "deck_calibration_modal_description": "Calibrating pipette offset before deck calibration when both are needed isn’t suggested. Calibrating the deck clears all other calibration data. ", "deck_calibration_modal_pipette_description": "Would you like to continue with pipette offset calibration?", "deck_calibration_modal_title": "Are you sure you want to calibrate?", "deck_calibration_recommended": "Deck calibration recommended", "deck_calibration_title": "Deck Calibration", "dev_tools_description": "Access additional logging and feature flags.", + "device_reset": "Device Reset", "device_reset_description": "Reset labware calibration, boot scripts, and/or robot calibration to factory settings.", "device_reset_slideout_description": "Select individual settings to only clear specific data types.", - "device_reset": "Device Reset", "device_resets_cannot_be_undone": "Resets cannot be undone", "directly_connected_to_this_computer": "Directly connected to this computer.", + "disconnect": "Disconnect", "disconnect_from_ssid": "Disconnect from {{ssid}}", + "disconnect_from_wifi": "Disconnect from Wi-Fi", "disconnect_from_wifi_network_failure": "Your robot was unable to disconnect from Wi-Fi network {{ssid}}.", "disconnect_from_wifi_network_success": "Your robot has successfully disconnected from the Wi-Fi network.", - "disconnect_from_wifi": "Disconnect from Wi-Fi", - "disconnect": "Disconnect", "disconnected_from_wifi": "Disconnected from Wi-Fi", "disconnecting_from_wifi_network": "Disconnecting from Wi-Fi network {{ssid}}", "disengaged": "Disengaged", "display_brightness": "Display Brightness", - "display_led_lights_description": "Control the strip of color lights on the front of the robot.", "display_led_lights": "Status LEDs", + "display_led_lights_description": "Control the strip of color lights on the front of the robot.", "display_sleep_settings": "Display Sleep Settings", - "do_not_turn_off": "Do not turn off the robot while updating", + "do_not_turn_off": "This could take up to 15 minutes. Don't turn off the robot.", "done": "Done", + "download": "Download", "download_calibration_data": "Download calibration logs", "download_error": "Download error", "download_logs": "Download logs", - "download": "Download", "downloading_logs": "Downloading logs...", "downloading_software": "Downloading software...", "downloading_update": "Downloading update...", "e_stop_connected": "E-stop successfully connected", "e_stop_not_connected": "Connect the E-stop to an auxiliary port on the back of the robot.", - "enable_status_light_description": "Turn on or off the strip of color LEDs on the front of the robot.", "enable_status_light": "Enable status light", + "enable_status_light_description": "Turn on or off the strip of color LEDs on the front of the robot.", "engaged": "Engaged", "enter_network_name": "Enter network name", "enter_password": "Enter password", + "estop": "E-stop", "estop_disengaged": "E-stop Disengaged", "estop_engaged": "E-stop Engaged", - "estop_missing_description": "Your E-stop could be damaged or detached. {{robotName}} lost its connection to the E-stop, so it canceled the protocol. Connect a functioning E-stop to continue.", "estop_missing": "E-stop missing", - "estop_pressed_description": "First, safely clear the deck of any labware or spills. Then, twist the E-stop button clockwise. Finally, have Flex move the gantry to its home position.", + "estop_missing_description": "Your E-stop could be damaged or detached. {{robotName}} lost its connection to the E-stop, so it canceled the protocol. Connect a functioning E-stop to continue.", "estop_pressed": "E-stop pressed", - "estop": "E-stop", - "ethernet_connection_description": "Connect an Ethernet cable to the back of the robot and a network switch or hub.", + "estop_pressed_description": "First, safely clear the deck of any labware or spills. Then, twist the E-stop button clockwise. Finally, have Flex move the gantry to its home position.", "ethernet": "Ethernet", + "ethernet_connection_description": "Connect an Ethernet cable to the back of the robot and a network switch or hub.", "exit": "exit", + "factory_reset": "Factory Reset", "factory_reset_description": "Resets all settings. You’ll have to redo initial setup before using the robot again.", "factory_reset_modal_description": "This data cannot be retrieved later.", - "factory_reset": "Factory Reset", "factory_resets_cannot_be_undone": "Factory resets cannot be undone.", "failed_to_connect_to_ssid": "Failed to connect to {{ssid}}", "feature_flags": "Feature Flags", @@ -130,8 +130,8 @@ "finish_setup": "Finish setup", "firmware_version": "Firmware Version", "fully_calibrate_before_checking_health": "Fully calibrate your robot before checking calibration health", - "gantry_homing_description": "Homes the gantry along the z-axis.", "gantry_homing": "Home Gantry on Restart", + "gantry_homing_description": "Homes the gantry along the z-axis.", "go_to_advanced_settings": "Go to Advanced App Settings", "gripper_calibration_description": "Gripper calibration uses a metal pin to determine the gripper's exact position relative to precision-cut squares on deck slots.", "gripper_calibration_title": "Gripper Calibration", @@ -144,30 +144,30 @@ "installing_software": "Installing software...", "installing_update": "Installing update...", "ip_address": "IP Address", - "join_other_network_error_message": "Must be 2–32 characters long", "join_other_network": "Join other network", + "join_other_network_error_message": "Must be 2–32 characters long", + "jupyter_notebook": "Jupyter Notebook", "jupyter_notebook_description": "Open the Jupyter Notebook running on this robot in the web browser. This is an experimental feature.", "jupyter_notebook_link": "Learn more about using Jupyter notebook", - "jupyter_notebook": "Jupyter Notebook", - "last_calibrated_label": "Last Calibrated", "last_calibrated": "Last calibrated: {{date}}", + "last_calibrated_label": "Last Calibrated", "launch_jupyter_notebook": "Launch Jupyter Notebook", "legacy_settings": "Legacy Settings", "mac_address": "MAC Address", "minutes": "{{minute}} minutes", "missing_calibration": "Missing calibration", "model_and_serial": "Pipette Model and Serial", - "module_calibration_description": "Module calibration uses a pipette and attached probe to determine the module's exact position relative to the deck.", - "module_calibration": "Module Calibration", "module": "Module", + "module_calibration": "Module Calibration", + "module_calibration_description": "Module calibration uses a pipette and attached probe to determine the module's exact position relative to the deck.", "mount": "Mount", "name_love_it": "{{name}}, love it!", "name_rule_description": "Enter up to 17 characters (letters and numbers only)", "name_rule_error_exist": "Oops! Name is already in use. Choose a different name.", "name_rule_error_name_length": "Oops! Robot name must follow the character count and limitations.", "name_rule_error_too_short": "Oops! Too short. Robot name must be at least 1 character.", - "name_your_robot_description": "Don’t worry, you can always change this in your settings.", "name_your_robot": "Name your robot", + "name_your_robot_description": "Don’t worry, you can always change this in your settings.", "need_another_security_type": "Need another security type?", "network_name": "Network Name", "network_settings": "Network Settings", @@ -182,28 +182,29 @@ "no_network_found": "No network found", "no_pipette_attached": "No pipette attached", "none_description": "Not recommended", - "not_calibrated_short": "Not calibrated", "not_calibrated": "Not calibrated yet", + "not_calibrated_short": "Not calibrated", + "not_connected": "Not connected", "not_connected_via_ethernet": "Not connected via Ethernet", "not_connected_via_usb": "Not connected via USB", "not_connected_via_wifi": "Not connected via Wi-Fi", "not_connected_via_wired_usb": "Not connected via wired USB", - "not_connected": "Not connected", "not_now": "Not now", "one_hour": "1 hour", "other_networks": "Other Networks", - "password_error_message": "Must be at least 8 characters", "password": "Password", - "pause_protocol_description": "When enabled, opening the robot door during a run will pause the robot after it has completed its current motion.", + "password_error_message": "Must be at least 8 characters", "pause_protocol": "Pause protocol when robot door opens", + "pause_protocol_description": "When enabled, opening the robot door during a run will pause the robot after it has completed its current motion.", "pipette_calibrations_description": "Pipette calibration uses a metal probe to determine the pipette's exact position relative to precision-cut squares on deck slots.", "pipette_calibrations_title": "Pipette Calibrations", + "pipette_offset_calibration": "pipette offset calibration", "pipette_offset_calibration_missing": "Pipette Offset calibration missing", "pipette_offset_calibration_recommended": "Pipette Offset calibration recommended", - "pipette_offset_calibration": "pipette offset calibration", "pipette_offset_calibrations_history": "See all Pipette Offset Calibration history", "pipette_offset_calibrations_title": "Pipette Offset Calibrations", "privacy": "Privacy", + "problem_during_update": "This update is taking longer than usual.", "proceed_without_updating": "Proceed without update", "protocol_run_history": "Protocol run History", "recalibrate_deck": "Recalibrate deck", @@ -215,16 +216,17 @@ "recalibration_recommended": "Recalibration recommended", "reinstall": "reinstall", "remind_me_later": "Remind me later", + "rename_robot": "Rename robot", "rename_robot_input_error": "Oops! Robot name must follow the character count and limitations.", "rename_robot_input_limitation_detail": "Please enter 17 characters max using valid inputs: letters and numbers.", "rename_robot_prefer_usb_connection": "To ensure reliable renaming of your robot, please connect to it via USB.", "rename_robot_title": "Rename Robot", - "rename_robot": "Rename robot", "requires_restarting_the_robot": "Updating the robot’s software requires restarting the robot", "reset_to_factory_settings": "Reset to factory settings?", "resets_cannot_be_undone": "Resets cannot be undone", "restart_now": "Restart now?", "restart_robot_confirmation_description": "It will take a few minutes for {{robotName}} to restart.", + "restart_taking_too_long": "{{robotName}} is taking longer than expected to restart. Check the Advanced tab of its settings page to see whether it updated successfully. If the robot is unresponsive, restart it manually.", "restarting_robot": "Install complete, robot restarting...", "resume_robot_operations": "Resume robot operations", "returns_your_device_to_new_state": "This returns your device to a new state.", @@ -233,21 +235,21 @@ "robot_name": "Robot Name", "robot_operating_update_available": "Robot Operating System Update Available", "robot_serial_number": "Robot Serial Number", - "robot_server_version_ot3_description": "The Opentrons Flex software includes the robot server and the touchscreen display interface.", "robot_server_version": "Robot Server Version", - "robot_settings_advanced_unknown": "Unknown", + "robot_server_version_ot3_description": "The Opentrons Flex software includes the robot server and the touchscreen display interface.", "robot_settings": "Robot Settings", + "robot_settings_advanced_unknown": "Unknown", "robot_software_update_required": "A robot software update is required to run protocols with this version of the Opentrons App.", "robot_successfully_connected": "Robot successfully connected to {{networkName}}.", - "robot_system_version_available": "Robot System Version {{releaseVersion}} available", "robot_system_version": "Robot System Version", - "robot_up_to_date_description": "It looks like your robot is already up to date, but if you're experiencing issues you can re-apply the latest update.", + "robot_system_version_available": "Robot System Version {{releaseVersion}} available", "robot_up_to_date": "Robot is up to date", + "robot_up_to_date_description": "It looks like your robot is already up to date, but if you're experiencing issues you can re-apply the latest update.", "robot_update_available": "Robot Update Available", "robot_update_success": "Robot software successfully updated", "search_again": "Search again", - "searching_for_networks": "Searching for networks...", "searching": "Searching", + "searching_for_networks": "Searching for networks...", "security_type": "Security Type", "select_a_network": "Select a network", "select_a_security_type": "Select a security type", @@ -255,26 +257,26 @@ "select_authentication_method": "Select authentication method for your selected network.", "sending_software": "Sending software...", "serial": "Serial", - "share_logs_with_opentrons_description_short": "Share anonymous robot logs with Opentrons.", + "share_logs_with_opentrons": "Share Robot logs with Opentrons", "share_logs_with_opentrons_description": "Help Opentrons improve its products and services by automatically sending anonymous robot logs. Opentrons uses these logs to troubleshoot robot issues and spot error trends.", + "share_logs_with_opentrons_description_short": "Share anonymous robot logs with Opentrons.", "share_logs_with_opentrons_short": "Share Robot logs", - "share_logs_with_opentrons": "Share Robot logs with Opentrons", - "short_trash_bin_description": "For pre-2019 robots with trash bins that are 55mm tall (instead of 77mm default)", "short_trash_bin": "Short trash bin", - "show_password": "Show Password", + "short_trash_bin_description": "For pre-2019 robots with trash bins that are 55mm tall (instead of 77mm default)", "show": "Show", + "show_password": "Show Password", "sign_into_wifi": "Sign into Wi-Fi", "software_is_up_to_date": "Your software is already up to date!", "software_update_error": "Software update error", "some_robot_controls_are_not_available": "Some robot controls are not available when run is in progress", "ssh_public_keys": "SSH public keys", "subnet_mask": "Subnet Mask", - "successfully_connected_to_network": "Successfully connected to {{ssid}}!", "successfully_connected": "Successfully connected!", + "successfully_connected_to_network": "Successfully connected to {{ssid}}!", "supported_protocol_api_versions": "Supported Protocol API Versions", "switch_to_usb_description": "If your network uses a different authentication method, connect to the Opentrons App and finish Wi-Fi setup there.", - "text_size_description": "Text on all screens will adjust to the size you choose below.", "text_size": "Text Size", + "text_size_description": "Text on all screens will adjust to the size you choose below.", "tip_length_calibrations_history": "See all Tip Length Calibration history", "tip_length_calibrations_title": "Tip Length Calibrations", "tiprack": "Tip Rack", @@ -282,24 +284,25 @@ "touchscreen_sleep": "Touchscreen Sleep", "troubleshooting": "Troubleshooting", "try_again": "Try again", + "try_restarting_the_update": "Try restarting the update.", "up_to_date": "up to date", "update_available": "Update Available", "update_channel_description": "Stable receives the latest stable releases. Beta allows you to try out new in-progress features before they launch in Stable channel, but they have not completed testing yet.", "update_complete": "Update complete!", "update_found": "Update found!", "update_robot_now": "Update robot now", + "update_robot_software": "Update robot software manually with a local file (.zip)", "update_robot_software_description": "Bypass the Opentrons App auto-update process and update the robot software manually.", "update_robot_software_link": "Launch Opentrons software update page", - "update_robot_software": "Update robot software manually with a local file (.zip)", - "updating_robot_system": "Updating the robot software requires restarting the robot", "updating": "Updating", + "updating_robot_system": "Updating the robot software requires restarting the robot", "usage_settings": "Usage Settings", - "usb_to_ethernet_description": "Looking for USB-to-Ethernet Adapter info?", "usb": "USB", - "use_older_aspirate_description": "Aspirate with the less accurate volumetric calibrations that were used before version 3.7.0. Use this if you need consistency with pre-v3.7.0 results. This only affects GEN1 P10S, P10M, P50M, and P300S pipettes.", + "usb_to_ethernet_description": "Looking for USB-to-Ethernet Adapter info?", "use_older_aspirate": "Use older aspirate behavior", - "use_older_protocol_analysis_method_description": "Use an older, slower method of analyzing uploaded protocols. This changes how the OT-2 validates your protocol during the upload step, but does not affect how your protocol actually runs. Opentrons Support might ask you to change this setting if you encounter problems with the newer, faster protocol analysis method.", + "use_older_aspirate_description": "Aspirate with the less accurate volumetric calibrations that were used before version 3.7.0. Use this if you need consistency with pre-v3.7.0 results. This only affects GEN1 P10S, P10M, P50M, and P300S pipettes.", "use_older_protocol_analysis_method": "Use older protocol analysis method", + "use_older_protocol_analysis_method_description": "Use an older, slower method of analyzing uploaded protocols. This changes how the OT-2 validates your protocol during the upload step, but does not affect how your protocol actually runs. Opentrons Support might ask you to change this setting if you encounter problems with the newer, faster protocol analysis method.", "validating_software": "Validating software...", "view_details": "View details", "view_latest_release_notes_at": "View latest release notes at {{url}}", @@ -314,13 +317,13 @@ "wired_ip": "Wired IP", "wired_mac_address": "Wired MAC Address", "wired_subnet_mask": "Wired Subnet Mask", - "wired_usb_description": "Learn about connecting to a robot via USB", "wired_usb": "Wired USB", + "wired_usb_description": "Learn about connecting to a robot via USB", "wireless_ip": "Wireless IP", "wireless_mac_address": "Wireless MAC Address", "wireless_subnet_mask": "Wireless Subnet Mask", - "wpa2_personal_description": "Most labs use this method", "wpa2_personal": "WPA2 Personal", + "wpa2_personal_description": "Most labs use this method", "yes_clear_data_and_restart_robot": "Yes, clear data and restart robot", "your_mac_address_is": "Your MAC Address is {{macAddress}}", "your_robot_is_ready_to_go": "Your robot is ready to go." diff --git a/app/src/assets/localization/en/protocol_setup.json b/app/src/assets/localization/en/protocol_setup.json index 5a3a79a18a9..eea63a7ff82 100644 --- a/app/src/assets/localization/en/protocol_setup.json +++ b/app/src/assets/localization/en/protocol_setup.json @@ -22,6 +22,7 @@ "calibrate_deck_to_proceed_to_tip_length_calibration": "Calibrate your deck in order to proceed to tip length calibration", "calibrate_gripper_failure_reason": "Calibrate the required gripper to continue", "calibrate_now": "Calibrate now", + "calibrate_module_failure_reason": "Calibrate the required modules(s) to continue", "calibrate_pipette_before_module_calibration": "Calibrate pipette before running module calibration", "calibrate_pipette_failure_reason": "Calibrate the required pipette(s) to continue", "calibrate_tiprack_failure_reason": "Calibrate the required tip lengths to continue", diff --git a/app/src/assets/localization/en/robot_calibration.json b/app/src/assets/localization/en/robot_calibration.json index 44f7ca2049b..9828ed706c4 100644 --- a/app/src/assets/localization/en/robot_calibration.json +++ b/app/src/assets/localization/en/robot_calibration.json @@ -21,7 +21,6 @@ "calibration_recommended": "Calibration recommended", "calibration_status": "Calibration Status", "calibration_status_description": "For accurate and precise movement, calibrate the robot's deck, pipette offsets, and tip lengths.", - "calibrations_aborted": "Using current calibrations.", "calibrations_complete": "Calibrations complete!", "change_tip_rack": "Change tip rack", "check_tip_on_block": "Check tip on block", @@ -130,6 +129,7 @@ "unknown_custom_tiprack": "unknown custom tip rack", "use_calibration_block": "Use Calibration Block", "use_trash_bin": "Use trash bin", + "using_current_calibrations": "Using current calibrations.", "you_can_remove_cal_block": "You can remove the Calibration Block from the deck now.", "you_will_need": "You will need:" } diff --git a/app/src/assets/localization/en/run_details.json b/app/src/assets/localization/en/run_details.json index 12fafac3580..b757dd5954c 100644 --- a/app/src/assets/localization/en/run_details.json +++ b/app/src/assets/localization/en/run_details.json @@ -16,6 +16,7 @@ "clear_protocol_to_make_available": "Clear protocol from robot to make it available.", "clear_protocol": "Clear protocol", "close_door_to_resume": "Close robot door to resume run", + "close_door": "Close robot door", "closing_protocol": "Closing Protocol", "comment_step": "Comment", "comment": "Comment", @@ -38,6 +39,7 @@ "error_info": "Error {{errorCode}}: {{errorType}}", "error_type": "Error: {{errorType}}", "failed_step": "Failed step", + "final_step": "Final Step", "ignore_stored_data": "Ignore stored data", "labware_offset_data": "labware offset data", "labware": "labware", diff --git a/app/src/atoms/Slideout/index.tsx b/app/src/atoms/Slideout/index.tsx index 2a9b4d63a21..2db12659541 100644 --- a/app/src/atoms/Slideout/index.tsx +++ b/app/src/atoms/Slideout/index.tsx @@ -30,7 +30,6 @@ export interface SlideoutProps { const SHARED_STYLE = css` z-index: 2; - overflow: hidden; @keyframes slidein { from { transform: translateX(100%); @@ -73,22 +72,27 @@ const EXPANDED_STYLE = css` const COLLAPSED_STYLE = css` ${SHARED_STYLE} animation: slideout 300ms forwards; + overflow: hidden; ` const INITIALLY_COLLAPSED_STYLE = css` ${SHARED_STYLE} animation: slideout 0ms forwards; + overflow: hidden; ` const OVERLAY_IN_STYLE = css` ${SHARED_STYLE} animation: overlayin 300ms forwards; + overflow: hidden; ` const OVERLAY_OUT_STYLE = css` ${SHARED_STYLE} animation: overlayout 300ms forwards; + overflow: hidden; ` const INITIALLY_OVERLAY_OUT_STYLE = css` ${SHARED_STYLE} animation: overlayout 0ms forwards; + overflow: hidden; ` const CLOSE_ICON_STYLE = css` @@ -133,6 +137,7 @@ export const Slideout = (props: SlideoutProps): JSX.Element => { const overlayOutStyle = hasBeenExpanded.current ? OVERLAY_OUT_STYLE : INITIALLY_OVERLAY_OUT_STYLE + return ( <> {isGripperAttached ? ( - - flex gripper + + Flex Gripper ) : null} {instrumentDiagramProps?.pipetteSpecs != null ? ( diff --git a/app/src/molecules/PipetteSelect/index.tsx b/app/src/molecules/PipetteSelect/index.tsx index ba6bc56dc76..75e3e3bb985 100644 --- a/app/src/molecules/PipetteSelect/index.tsx +++ b/app/src/molecules/PipetteSelect/index.tsx @@ -81,7 +81,7 @@ export const PipetteSelect = (props: PipetteSelectProps): JSX.Element => { styles={{ menuPortal: base => ({ ...base, zIndex: 10 }) }} value={value} defaultValue={defaultValue} - width="14rem" + width="15rem" tabIndex={tabIndex} onChange={( option: SingleValue | MultiValue, diff --git a/app/src/organisms/CalibrateDeck/index.tsx b/app/src/organisms/CalibrateDeck/index.tsx index 8fa74d35718..5beac8275f3 100644 --- a/app/src/organisms/CalibrateDeck/index.tsx +++ b/app/src/organisms/CalibrateDeck/index.tsx @@ -1,7 +1,9 @@ // Deck Calibration Orchestration Component import * as React from 'react' import { useTranslation } from 'react-i18next' +import { useQueryClient } from 'react-query' +import { useHost } from '@opentrons/react-api-client' import { getPipetteModelSpecs } from '@opentrons/shared-data' import { useConditionalConfirm } from '@opentrons/components' @@ -65,12 +67,15 @@ export function CalibrateDeck( dispatchRequests, showSpinner, isJogging, - wasExitBeforeCompletion, + exitBeforeDeckConfigCompletion, offsetInvalidationHandler, } = props const { currentStep, instrument, labware, supportedCommands } = session?.details || {} + const queryClient = useQueryClient() + const host = useHost() + const { showConfirmation: showConfirmExit, confirm: confirmExit, @@ -97,8 +102,16 @@ export function CalibrateDeck( } function cleanUpAndExit(): void { - if (wasExitBeforeCompletion) { - wasExitBeforeCompletion.current = true + queryClient + .invalidateQueries([host, 'calibration']) + .catch((e: Error) => + console.error(`error invalidating calibration queries: ${e.message}`) + ) + if ( + exitBeforeDeckConfigCompletion && + currentStep !== Sessions.DECK_STEP_CALIBRATION_COMPLETE + ) { + exitBeforeDeckConfigCompletion.current = true } if (session?.id) { dispatchRequests( diff --git a/app/src/organisms/CalibrateDeck/types.ts b/app/src/organisms/CalibrateDeck/types.ts index fb5ae8dcb10..b2df75e4a6d 100644 --- a/app/src/organisms/CalibrateDeck/types.ts +++ b/app/src/organisms/CalibrateDeck/types.ts @@ -7,6 +7,6 @@ export interface CalibrateDeckParentProps { dispatchRequests: DispatchRequestsType showSpinner: boolean isJogging: boolean - wasExitBeforeCompletion?: MutableRefObject + exitBeforeDeckConfigCompletion?: MutableRefObject offsetInvalidationHandler?: () => void } diff --git a/app/src/organisms/CalibratePipetteOffset/index.tsx b/app/src/organisms/CalibratePipetteOffset/index.tsx index 00f4fe49c77..1862f7e8b05 100644 --- a/app/src/organisms/CalibratePipetteOffset/index.tsx +++ b/app/src/organisms/CalibratePipetteOffset/index.tsx @@ -1,7 +1,9 @@ // Pipette Offset Calibration Orchestration Component import * as React from 'react' import { useTranslation } from 'react-i18next' +import { useQueryClient } from 'react-query' +import { useHost } from '@opentrons/react-api-client' import { getPipetteModelSpecs } from '@opentrons/shared-data' import { useConditionalConfirm } from '@opentrons/components' @@ -60,6 +62,9 @@ export function CalibratePipetteOffset( const { currentStep, instrument, labware, supportedCommands } = session?.details ?? {} + const queryClient = useQueryClient() + const host = useHost() + const { showConfirmation: showConfirmExit, confirm: confirmExit, @@ -92,6 +97,11 @@ export function CalibratePipetteOffset( } function cleanUpAndExit(): void { + queryClient + .invalidateQueries([host, 'calibration']) + .catch((e: Error) => + console.error(`error invalidating calibration queries: ${e.message}`) + ) if (session?.id != null) { dispatchRequests( Sessions.createSessionCommand(robotName, session.id, { diff --git a/app/src/organisms/CalibrateTipLength/index.tsx b/app/src/organisms/CalibrateTipLength/index.tsx index c1ffa1f5ae7..bc63d34f134 100644 --- a/app/src/organisms/CalibrateTipLength/index.tsx +++ b/app/src/organisms/CalibrateTipLength/index.tsx @@ -1,8 +1,10 @@ // Tip Length Calibration Orchestration Component import * as React from 'react' import { useTranslation } from 'react-i18next' +import { useQueryClient } from 'react-query' import { css } from 'styled-components' +import { useHost } from '@opentrons/react-api-client' import { getPipetteModelSpecs } from '@opentrons/shared-data' import { useConditionalConfirm } from '@opentrons/components' @@ -72,6 +74,9 @@ export function CalibrateTipLength( } = props const { currentStep, instrument, labware } = session?.details ?? {} + const queryClient = useQueryClient() + const host = useHost() + const isMulti = React.useMemo(() => { const spec = instrument != null ? getPipetteModelSpecs(instrument.model) : null @@ -96,6 +101,11 @@ export function CalibrateTipLength( } function cleanUpAndExit(): void { + queryClient + .invalidateQueries([host, 'calibration']) + .catch((e: Error) => + console.error(`error invalidating calibration queries: ${e.message}`) + ) if (session?.id != null) { dispatchRequests( Sessions.createSessionCommand(robotName, session.id, { diff --git a/app/src/organisms/CalibrationTaskList/__tests__/CalibrationTaskList.test.tsx b/app/src/organisms/CalibrationTaskList/__tests__/CalibrationTaskList.test.tsx index 2bf0b7cfddc..9942ca681b3 100644 --- a/app/src/organisms/CalibrationTaskList/__tests__/CalibrationTaskList.test.tsx +++ b/app/src/organisms/CalibrationTaskList/__tests__/CalibrationTaskList.test.tsx @@ -43,7 +43,7 @@ const render = (robotName: string = 'otie') => { pipOffsetCalLauncher={mockPipOffsetCalLauncher} tipLengthCalLauncher={mockTipLengthCalLauncher} deckCalLauncher={mockDeckCalLauncher} - wasExitBeforeCompletion={false} + exitBeforeDeckConfigCompletion={false} /> , { @@ -94,7 +94,7 @@ describe('CalibrationTaskList', () => { pipOffsetCalLauncher={mockPipOffsetCalLauncher} tipLengthCalLauncher={mockTipLengthCalLauncher} deckCalLauncher={mockDeckCalLauncher} - wasExitBeforeCompletion={false} + exitBeforeDeckConfigCompletion={false} /> ) @@ -120,7 +120,7 @@ describe('CalibrationTaskList', () => { pipOffsetCalLauncher={mockPipOffsetCalLauncher} tipLengthCalLauncher={mockTipLengthCalLauncher} deckCalLauncher={mockDeckCalLauncher} - wasExitBeforeCompletion={false} + exitBeforeDeckConfigCompletion={false} /> ) @@ -145,7 +145,7 @@ describe('CalibrationTaskList', () => { pipOffsetCalLauncher={mockPipOffsetCalLauncher} tipLengthCalLauncher={mockTipLengthCalLauncher} deckCalLauncher={mockDeckCalLauncher} - wasExitBeforeCompletion={false} + exitBeforeDeckConfigCompletion={false} /> ) @@ -170,7 +170,7 @@ describe('CalibrationTaskList', () => { pipOffsetCalLauncher={mockPipOffsetCalLauncher} tipLengthCalLauncher={mockTipLengthCalLauncher} deckCalLauncher={mockDeckCalLauncher} - wasExitBeforeCompletion={false} + exitBeforeDeckConfigCompletion={false} /> ) @@ -194,7 +194,7 @@ describe('CalibrationTaskList', () => { pipOffsetCalLauncher={mockPipOffsetCalLauncher} tipLengthCalLauncher={mockTipLengthCalLauncher} deckCalLauncher={mockDeckCalLauncher} - wasExitBeforeCompletion={false} + exitBeforeDeckConfigCompletion={false} /> ) @@ -218,7 +218,7 @@ describe('CalibrationTaskList', () => { pipOffsetCalLauncher={mockPipOffsetCalLauncher} tipLengthCalLauncher={mockTipLengthCalLauncher} deckCalLauncher={mockDeckCalLauncher} - wasExitBeforeCompletion={false} + exitBeforeDeckConfigCompletion={false} /> ) @@ -240,7 +240,7 @@ describe('CalibrationTaskList', () => { pipOffsetCalLauncher={mockPipOffsetCalLauncher} tipLengthCalLauncher={mockTipLengthCalLauncher} deckCalLauncher={mockDeckCalLauncher} - wasExitBeforeCompletion={true} + exitBeforeDeckConfigCompletion={true} /> ) @@ -265,7 +265,7 @@ describe('CalibrationTaskList', () => { pipOffsetCalLauncher={mockPipOffsetCalLauncher} tipLengthCalLauncher={mockTipLengthCalLauncher} deckCalLauncher={mockDeckCalLauncher} - wasExitBeforeCompletion={false} + exitBeforeDeckConfigCompletion={false} /> ) @@ -293,7 +293,7 @@ describe('CalibrationTaskList', () => { pipOffsetCalLauncher={mockPipOffsetCalLauncher} tipLengthCalLauncher={mockTipLengthCalLauncher} deckCalLauncher={mockDeckCalLauncher} - wasExitBeforeCompletion={false} + exitBeforeDeckConfigCompletion={false} /> ) diff --git a/app/src/organisms/CalibrationTaskList/index.tsx b/app/src/organisms/CalibrationTaskList/index.tsx index 618ceabe68a..fad79643dea 100644 --- a/app/src/organisms/CalibrationTaskList/index.tsx +++ b/app/src/organisms/CalibrationTaskList/index.tsx @@ -1,4 +1,5 @@ import * as React from 'react' +import { css } from 'styled-components' import { useTranslation } from 'react-i18next' import { useHistory } from 'react-router-dom' @@ -35,7 +36,7 @@ interface CalibrationTaskListProps { pipOffsetCalLauncher: DashboardCalOffsetInvoker tipLengthCalLauncher: DashboardCalTipLengthInvoker deckCalLauncher: DashboardCalDeckInvoker - wasExitBeforeCompletion: boolean + exitBeforeDeckConfigCompletion: boolean } export function CalibrationTaskList({ @@ -43,7 +44,7 @@ export function CalibrationTaskList({ pipOffsetCalLauncher, tipLengthCalLauncher, deckCalLauncher, - wasExitBeforeCompletion, + exitBeforeDeckConfigCompletion, }: CalibrationTaskListProps): JSX.Element { const prevActiveIndex = React.useRef<[number, number] | null>(null) const [hasLaunchedWizard, setHasLaunchedWizard] = React.useState( @@ -115,6 +116,10 @@ export function CalibrationTaskList({ fullPage backgroundColor={COLORS.fundamentalsBackground} childrenPadding={`${SPACING.spacing16} ${SPACING.spacing24} ${SPACING.spacing24} ${SPACING.spacing4}`} + css={css` + width: 50rem; + height: 47.5rem; + `} > {showCompletionScreen ? ( - {wasExitBeforeCompletion ? ( + {exitBeforeDeckConfigCompletion ? ( ) : ( )} - {wasExitBeforeCompletion - ? t('calibrations_aborted') + {exitBeforeDeckConfigCompletion + ? t('using_current_calibrations') : t('calibrations_complete')} { const { t } = useTranslation('robot_calibration') return ( @@ -25,17 +25,19 @@ export const CalibrationHealthCheckResults = ({ {t('calibration_health_check_results')} { let props: React.ComponentProps beforeEach(() => { props = { - isCalibrationCompleted: true, + isCalibrationRecommended: false, } }) @@ -35,7 +35,7 @@ describe('CalibrationHealthCheckResults', () => { }) it('should render title and warning StatusLabel when calibration results includes bad', () => { - props.isCalibrationCompleted = false + props.isCalibrationRecommended = true const { getByText, getByTestId } = render(props) getByText('Calibration recommended') expect(getByTestId('status_circle')).toHaveStyle( diff --git a/app/src/organisms/CheckCalibration/ResultsSummary/index.tsx b/app/src/organisms/CheckCalibration/ResultsSummary/index.tsx index 624f8946b91..54ba1e6110b 100644 --- a/app/src/organisms/CheckCalibration/ResultsSummary/index.tsx +++ b/app/src/organisms/CheckCalibration/ResultsSummary/index.tsx @@ -111,7 +111,7 @@ export function ResultsSummary( // check all calibration status // if all of them are good, this returns true. otherwise return false - const isCalibrationCompleted = (): boolean => { + const isCalibrationRecommended = (): boolean => { const isOffsetsBad = pipetteResultsBad(calibrationsByMount.left.calibration).offsetBad && pipetteResultsBad(calibrationsByMount.right.calibration).offsetBad @@ -130,7 +130,7 @@ export function ResultsSummary( > diff --git a/app/src/organisms/CommandText/PipettingCommandText.tsx b/app/src/organisms/CommandText/PipettingCommandText.tsx index ad5e5900588..4437fc5ebd9 100644 --- a/app/src/organisms/CommandText/PipettingCommandText.tsx +++ b/app/src/organisms/CommandText/PipettingCommandText.tsx @@ -11,7 +11,11 @@ import { } from '@opentrons/shared-data' import { getLabwareDefinitionsFromCommands } from '../LabwarePositionCheck/utils/labware' import { getLoadedLabware } from './utils/accessors' -import { getLabwareName, getLabwareDisplayLocation } from './utils' +import { + getLabwareName, + getLabwareDisplayLocation, + getFinalLabwareLocation, +} from './utils' type PipettingRunTimeCommmand = | AspirateRunTimeCommand @@ -32,9 +36,14 @@ export const PipettingCommandText = ({ const { t } = useTranslation('protocol_command_text') const { labwareId, wellName } = command.params - const labwareLocation = getLoadedLabware(robotSideAnalysis, labwareId) - ?.location - + const allPreviousCommands = robotSideAnalysis.commands.slice( + 0, + robotSideAnalysis.commands.findIndex(c => c.id === command.id) + ) + const labwareLocation = getFinalLabwareLocation( + labwareId, + allPreviousCommands + ) const displayLocation = labwareLocation != null ? getLabwareDisplayLocation(robotSideAnalysis, labwareLocation, t) diff --git a/app/src/organisms/CommandText/utils/index.ts b/app/src/organisms/CommandText/utils/index.ts index 851260f6790..0b7a5c24124 100644 --- a/app/src/organisms/CommandText/utils/index.ts +++ b/app/src/organisms/CommandText/utils/index.ts @@ -4,3 +4,4 @@ export * from './getModuleModel' export * from './getModuleDisplayLocation' export * from './getLiquidDisplayName' export * from './getLabwareDisplayLocation' +export * from './getFinalLabwareLocation' diff --git a/app/src/organisms/ConfigurePipette/ConfigForm.tsx b/app/src/organisms/ConfigurePipette/ConfigForm.tsx index 5c640e6957b..919d4224f7a 100644 --- a/app/src/organisms/ConfigurePipette/ConfigForm.tsx +++ b/app/src/organisms/ConfigurePipette/ConfigForm.tsx @@ -16,13 +16,12 @@ import { } from './ConfigFormGroup' import type { FormikProps } from 'formik' +import type { FormValues } from './ConfigFormGroup' import type { PipetteSettingsField, PipetteSettingsFieldsMap, - PipetteSettingsFieldsUpdate, -} from '../../redux/pipettes/types' - -import type { FormValues } from './ConfigFormGroup' + UpdatePipetteSettingsData, +} from '@opentrons/api-client' export interface DisplayFieldProps extends PipetteSettingsField { name: string @@ -38,7 +37,7 @@ export interface DisplayQuirkFieldProps { export interface ConfigFormProps { settings: PipetteSettingsFieldsMap updateInProgress: boolean - updateSettings: (fields: PipetteSettingsFieldsUpdate) => unknown + updateSettings: (params: UpdatePipetteSettingsData) => void groupLabels: string[] formId: string } @@ -96,14 +95,16 @@ export class ConfigForm extends React.Component { } handleSubmit: (values: FormValues) => void = values => { - const params = mapValues(values, v => { - if (v === true || v === false) return v + const fields = mapValues< + FormValues, + { value: PipetteSettingsField['value'] } | null + >(values, v => { + if (v === true || v === false) return { value: v } if (v === '' || v == null) return null - return Number(v) + return { value: Number(v) } }) - // @ts-expect-error TODO updateSettings type doesn't include boolean for values of params, but they could be returned. - this.props.updateSettings(params) + this.props.updateSettings({ fields }) } getFieldValue( @@ -161,7 +162,6 @@ export class ConfigForm extends React.Component { PipetteSettingsFieldsMap, string | boolean >(fields, f => { - // @ts-expect-error TODO: PipetteSettingsFieldsMap doesn't include a boolean value, despite checking for it here if (f.value === true || f.value === false) return f.value // @ts-expect-error(sa, 2021-05-27): avoiding src code change, use optional chain to access f.value return f.value !== f.default ? f.value.toString() : '' diff --git a/app/src/organisms/ConfigurePipette/ConfigFormGroup.tsx b/app/src/organisms/ConfigurePipette/ConfigFormGroup.tsx index 227577474dd..919e4660d5d 100644 --- a/app/src/organisms/ConfigurePipette/ConfigFormGroup.tsx +++ b/app/src/organisms/ConfigurePipette/ConfigFormGroup.tsx @@ -96,7 +96,7 @@ export function ConfigInput(props: ConfigInputProps): JSX.Element { const { field } = props const { name, units, displayName } = field const id = makeId(field.name) - const _default = field.default.toString() + const _default = field.default?.toString() return ( diff --git a/app/src/organisms/ConfigurePipette/__tests__/ConfigurePipette.test.tsx b/app/src/organisms/ConfigurePipette/__tests__/ConfigurePipette.test.tsx index ff7a7abe3e6..a2d9aca36e7 100644 --- a/app/src/organisms/ConfigurePipette/__tests__/ConfigurePipette.test.tsx +++ b/app/src/organisms/ConfigurePipette/__tests__/ConfigurePipette.test.tsx @@ -35,9 +35,10 @@ describe('ConfigurePipette', () => { beforeEach(() => { props = { + isUpdateLoading: false, + updateError: null, settings: mockPipetteSettingsFieldsMap, robotName: mockRobotName, - updateRequest: { status: 'pending' }, updateSettings: jest.fn(), closeModal: jest.fn(), formId: 'id', diff --git a/app/src/organisms/ConfigurePipette/index.tsx b/app/src/organisms/ConfigurePipette/index.tsx index 1353854ff54..326f9e5792e 100644 --- a/app/src/organisms/ConfigurePipette/index.tsx +++ b/app/src/organisms/ConfigurePipette/index.tsx @@ -2,27 +2,31 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' import { Box } from '@opentrons/components' -import { SUCCESS, FAILURE, PENDING } from '../../redux/robot-api' import { ConfigForm } from './ConfigForm' import { ConfigErrorBanner } from './ConfigErrorBanner' - import type { - PipetteSettingsFieldsUpdate, PipetteSettingsFieldsMap, -} from '../../redux/pipettes/types' -import type { RequestState } from '../../redux/robot-api/types' + UpdatePipetteSettingsData, +} from '@opentrons/api-client' interface Props { closeModal: () => void - updateRequest: RequestState | null - updateSettings: (fields: PipetteSettingsFieldsUpdate) => void + updateSettings: (params: UpdatePipetteSettingsData) => void + updateError: Error | null + isUpdateLoading: boolean robotName: string formId: string settings: PipetteSettingsFieldsMap } export function ConfigurePipette(props: Props): JSX.Element { - const { closeModal, updateRequest, updateSettings, formId, settings } = props + const { + updateSettings, + updateError, + isUpdateLoading, + formId, + settings, + } = props const { t } = useTranslation('device_details') const groupLabels = [ @@ -31,25 +35,14 @@ export function ConfigurePipette(props: Props): JSX.Element { t('power_force'), ] - const updateError: string | null = - updateRequest && updateRequest.status === FAILURE - ? // @ts-expect-error(sa, 2021-05-27): avoiding src code change, need to type narrow - updateRequest.error.message || t('an_error_occurred_while_updating') - : null - - // when an in-progress request completes, close modal if response was ok - React.useEffect(() => { - if (updateRequest?.status === SUCCESS) { - closeModal() - } - }, [updateRequest, closeModal]) - return ( - {updateError && } + {updateError != null && ( + + )} {t('robot_control_not_available')} )} - {getShowPipetteCalibrationWarning(attachedInstruments) && - (currentRunId == null || isRunTerminal) ? ( - + {isRobotViewable && + getShowPipetteCalibrationWarning(attachedInstruments) && + (isRunTerminal || currentRunId == null) ? ( + ) : null} @@ -213,7 +214,7 @@ export function InstrumentsAndModules({ pipetteIs96Channel={is96ChannelAttached} pipetteIsBad={badLeftPipette != null} updatePipette={() => setSubsystemToUpdate('pipette_left')} - isRunActive={currentRunId != null && !isRunTerminal} + isRunActive={currentRunId != null && isRunRunning} /> {isFlex && ( )} {leftColumnModules.map((module, index) => ( @@ -265,7 +266,7 @@ export function InstrumentsAndModules({ pipetteIs96Channel={false} pipetteIsBad={badRightPipette != null} updatePipette={() => setSubsystemToUpdate('pipette_right')} - isRunActive={currentRunId != null && !isRunTerminal} + isRunActive={currentRunId != null && isRunRunning} /> )} {rightColumnModules.map((module, index) => ( diff --git a/app/src/organisms/Devices/PipetteCard/PipetteOverflowMenu.tsx b/app/src/organisms/Devices/PipetteCard/PipetteOverflowMenu.tsx index 19200b32d82..577e1c7a274 100644 --- a/app/src/organisms/Devices/PipetteCard/PipetteOverflowMenu.tsx +++ b/app/src/organisms/Devices/PipetteCard/PipetteOverflowMenu.tsx @@ -18,10 +18,8 @@ import { import { MenuItem } from '../../../atoms/MenuList/MenuItem' import { Divider } from '../../../atoms/structure' -import type { - Mount, - PipetteSettingsFieldsMap, -} from '../../../redux/pipettes/types' +import type { Mount } from '../../../redux/pipettes/types' +import type { PipetteSettingsFieldsMap } from '@opentrons/api-client' interface PipetteOverflowMenuProps { pipetteSpecs: PipetteModelSpecs | null diff --git a/app/src/organisms/Devices/PipetteCard/PipetteRecalibrationWarning.tsx b/app/src/organisms/Devices/PipetteCard/PipetteRecalibrationWarning.tsx index decd6b35606..0ee79d620ff 100644 --- a/app/src/organisms/Devices/PipetteCard/PipetteRecalibrationWarning.tsx +++ b/app/src/organisms/Devices/PipetteCard/PipetteRecalibrationWarning.tsx @@ -16,7 +16,7 @@ export const PipetteRecalibrationWarning = (): JSX.Element | null => { if (!showBanner) return null return ( - + () - const [dispatchRequest, requestIds] = useDispatchApiRequest() - const updateSettings = (fields: PipetteSettingsFieldsUpdate): void => { - dispatchRequest(updatePipetteSettings(robotName, pipetteId, fields)) - } - const latestRequestId = last(requestIds) - const updateRequest = useSelector((state: State) => - latestRequestId != null ? getRequestById(state, latestRequestId) : null - ) - const FORM_ID = `configurePipetteForm_${pipetteId}` + const { + updatePipetteSettings, + isLoading, + error, + } = useUpdatePipetteSettingsMutation(pipetteId, { onSuccess: onCloseClick }) - // TODO(bc, 2023-02-10): replace this with the usePipetteSettingsQuery for poll and data access in the child components - useInterval( - () => { - dispatch(fetchPipetteSettings(robotName)) - }, - FETCH_PIPETTES_INTERVAL_MS, - true - ) + const FORM_ID = `configurePipetteForm_${pipetteId}` return ( - } + footer={} > const mockUseCurrentSubsystemUpdateQuery = useCurrentSubsystemUpdateQuery as jest.MockedFunction< typeof useCurrentSubsystemUpdateQuery > -const mockGetAttachedPipetteSettingsFieldsById = getAttachedPipetteSettingsFieldsById as jest.MockedFunction< - typeof getAttachedPipetteSettingsFieldsById +const mockUsePipetteSettingsQuery = usePipetteSettingsQuery as jest.MockedFunction< + typeof usePipetteSettingsQuery > const render = (props: React.ComponentProps) => { @@ -113,9 +113,9 @@ describe('PipetteCard', () => { mockUseCurrentSubsystemUpdateQuery.mockReturnValue({ data: undefined, } as any) - when(mockGetAttachedPipetteSettingsFieldsById) - .calledWith({} as State, mockRobotName, 'id') - .mockReturnValue(mockPipetteSettingsFieldsMap) + when(mockUsePipetteSettingsQuery) + .calledWith({ refetchInterval: 5000, enabled: true }) + .mockReturnValue({} as any) }) afterEach(() => { jest.resetAllMocks() @@ -296,9 +296,6 @@ describe('PipetteCard', () => { getByText('Firmware update in progress...') }) it('does not render a pipette settings slideout card if the pipette has no settings', () => { - when(mockGetAttachedPipetteSettingsFieldsById) - .calledWith({} as State, mockRobotName, 'id') - .mockReturnValue(null) const { queryByTestId } = render(props) expect( queryByTestId( diff --git a/app/src/organisms/Devices/PipetteCard/__tests__/PipetteSettingsSlideout.test.tsx b/app/src/organisms/Devices/PipetteCard/__tests__/PipetteSettingsSlideout.test.tsx index 546d3774d8b..393e1aed586 100644 --- a/app/src/organisms/Devices/PipetteCard/__tests__/PipetteSettingsSlideout.test.tsx +++ b/app/src/organisms/Devices/PipetteCard/__tests__/PipetteSettingsSlideout.test.tsx @@ -3,10 +3,11 @@ import { resetAllWhenMocks, when } from 'jest-when' import { waitFor } from '@testing-library/dom' import { fireEvent } from '@testing-library/react' import { renderWithProviders } from '@opentrons/components' +import { + useHost, + useUpdatePipetteSettingsMutation, +} from '@opentrons/react-api-client' import { i18n } from '../../../../i18n' -import * as RobotApi from '../../../../redux/robot-api' -import { updatePipetteSettings } from '../../../../redux/pipettes' -import { getConfig } from '../../../../redux/config' import { PipetteSettingsSlideout } from '../PipetteSettingsSlideout' import { @@ -14,22 +15,11 @@ import { mockPipetteSettingsFieldsMap, } from '../../../../redux/pipettes/__fixtures__' -import type { DispatchApiRequestType } from '../../../../redux/robot-api' -import type { UpdatePipetteSettingsAction } from '../../../../redux/pipettes/types' - -jest.mock('../../../../redux/robot-api') -jest.mock('../../../../redux/config') -jest.mock('../../../../redux/pipettes') +jest.mock('@opentrons/react-api-client') -const mockGetConfig = getConfig as jest.MockedFunction -const mockUseDispatchApiRequest = RobotApi.useDispatchApiRequest as jest.MockedFunction< - typeof RobotApi.useDispatchApiRequest -> -const mockGetRequestById = RobotApi.getRequestById as jest.MockedFunction< - typeof RobotApi.getRequestById -> -const mockUpdatePipetteSettings = updatePipetteSettings as jest.MockedFunction< - typeof updatePipetteSettings +const mockUseHost = useHost as jest.MockedFunction +const mockUseUpdatePipetteSettingsMutation = useUpdatePipetteSettingsMutation as jest.MockedFunction< + typeof useUpdatePipetteSettingsMutation > const render = ( @@ -43,9 +33,8 @@ const render = ( const mockRobotName = 'mockRobotName' describe('PipetteSettingsSlideout', () => { - let dispatchApiRequest: DispatchApiRequestType - let props: React.ComponentProps + let mockUpdatePipetteSettings: jest.Mock beforeEach(() => { props = { @@ -56,20 +45,19 @@ describe('PipetteSettingsSlideout', () => { isExpanded: true, onCloseClick: jest.fn(), } - mockGetRequestById.mockReturnValue({ - status: RobotApi.SUCCESS, - response: { - method: 'POST', - ok: true, - path: '/', - status: 200, - }, - }) - mockGetConfig.mockReturnValue({} as any) - dispatchApiRequest = jest.fn() - when(mockUseDispatchApiRequest) + when(mockUseHost) .calledWith() - .mockReturnValue([dispatchApiRequest, ['id']]) + .mockReturnValue({} as any) + + mockUpdatePipetteSettings = jest.fn() + + when(mockUseUpdatePipetteSettingsMutation) + .calledWith(props.pipetteId, expect.anything()) + .mockReturnValue({ + updatePipetteSettings: mockUpdatePipetteSettings, + isLoading: false, + error: null, + } as any) }) afterEach(() => { jest.resetAllMocks() @@ -96,30 +84,20 @@ describe('PipetteSettingsSlideout', () => { const { getByRole } = render(props) const button = getByRole('button', { name: 'Confirm' }) - when(mockUpdatePipetteSettings) - .calledWith( - mockRobotName, - props.pipetteId, - expect.objectContaining({ - blowout: 2, - bottom: 3, - dropTip: 1, + fireEvent.click(button) + await waitFor(() => { + expect(mockUpdatePipetteSettings).toHaveBeenCalledWith({ + fields: expect.objectContaining({ + blowout: { value: 2 }, + bottom: { value: 3 }, + dropTip: { value: 1 }, dropTipCurrent: null, dropTipSpeed: null, pickUpCurrent: null, pickUpDistance: null, plungerCurrent: null, - top: 4, - }) - ) - .mockReturnValue({ - type: 'pipettes:UPDATE_PIPETTE_SETTINGS', - } as UpdatePipetteSettingsAction) - - fireEvent.click(button) - await waitFor(() => { - expect(dispatchApiRequest).toHaveBeenCalledWith({ - type: 'pipettes:UPDATE_PIPETTE_SETTINGS', + top: { value: 4 }, + }), }) }) }) diff --git a/app/src/organisms/Devices/PipetteCard/index.tsx b/app/src/organisms/Devices/PipetteCard/index.tsx index e9bda57500d..6e0b0345d2b 100644 --- a/app/src/organisms/Devices/PipetteCard/index.tsx +++ b/app/src/organisms/Devices/PipetteCard/index.tsx @@ -1,7 +1,6 @@ import * as React from 'react' import { Trans, useTranslation } from 'react-i18next' import { css } from 'styled-components' -import { useSelector } from 'react-redux' import { Box, @@ -15,6 +14,7 @@ import { useOnClickOutside, InstrumentDiagram, BORDERS, + ALIGN_CENTER, } from '@opentrons/components' import { FLEX_ROBOT_TYPE, @@ -23,12 +23,12 @@ import { OT2_ROBOT_TYPE, SINGLE_MOUNT_PIPETTES, } from '@opentrons/shared-data' -import { useCurrentSubsystemUpdateQuery } from '@opentrons/react-api-client' - import { - LEFT, - getAttachedPipetteSettingsFieldsById, -} from '../../../redux/pipettes' + useCurrentSubsystemUpdateQuery, + usePipetteSettingsQuery, +} from '@opentrons/react-api-client' + +import { LEFT } from '../../../redux/pipettes' import { OverflowBtn } from '../../../atoms/MenuList/OverflowBtn' import { StyledText } from '../../../atoms/text' import { Banner } from '../../../atoms/Banner' @@ -42,7 +42,7 @@ import { useIsFlex } from '../hooks' import { PipetteOverflowMenu } from './PipetteOverflowMenu' import { PipetteSettingsSlideout } from './PipetteSettingsSlideout' import { AboutPipetteSlideout } from './AboutPipetteSlideout' -import type { State } from '../../../redux/types' + import type { PipetteModelSpecs, PipetteName } from '@opentrons/shared-data' import type { AttachedPipette, Mount } from '../../../redux/pipettes/types' import type { @@ -123,9 +123,11 @@ export const PipetteCard = (props: PipetteCardProps): JSX.Element => { refetchInterval: SUBSYSTEM_UPDATE_POLL_MS, } ) - const settings = useSelector((state: State) => - getAttachedPipetteSettingsFieldsById(state, robotName, pipetteId ?? '') - ) + const settings = + usePipetteSettingsQuery({ + refetchInterval: 5000, + enabled: pipetteId != null, + })?.data?.[pipetteId ?? '']?.fields ?? null const [ selectedPipette, @@ -226,12 +228,9 @@ export const PipetteCard = (props: PipetteCardProps): JSX.Element => { )} {!pipetteIsBad && subsystemUpdateData == null && ( <> - + - + {pipetteModelSpecs !== null ? ( { transform={isFlex ? 'scale(0.4)' : 'scale(0.3)'} size="3.125rem" transformOrigin={isFlex ? '-50% -10%' : '20% -10%'} + width="100%" + height="100%" /> ) : null} diff --git a/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx b/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx index 63f797ff17f..fe51b7dec30 100644 --- a/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx +++ b/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx @@ -85,6 +85,7 @@ import { useTrackProtocolRunEvent, useRobotAnalyticsData, useIsFlex, + useModuleCalibrationStatus, } from '../hooks' import { formatTimestamp } from '../utils' import { RunTimer } from './RunTimer' @@ -476,10 +477,17 @@ function ActionButton(props: ActionButtonProps): JSX.Element { robotName, runId ) + const { complete: isModuleCalibrationComplete } = useModuleCalibrationStatus( + robotName, + runId + ) const [showIsShakingModal, setShowIsShakingModal] = React.useState( false ) - const isSetupComplete = isCalibrationComplete && missingModuleIds.length === 0 + const isSetupComplete = + isCalibrationComplete && + isModuleCalibrationComplete && + missingModuleIds.length === 0 const isRobotOnWrongVersionOfSoftware = ['upgrade', 'downgrade'].includes( useSelector((state: State) => { return getRobotUpdateDisplayInfo(state, robotName) @@ -543,6 +551,12 @@ function ActionButton(props: ActionButtonProps): JSX.Element { disableReason = t('shared:robot_is_busy') } else if (isRobotOnWrongVersionOfSoftware) { disableReason = t('shared:a_software_update_is_available') + } else if ( + isDoorOpen && + runStatus != null && + START_RUN_STATUSES.includes(runStatus) + ) { + disableReason = t('close_door') } if (isProtocolAnalyzing) { diff --git a/app/src/organisms/Devices/ProtocolRun/ProtocolRunSetup.tsx b/app/src/organisms/Devices/ProtocolRun/ProtocolRunSetup.tsx index 432fa6d9705..76fb0af0350 100644 --- a/app/src/organisms/Devices/ProtocolRun/ProtocolRunSetup.tsx +++ b/app/src/organisms/Devices/ProtocolRun/ProtocolRunSetup.tsx @@ -34,6 +34,7 @@ import { useRunHasStarted, useProtocolAnalysisErrors, useStoredProtocolAnalysis, + useModuleCalibrationStatus, ProtocolCalibrationStatus, } from '../hooks' import { useMostRecentCompletedAnalysis } from '../../LabwarePositionCheck/useMostRecentCompletedAnalysis' @@ -123,7 +124,8 @@ export function ProtocolRunSetup({ }, } const robot = useRobot(robotName) - const calibrationStatus = useRunCalibrationStatus(robotName, runId) + const calibrationStatusRobot = useRunCalibrationStatus(robotName, runId) + const calibrationStatusModules = useModuleCalibrationStatus(robotName, runId) const isFlex = useIsFlex(robotName) const runHasStarted = useRunHasStarted(runId) const { analysisErrors } = useProtocolAnalysisErrors(runId) @@ -198,7 +200,7 @@ export function ProtocolRunSetup({ ] } expandStep={setExpandedStepKey} - calibrationStatus={calibrationStatus} + calibrationStatus={calibrationStatusRobot} /> ), // change description for OT-3 @@ -306,7 +308,12 @@ export function ProtocolRunSetup({ } rightElement={ } > @@ -319,6 +326,7 @@ export function ProtocolRunSetup({ ) }) + )} ) : ( @@ -332,25 +340,42 @@ export function ProtocolRunSetup({ interface StepRightElementProps { stepKey: StepKey - calibrationStatus: ProtocolCalibrationStatus + calibrationStatusRobot: ProtocolCalibrationStatus + calibrationStatusModules?: ProtocolCalibrationStatus runHasStarted: boolean + isFlex: boolean } function StepRightElement(props: StepRightElementProps): JSX.Element | null { - const { stepKey, calibrationStatus, runHasStarted } = props + const { + stepKey, + runHasStarted, + calibrationStatusRobot, + calibrationStatusModules, + isFlex, + } = props const { t } = useTranslation('protocol_setup') - if (stepKey === ROBOT_CALIBRATION_STEP_KEY && !runHasStarted) { + if ( + !runHasStarted && + (stepKey === ROBOT_CALIBRATION_STEP_KEY || + (stepKey === MODULE_SETUP_KEY && isFlex)) + ) { + const calibrationStatus = + stepKey === ROBOT_CALIBRATION_STEP_KEY + ? calibrationStatusRobot + : calibrationStatusModules + return ( - {calibrationStatus.complete + {calibrationStatus?.complete ? t('calibration_ready') : t('calibration_needed')} diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/CurrentOffsetsTable.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/CurrentOffsetsTable.tsx index 48bfbd2fe44..c78d3b0b154 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/CurrentOffsetsTable.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/CurrentOffsetsTable.tsx @@ -22,7 +22,6 @@ import { StyledText } from '../../../../atoms/text' import { LabwareOffsetTabs } from '../../../LabwareOffsetTabs' import { OffsetVector } from '../../../../molecules/OffsetVector' import { PythonLabwareOffsetSnippet } from '../../../../molecules/PythonLabwareOffsetSnippet' -import { getLatestCurrentOffsets } from './utils' import type { LabwareOffset } from '@opentrons/api-client' import type { RunTimeCommand, @@ -71,7 +70,6 @@ export function CurrentOffsetsTable( const isLabwareOffsetCodeSnippetsOn = useSelector( getIsLabwareOffsetCodeSnippetsOn ) - const latestCurrentOffsets = getLatestCurrentOffsets(currentOffsets) const TableComponent = ( @@ -83,7 +81,7 @@ export function CurrentOffsetsTable( - {latestCurrentOffsets.map(offset => { + {currentOffsets.map(offset => { const labwareDisplayName = offset.definitionUri in defsByURI ? getLabwareDisplayName(defsByURI[offset.definitionUri]) @@ -112,7 +110,7 @@ export function CurrentOffsetsTable( const JupyterSnippet = ( + labwareOffsets={currentOffsets.map(o => pick(o, ['definitionUri', 'location', 'vector']) )} commands={commands ?? []} @@ -123,7 +121,7 @@ export function CurrentOffsetsTable( const CommandLineSnippet = ( + labwareOffsets={currentOffsets.map(o => pick(o, ['definitionUri', 'location', 'vector']) )} commands={commands ?? []} @@ -137,7 +135,9 @@ export function CurrentOffsetsTable( justifyContent={JUSTIFY_SPACE_BETWEEN} padding={SPACING.spacing16} > - {t('applied_offset_data')} + + {i18n.format(t('applied_offset_data'), 'upperCase')} + {isLabwareOffsetCodeSnippetsOn ? ( { }) it('renders the correct text', () => { const { getByText } = render(props) - getByText('Applied Labware Offset data') + getByText('APPLIED LABWARE OFFSET DATA') getByText('location') getByText('labware') getByText('labware offset data') diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/index.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/index.tsx index 7df13a85fcf..f81a4f7bbae 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/index.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/index.tsx @@ -22,6 +22,7 @@ import { CurrentOffsetsTable } from './CurrentOffsetsTable' import { useLaunchLPC } from '../../../LabwarePositionCheck/useLaunchLPC' import { StyledText } from '../../../../atoms/text' import type { LabwareOffset } from '@opentrons/api-client' +import { getLatestCurrentOffsets } from './utils' interface SetupLabwarePositionCheckProps { expandLabwareStep: () => void @@ -74,15 +75,17 @@ export function SetupLabwarePositionCheck( const { launchLPC, LPCWizard } = useLaunchLPC(runId, protocolName) + const nonIdentityOffsets = getLatestCurrentOffsets(sortedOffsets) + return ( - {sortedOffsets.length > 0 ? ( + {nonIdentityOffsets.length > 0 ? ( +const mockUseModuleCalibrationStatus = useModuleCalibrationStatus as jest.MockedFunction< + typeof useModuleCalibrationStatus +> const mockSetupModulesList = SetupModulesList as jest.MockedFunction< typeof SetupModulesList > @@ -88,6 +92,9 @@ describe('SetupModuleAndDeck', () => { when(mockUseFeatureFlag) .calledWith('enableDeckConfiguration') .mockReturnValue(false) + when(mockUseModuleCalibrationStatus) + .calledWith(MOCK_ROBOT_NAME, MOCK_RUN_ID) + .mockReturnValue({ complete: true }) }) it('renders the list and map view buttons', () => { @@ -118,6 +125,17 @@ describe('SetupModuleAndDeck', () => { expect(button).toBeDisabled() }) + it('should render a disabled Proceed to labware setup CTA if the protocol requests modules they are not all calibrated', () => { + when(mockUseModuleCalibrationStatus) + .calledWith(MOCK_ROBOT_NAME, MOCK_RUN_ID) + .mockReturnValue({ complete: false }) + const { getByRole } = render(props) + const button = getByRole('button', { + name: 'Proceed to labware position check', + }) + expect(button).toBeDisabled() + }) + it('should render the SetupModulesList component when clicking List View', () => { const { getByRole, getByText } = render(props) const button = getByRole('button', { name: 'List View' }) diff --git a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/index.tsx b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/index.tsx index edf9cb19ba0..513b8bec08c 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/index.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/index.tsx @@ -11,7 +11,7 @@ import { import { useToggleGroup } from '../../../../molecules/ToggleGroup/useToggleGroup' import { Tooltip } from '../../../../atoms/Tooltip' import { useFeatureFlag } from '../../../../redux/config' -import { useRunHasStarted, useUnmatchedModulesForProtocol } from '../../hooks' +import { useRunHasStarted, useUnmatchedModulesForProtocol, useModuleCalibrationStatus } from '../../hooks' import { SetupModulesMap } from './SetupModulesMap' import { SetupModulesList } from './SetupModulesList' import { SetupFixtureList } from './SetupFixtureList' @@ -42,6 +42,9 @@ export const SetupModuleAndDeck = ({ const { missingModuleIds } = useUnmatchedModulesForProtocol(robotName, runId) const runHasStarted = useRunHasStarted(runId) const [targetProps, tooltipProps] = useHoverTooltip() + + const moduleCalibrationStatus = useModuleCalibrationStatus(robotName, runId) + return ( <> @@ -62,7 +65,11 @@ export const SetupModuleAndDeck = ({ 0 || runHasStarted} + disabled={ + missingModuleIds.length > 0 || + runHasStarted || + !moduleCalibrationStatus.complete + } onClick={expandLabwarePositionCheckStep} id="ModuleSetup_proceedToLabwarePositionCheck" padding={`${SPACING.spacing8} ${SPACING.spacing16}`} @@ -71,11 +78,15 @@ export const SetupModuleAndDeck = ({ {t('proceed_to_labware_position_check')} - {missingModuleIds.length > 0 || runHasStarted ? ( + {missingModuleIds.length > 0 || + runHasStarted || + !moduleCalibrationStatus.complete ? ( {runHasStarted ? t('protocol_run_started') - : t('plug_in_required_module', { count: missingModuleIds.length })} + : missingModuleIds.length > 0 + ? t('plug_in_required_module', { count: missingModuleIds.length }) + : t('calibrate_module_failure_reason')} ) : null} diff --git a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx index 4934dfda2da..10971405908 100644 --- a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx @@ -66,6 +66,7 @@ import { useTrackProtocolRunEvent, useRunCalibrationStatus, useRunCreatedAtTimestamp, + useModuleCalibrationStatus, useUnmatchedModulesForProtocol, useIsRobotViewable, useIsFlex, @@ -148,6 +149,9 @@ const mockUseUnmatchedModulesForProtocol = useUnmatchedModulesForProtocol as jes const mockUseRunCalibrationStatus = useRunCalibrationStatus as jest.MockedFunction< typeof useRunCalibrationStatus > +const mockUseModuleCalibrationStatus = useModuleCalibrationStatus as jest.MockedFunction< + typeof useModuleCalibrationStatus +> const mockUseRunCreatedAtTimestamp = useRunCreatedAtTimestamp as jest.MockedFunction< typeof useRunCreatedAtTimestamp > @@ -364,6 +368,9 @@ describe('ProtocolRunHeader', () => { .calledWith(ROBOT_NAME, RUN_ID) .mockReturnValue({ complete: true }) when(mockUseIsFlex).calledWith(ROBOT_NAME).mockReturnValue(true) + when(mockUseModuleCalibrationStatus) + .calledWith(ROBOT_NAME, RUN_ID) + .mockReturnValue({ complete: true }) mockRunFailedModal.mockReturnValue(
mock RunFailedModal
) mockUseEstopQuery.mockReturnValue({ data: mockEstopStatus } as any) mockUseDoorQuery.mockReturnValue({ data: mockDoorStatus } as any) @@ -595,6 +602,28 @@ describe('ProtocolRunHeader', () => { getByText('Stop requested') }) + it('renders a disabled button and when the robot door is open', () => { + when(mockUseRunQuery) + .calledWith(RUN_ID) + .mockReturnValue({ + data: { data: mockRunningRun }, + } as UseQueryResult) + when(mockUseRunStatus) + .calledWith(RUN_ID) + .mockReturnValue(RUN_STATUS_BLOCKED_BY_OPEN_DOOR) + + const mockOpenDoorStatus = { + data: { status: 'open', doorRequiredClosedForProtocol: true }, + } + mockUseDoorQuery.mockReturnValue({ data: mockOpenDoorStatus } as any) + + const [{ getByText, getByRole }] = render() + + const button = getByRole('button', { name: 'Resume run' }) + expect(button).toBeDisabled() + getByText('Close robot door') + }) + it('renders a Run Again button and end time when run has stopped and calls trackProtocolRunEvent when run again button clicked', () => { when(mockUseRunQuery) .calledWith(RUN_ID) diff --git a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunSetup.test.tsx b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunSetup.test.tsx index e3170881659..0e1330a32d4 100644 --- a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunSetup.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunSetup.test.tsx @@ -21,6 +21,7 @@ import { useIsFlex, useRobot, useRunCalibrationStatus, + useModuleCalibrationStatus, useRunHasStarted, useProtocolAnalysisErrors, useStoredProtocolAnalysis, @@ -54,6 +55,9 @@ const mockUseRobot = useRobot as jest.MockedFunction const mockUseRunCalibrationStatus = useRunCalibrationStatus as jest.MockedFunction< typeof useRunCalibrationStatus > +const mockUseModuleCalibrationStatus = useModuleCalibrationStatus as jest.MockedFunction< + typeof useModuleCalibrationStatus +> const mockUseRunHasStarted = useRunHasStarted as jest.MockedFunction< typeof useRunHasStarted > @@ -273,11 +277,43 @@ describe('ProtocolRunSetup', () => { ...MOCK_ROTOCOL_LIQUID_KEY, } as any) when(mockUseRunHasStarted).calledWith(RUN_ID).mockReturnValue(false) + when(mockUseModuleCalibrationStatus) + .calledWith(ROBOT_NAME, RUN_ID) + .mockReturnValue({ complete: true }) }) afterEach(() => { resetAllWhenMocks() }) + it('renders calibration ready if robot is Flex and modules are calibrated', () => { + when(mockUseIsOT3).calledWith(ROBOT_NAME).mockReturnValue(true) + when(mockUseModuleCalibrationStatus) + .calledWith(ROBOT_NAME, RUN_ID) + .mockReturnValue({ complete: true }) + + const { getAllByText } = render() + expect(getAllByText('Calibration ready').length).toEqual(2) + }) + + it('renders calibration needed if robot is Flex and modules are not calibrated', () => { + when(mockUseIsOT3).calledWith(ROBOT_NAME).mockReturnValue(true) + when(mockUseModuleCalibrationStatus) + .calledWith(ROBOT_NAME, RUN_ID) + .mockReturnValue({ complete: false }) + + const { getByText } = render() + getByText('STEP 2') + getByText('Modules') + getByText('Calibration needed') + }) + + it('does not render calibration element if robot is OT-2', () => { + when(mockUseIsOT3).calledWith(ROBOT_NAME).mockReturnValue(false) + + const { getAllByText } = render() + expect(getAllByText('Calibration ready').length).toEqual(1) + }) + it('renders module setup and allows the user to proceed to labware setup', () => { const { getByText } = render() const moduleSetup = getByText('Modules') diff --git a/app/src/organisms/Devices/RobotOverview.tsx b/app/src/organisms/Devices/RobotOverview.tsx index 483441f7d01..0b03bb8a43e 100644 --- a/app/src/organisms/Devices/RobotOverview.tsx +++ b/app/src/organisms/Devices/RobotOverview.tsx @@ -112,12 +112,7 @@ export function RobotOverview({ - {robot != null ? ( - - ) : null} + (false) const [ showChooseProtocolSlideout, setShowChooseProtocolSlideout, @@ -87,10 +83,6 @@ export const RobotOverviewOverflowMenu = ( dispatch(checkShellUpdate()) }) - const handleClickUpdateBuildroot: React.MouseEventHandler = () => { - setShowSoftwareUpdateModal(true) - } - const handleClickRun: React.MouseEventHandler = () => { setShowChooseProtocolSlideout(true) } @@ -105,14 +97,6 @@ export const RobotOverviewOverflowMenu = ( return ( - {showSoftwareUpdateModal && - robot != null && - robot.status !== UNREACHABLE ? ( - setShowSoftwareUpdateModal(false)} - /> - ) : null} {showDisconnectModal ? ( setShowDisconnectModal(false)} @@ -140,7 +124,7 @@ export const RobotOverviewOverflowMenu = ( > {isRobotOnWrongVersionOfSoftware && !isRobotUnavailable ? ( handleUpdateBuildroot(robot)} data-testid={`RobotOverviewOverflowMenu_updateSoftware_${String( robot.name )}`} diff --git a/app/src/organisms/Devices/RobotSettings/AdvancedTab/RobotServerVersion.tsx b/app/src/organisms/Devices/RobotSettings/AdvancedTab/RobotServerVersion.tsx index b13713b5be3..4ab2fa7d979 100644 --- a/app/src/organisms/Devices/RobotSettings/AdvancedTab/RobotServerVersion.tsx +++ b/app/src/organisms/Devices/RobotSettings/AdvancedTab/RobotServerVersion.tsx @@ -13,13 +13,12 @@ import { JUSTIFY_FLEX_END, } from '@opentrons/components' import { StyledText } from '../../../../atoms/text' -import { Portal } from '../../../../App/portal' import { TertiaryButton } from '../../../../atoms/buttons' -import { getRobotApiVersion, UNREACHABLE } from '../../../../redux/discovery' +import { getRobotApiVersion } from '../../../../redux/discovery' import { getRobotUpdateDisplayInfo } from '../../../../redux/robot-update' import { UpdateRobotBanner } from '../../../UpdateRobotBanner' import { useIsFlex, useRobot } from '../../hooks' -import { UpdateBuildroot } from '../UpdateBuildroot' +import { handleUpdateBuildroot } from '../UpdateBuildroot' import type { State } from '../../../../redux/types' @@ -36,7 +35,6 @@ export function RobotServerVersion({ const { t } = useTranslation(['device_settings', 'shared']) const robot = useRobot(robotName) const isFlex = useIsFlex(robotName) - const [showVersionInfoModal, setShowVersionInfoModal] = React.useState(false) const { autoUpdateAction } = useSelector((state: State) => { return getRobotUpdateDisplayInfo(state, robotName) }) @@ -46,14 +44,6 @@ export function RobotServerVersion({ return ( <> - {showVersionInfoModal && robot != null && robot.status !== UNREACHABLE ? ( - - setShowVersionInfoModal(false)} - /> - - ) : null} {autoUpdateAction !== 'reinstall' && robot != null ? ( @@ -98,7 +88,7 @@ export function RobotServerVersion({ {t('up_to_date')} setShowVersionInfoModal(true)} + onClick={() => handleUpdateBuildroot(robot)} textTransform={TYPOGRAPHY.textTransformCapitalize} > {t('reinstall')} diff --git a/app/src/organisms/Devices/RobotSettings/AdvancedTab/SoftwareUpdateModal.tsx b/app/src/organisms/Devices/RobotSettings/AdvancedTab/SoftwareUpdateModal.tsx index 5f61f63025e..d8d8b0b548b 100644 --- a/app/src/organisms/Devices/RobotSettings/AdvancedTab/SoftwareUpdateModal.tsx +++ b/app/src/organisms/Devices/RobotSettings/AdvancedTab/SoftwareUpdateModal.tsx @@ -21,7 +21,7 @@ import { LegacyModal } from '../../../../molecules/LegacyModal' import { CONNECTABLE, REACHABLE } from '../../../../redux/discovery' import { Divider } from '../../../../atoms/structure' import { useRobot } from '../../hooks' -import { UpdateBuildroot } from '../UpdateBuildroot' +import { handleUpdateBuildroot } from '../UpdateBuildroot' const TECHNICAL_CHANGE_LOG_URL = 'https://github.com/Opentrons/opentrons/blob/edge/CHANGELOG.md' @@ -50,22 +50,9 @@ export function SoftwareUpdateModal({ const [showUpdateModal, setShowUpdateModal] = React.useState(false) const robot = useRobot(robotName) - const handleCloseModal = (): void => { - setShowUpdateModal(false) - closeModal() - } - - const handleLaunchUpdateModal: React.MouseEventHandler = e => { - e.preventDefault() - e.stopPropagation() - setShowUpdateModal(true) - } - if (robot?.status !== CONNECTABLE && robot?.status !== REACHABLE) return null - return showUpdateModal ? ( - - ) : ( + return !showUpdateModal ? ( {t('requires_restarting_the_robot')} @@ -119,7 +106,10 @@ export function SoftwareUpdateModal({ {t('remind_me_later')} { + setShowUpdateModal(true) + handleUpdateBuildroot(robot) + }} disabled={currentRunId != null} > {t('update_robot_now')} @@ -127,5 +117,5 @@ export function SoftwareUpdateModal({ - ) + ) : null } diff --git a/app/src/organisms/Devices/RobotSettings/AdvancedTab/UpdateRobotSoftware.tsx b/app/src/organisms/Devices/RobotSettings/AdvancedTab/UpdateRobotSoftware.tsx index 700bcbeb91a..980d4c4f791 100644 --- a/app/src/organisms/Devices/RobotSettings/AdvancedTab/UpdateRobotSoftware.tsx +++ b/app/src/organisms/Devices/RobotSettings/AdvancedTab/UpdateRobotSoftware.tsx @@ -1,6 +1,6 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' -import { useSelector, useDispatch } from 'react-redux' +import { useSelector } from 'react-redux' import { css } from 'styled-components' import { @@ -18,12 +18,10 @@ import { StyledText } from '../../../../atoms/text' import { ExternalLink } from '../../../../atoms/Link/ExternalLink' import { TertiaryButton } from '../../../../atoms/buttons' import { Tooltip } from '../../../../atoms/Tooltip' -import { - getRobotUpdateDisplayInfo, - startRobotUpdate, -} from '../../../../redux/robot-update' +import { getRobotUpdateDisplayInfo } from '../../../../redux/robot-update' +import { useDispatchStartRobotUpdate } from '../../../../redux/robot-update/hooks' -import type { State, Dispatch } from '../../../../redux/types' +import type { State } from '../../../../redux/types' const OT_APP_UPDATE_PAGE_LINK = 'https://opentrons.com/ot-app/' const HIDDEN_CSS = css` @@ -43,18 +41,18 @@ export function UpdateRobotSoftware({ isRobotBusy, }: UpdateRobotSoftwareProps): JSX.Element { const { t } = useTranslation('device_settings') - const dispatch = useDispatch() const { updateFromFileDisabledReason } = useSelector((state: State) => { return getRobotUpdateDisplayInfo(state, robotName) }) const updateDisabled = updateFromFileDisabledReason !== null const [updateButtonProps, updateButtonTooltipProps] = useHoverTooltip() const inputRef = React.useRef(null) + const dispatchStartRobotUpdate = useDispatchStartRobotUpdate() const handleChange: React.ChangeEventHandler = event => { const { files } = event.target if (files?.length === 1 && !updateDisabled) { - dispatch(startRobotUpdate(robotName, files[0].path)) + dispatchStartRobotUpdate(robotName, files[0].path) onUpdateStart() } } diff --git a/app/src/organisms/Devices/RobotSettings/AdvancedTab/__tests__/RobotServerVersion.test.tsx b/app/src/organisms/Devices/RobotSettings/AdvancedTab/__tests__/RobotServerVersion.test.tsx index 63b15534638..c9e6fbed7af 100644 --- a/app/src/organisms/Devices/RobotSettings/AdvancedTab/__tests__/RobotServerVersion.test.tsx +++ b/app/src/organisms/Devices/RobotSettings/AdvancedTab/__tests__/RobotServerVersion.test.tsx @@ -7,7 +7,7 @@ import { getRobotApiVersion } from '../../../../../redux/discovery' import { getRobotUpdateDisplayInfo } from '../../../../../redux/robot-update' import { mockConnectableRobot } from '../../../../../redux/discovery/__fixtures__' import { useRobot } from '../../../hooks' -import { UpdateBuildroot } from '../../UpdateBuildroot' +import { handleUpdateBuildroot } from '../../UpdateBuildroot' import { RobotServerVersion } from '../RobotServerVersion' jest.mock('../../../hooks') @@ -24,8 +24,8 @@ const mockGetBuildrootUpdateDisplayInfo = getRobotUpdateDisplayInfo as jest.Mock const mockUseRobot = useRobot as jest.MockedFunction -const mockUpdateBuildroot = UpdateBuildroot as jest.MockedFunction< - typeof UpdateBuildroot +const mockUpdateBuildroot = handleUpdateBuildroot as jest.MockedFunction< + typeof handleUpdateBuildroot > const MOCK_ROBOT_VERSION = '7.7.7' @@ -40,7 +40,6 @@ const render = () => { describe('RobotSettings RobotServerVersion', () => { beforeEach(() => { - mockUpdateBuildroot.mockReturnValue(
mock update buildroot
) mockUseRobot.mockReturnValue(mockConnectableRobot) mockGetBuildrootUpdateDisplayInfo.mockReturnValue({ autoUpdateAction: 'reinstall', @@ -66,7 +65,7 @@ describe('RobotSettings RobotServerVersion', () => { getByText('up to date') const reinstall = getByRole('button', { name: 'reinstall' }) fireEvent.click(reinstall) - getByText('mock update buildroot') + expect(mockUpdateBuildroot).toHaveBeenCalled() }) it('should render the warning message if the robot server version needs to upgrade', () => { @@ -81,7 +80,7 @@ describe('RobotSettings RobotServerVersion', () => { ) const btn = getByText('View update') fireEvent.click(btn) - getByText('mock update buildroot') + expect(mockUpdateBuildroot).toHaveBeenCalled() }) it('should render the warning message if the robot server version needs to downgrade', () => { @@ -96,7 +95,7 @@ describe('RobotSettings RobotServerVersion', () => { ) const btn = getByText('View update') fireEvent.click(btn) - getByText('mock update buildroot') + expect(mockUpdateBuildroot).toHaveBeenCalled() }) it('the link should have the correct href', () => { diff --git a/app/src/organisms/Devices/RobotSettings/ConnectNetwork/DisconnectModal.tsx b/app/src/organisms/Devices/RobotSettings/ConnectNetwork/DisconnectModal.tsx index cc68dca6477..662dbdce9b5 100644 --- a/app/src/organisms/Devices/RobotSettings/ConnectNetwork/DisconnectModal.tsx +++ b/app/src/organisms/Devices/RobotSettings/ConnectNetwork/DisconnectModal.tsx @@ -22,6 +22,7 @@ import { LegacyModal } from '../../../../molecules/LegacyModal' import { useRobot } from '../../../../organisms/Devices/hooks' import { CONNECTABLE } from '../../../../redux/discovery' import { + clearWifiStatus, getNetworkInterfaces, postWifiDisconnect, } from '../../../../redux/networking' @@ -112,6 +113,12 @@ export const DisconnectModal = ({ disconnectModalBody = t('disconnect_from_wifi_network_failure', { ssid }) } + React.useEffect(() => { + if (isDisconnected) { + dispatch(clearWifiStatus(robotName)) + } + }, [isDisconnected]) + return ( const mockUseRobot = useRobot as jest.MockedFunction +const mockClearWifiStatus = clearWifiStatus as jest.MockedFunction< + typeof clearWifiStatus +> const ROBOT_NAME = 'otie' const LAST_ID = 'a request id' @@ -117,6 +121,7 @@ describe('DisconnectModal', () => { getByText('Disconnect from foo') getByText('Disconnecting from Wi-Fi network foo') getByRole('button', { name: 'cancel' }) + expect(mockClearWifiStatus).not.toHaveBeenCalled() }) it('renders success body when request is pending and robot is not connectable', () => { @@ -133,6 +138,7 @@ describe('DisconnectModal', () => { 'Your robot has successfully disconnected from the Wi-Fi network.' ) getByRole('button', { name: 'Done' }) + expect(mockClearWifiStatus).toHaveBeenCalled() }) it('renders success body when request is successful', () => { @@ -146,6 +152,7 @@ describe('DisconnectModal', () => { 'Your robot has successfully disconnected from the Wi-Fi network.' ) getByRole('button', { name: 'Done' }) + expect(mockClearWifiStatus).toHaveBeenCalled() }) it('renders success body when wifi is not connected', () => { @@ -162,6 +169,7 @@ describe('DisconnectModal', () => { 'Your robot has successfully disconnected from the Wi-Fi network.' ) getByRole('button', { name: 'Done' }) + expect(mockClearWifiStatus).toHaveBeenCalled() }) it('renders error body when request is unsuccessful', () => { @@ -181,6 +189,7 @@ describe('DisconnectModal', () => { ) getByRole('button', { name: 'cancel' }) getByRole('button', { name: 'Disconnect' }) + expect(mockClearWifiStatus).not.toHaveBeenCalled() }) it('dispatches postWifiDisconnect on click Disconnect', () => { diff --git a/app/src/organisms/Devices/RobotSettings/RobotSettingsAdvanced.tsx b/app/src/organisms/Devices/RobotSettings/RobotSettingsAdvanced.tsx index 9be35d2a2b7..1161c707b3a 100644 --- a/app/src/organisms/Devices/RobotSettings/RobotSettingsAdvanced.tsx +++ b/app/src/organisms/Devices/RobotSettings/RobotSettingsAdvanced.tsx @@ -38,7 +38,7 @@ import { import { RenameRobotSlideout } from './AdvancedTab/AdvancedTabSlideouts/RenameRobotSlideout' import { DeviceResetSlideout } from './AdvancedTab/AdvancedTabSlideouts/DeviceResetSlideout' import { DeviceResetModal } from './AdvancedTab/AdvancedTabSlideouts/DeviceResetModal' -import { UpdateBuildroot } from './UpdateBuildroot' +import { handleUpdateBuildroot } from './UpdateBuildroot' import { UNREACHABLE } from '../../../redux/discovery' import { Portal } from '../../../App/portal' @@ -70,10 +70,6 @@ export function RobotSettingsAdvanced({ showDeviceResetModal, setShowDeviceResetModal, ] = React.useState(false) - const [ - showSoftwareUpdateModal, - setShowSoftwareUpdateModal, - ] = React.useState(false) const isRobotBusy = useIsRobotBusy({ poll: true }) @@ -124,14 +120,6 @@ export function RobotSettingsAdvanced({ return ( <> - {showSoftwareUpdateModal && - robot != null && - robot.status !== UNREACHABLE ? ( - setShowSoftwareUpdateModal(false)} - /> - ) : null} {showRenameRobotSlideout && ( setShowSoftwareUpdateModal(true)} + onUpdateStart={() => handleUpdateBuildroot(robot)} /> diff --git a/app/src/organisms/Devices/RobotSettings/RobotSettingsFeatureFlags.tsx b/app/src/organisms/Devices/RobotSettings/RobotSettingsFeatureFlags.tsx index ffd2f00ca30..e538b099559 100644 --- a/app/src/organisms/Devices/RobotSettings/RobotSettingsFeatureFlags.tsx +++ b/app/src/organisms/Devices/RobotSettings/RobotSettingsFeatureFlags.tsx @@ -35,6 +35,7 @@ const NON_FEATURE_FLAG_SETTINGS = [ 'useOldAspirationFunctions', 'disableLogAggregation', 'disableFastProtocolUpload', + 'disableStatusBar', ] export function RobotSettingsFeatureFlags({ diff --git a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/RobotUpdateProgressModal.tsx b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/RobotUpdateProgressModal.tsx index 31ca9616b58..c815dcf3ab3 100644 --- a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/RobotUpdateProgressModal.tsx +++ b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/RobotUpdateProgressModal.tsx @@ -1,11 +1,12 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' -import { useDispatch } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import { css } from 'styled-components' import { Flex, Icon, + Link, NewPrimaryBtn, NewSecondaryBtn, JUSTIFY_FLEX_END, @@ -22,86 +23,182 @@ import { LegacyModal } from '../../../../molecules/LegacyModal' import { ProgressBar } from '../../../../atoms/ProgressBar' import { FOOTER_BUTTON_STYLE } from './UpdateRobotModal' import { - clearRobotUpdateSession, startRobotUpdate, + clearRobotUpdateSession, + getRobotSessionIsManualFile, } from '../../../../redux/robot-update' +import { useDispatchStartRobotUpdate } from '../../../../redux/robot-update/hooks' +import { useRobotUpdateInfo } from './useRobotUpdateInfo' import successIcon from '../../../../assets/images/icon_success.png' +import type { State } from '../../../../redux/types' import type { SetStatusBarCreateCommand } from '@opentrons/shared-data/protocol/types/schemaV7/command/incidental' -import type { Dispatch } from '../../../../redux/types' -import type { UpdateStep } from '.' -import type { RobotUpdateAction } from '../../../../redux/robot-update/types' +import type { RobotUpdateSession } from '../../../../redux/robot-update/types' +import type { UpdateStep } from './useRobotUpdateInfo' -interface SuccessOrErrorProps { - errorMessage?: string | null +const UPDATE_PROGRESS_BAR_STYLE = css` + margin-top: ${SPACING.spacing24}; + margin-bottom: ${SPACING.spacing24}; + border-radius: ${BORDERS.borderRadiusSize3}; + background: ${COLORS.medGreyEnabled}; + width: 17.12rem; +` +const UPDATE_TEXT_STYLE = css` + color: ${COLORS.darkGreyEnabled}; + font-size: 0.8rem; +` +const TRY_RESTART_STYLE = css` + color: ${COLORS.blueEnabled}; + font-size: 0.8rem; +` +const HIDDEN_CSS = css` + position: fixed; + clip: rect(1px 1px 1px 1px); +` + +interface RobotUpdateProgressModalProps { + robotName: string + session: RobotUpdateSession | null + closeUpdateBuildroot?: () => void } -function SuccessOrError({ errorMessage }: SuccessOrErrorProps): JSX.Element { +export function RobotUpdateProgressModal({ + robotName, + session, + closeUpdateBuildroot, +}: RobotUpdateProgressModalProps): JSX.Element { + const dispatch = useDispatch() const { t } = useTranslation('device_settings') - const IMAGE_ALT = 'Welcome screen background image' - let renderedImg: JSX.Element - if (!errorMessage) - renderedImg = ( - {IMAGE_ALT} - ) - else - renderedImg = ( - - ) + const [showFileSelect, setShowFileSelect] = React.useState(false) + const installFromFileRef = React.useRef(null) + const dispatchStartRobotUpdate = useDispatchStartRobotUpdate() + const manualFileUsedForUpdate = useSelector((state: State) => + getRobotSessionIsManualFile(state) + ) + const completeRobotUpdateHandler = (): void => { + if (closeUpdateBuildroot != null) closeUpdateBuildroot() + } + const reinstallUpdate = React.useCallback(() => { + dispatchStartRobotUpdate(robotName) + }, [robotName]) + + const { error } = session || { error: null } + const { updateStep, progressPercent } = useRobotUpdateInfo(session) + useStatusBarAnimation(error != null) + useCleanupRobotUpdateSessionOnDismount() + + const handleFileSelect: React.ChangeEventHandler = event => { + const { files } = event.target + if (files?.length === 1) { + dispatch(startRobotUpdate(robotName, files[0].path)) + } + setShowFileSelect(false) + } + React.useEffect(() => { + if (showFileSelect && installFromFileRef.current) + installFromFileRef.current.click() + }, [showFileSelect]) + + const hasStoppedUpdating = error || updateStep === 'finished' + const letUserExitUpdate = useAllowExitIfUpdateStalled( + updateStep, + progressPercent + ) + + let modalBodyText = t('installing_update') + let subProgressBarText = t('do_not_turn_off') + if (updateStep === 'restart') modalBodyText = t('restarting_robot') + if (updateStep === 'restart' && letUserExitUpdate) { + subProgressBarText = t('restart_taking_too_long', { robotName }) + } return ( - <> - {renderedImg} - - {!errorMessage ? t('robot_update_success') : errorMessage} - - + + ) : null + } + > + {hasStoppedUpdating ? ( + + + + ) : ( + + {modalBodyText} + + + {letUserExitUpdate && updateStep !== 'restart' ? ( + <> + {t('problem_during_update')}{' '} + setShowFileSelect(true) + } + > + {t('try_restarting_the_update')} + + {showFileSelect && ( + + )} + + ) : ( + subProgressBarText + )} + + + )} + ) } interface RobotUpdateProgressFooterProps { robotName: string + installRobotUpdate: (robotName: string) => void errorMessage?: string | null closeUpdateBuildroot?: () => void } function RobotUpdateProgressFooter({ robotName, + installRobotUpdate, errorMessage, closeUpdateBuildroot, }: RobotUpdateProgressFooterProps): JSX.Element { const { t } = useTranslation('device_settings') - const dispatch = useDispatch() const installUpdate = React.useCallback(() => { - dispatch(clearRobotUpdateSession()) - dispatch(startRobotUpdate(robotName)) + installRobotUpdate(robotName) }, [robotName]) - const { createLiveCommand } = useCreateLiveCommandMutation() - const idleCommand: SetStatusBarCreateCommand = { - commandType: 'setStatusBar', - params: { animation: 'idle' }, - } - - // Called if the update fails - const startIdleAnimationIfFailed = (): void => { - if (errorMessage) { - createLiveCommand({ - command: idleCommand, - waitUntilComplete: false, - }).catch((e: Error) => - console.warn(`cannot run status bar animation: ${e.message}`) - ) - } - } - - React.useEffect(startIdleAnimationIfFailed, []) - return ( {errorMessage && ( @@ -125,39 +222,87 @@ function RobotUpdateProgressFooter({ ) } -interface RobotUpdateProgressModalProps { - robotName: string - updateStep: UpdateStep - stepProgress: number | null - error?: string | null - closeUpdateBuildroot?: () => void +interface SuccessOrErrorProps { + errorMessage?: string | null } -export function RobotUpdateProgressModal({ - robotName, - updateStep, - stepProgress, - error, - closeUpdateBuildroot, -}: RobotUpdateProgressModalProps): JSX.Element { +function SuccessOrError({ errorMessage }: SuccessOrErrorProps): JSX.Element { const { t } = useTranslation('device_settings') - const dispatch = useDispatch() - const progressPercent = React.useRef(0) - const [previousUpdateStep, setPreviousUpdateStep] = React.useState< - string | null - >(null) - const completeRobotUpdateHandler = (): RobotUpdateAction => { - if (closeUpdateBuildroot != null) closeUpdateBuildroot() - return dispatch(clearRobotUpdateSession()) - } + const IMAGE_ALT = 'Welcome screen background image' + let renderedImg: JSX.Element + if (!errorMessage) + renderedImg = ( + {IMAGE_ALT} + ) + else + renderedImg = ( + + ) + return ( + <> + {renderedImg} + + {!errorMessage ? t('robot_update_success') : errorMessage} + + + ) +} + +export const TIME_BEFORE_ALLOWING_EXIT_MS = 600000 // 10 mins + +function useAllowExitIfUpdateStalled( + updateStep: UpdateStep, + progressPercent: number +): boolean { + const [letUserExitUpdate, setLetUserExitUpdate] = React.useState( + false + ) + const prevSeenUpdateProgress = React.useRef(null) + const exitTimeoutRef = React.useRef(null) + + React.useEffect(() => { + if (updateStep === 'initial' && prevSeenUpdateProgress.current !== null) { + prevSeenUpdateProgress.current = null + } else if (updateStep === 'finished' && exitTimeoutRef.current) { + clearTimeout(exitTimeoutRef.current) + setLetUserExitUpdate(false) + } else if (progressPercent !== prevSeenUpdateProgress.current) { + if (exitTimeoutRef.current) clearTimeout(exitTimeoutRef.current) + exitTimeoutRef.current = setTimeout(() => { + setLetUserExitUpdate(true) + }, TIME_BEFORE_ALLOWING_EXIT_MS) + + prevSeenUpdateProgress.current = progressPercent + setLetUserExitUpdate(false) + } + }, [progressPercent, updateStep]) + + React.useEffect(() => { + return () => { + if (exitTimeoutRef.current) clearTimeout(exitTimeoutRef.current) + } + }, []) + + return letUserExitUpdate +} + +function useStatusBarAnimation(isError: boolean): void { const { createLiveCommand } = useCreateLiveCommandMutation() const updatingCommand: SetStatusBarCreateCommand = { commandType: 'setStatusBar', params: { animation: 'updating' }, } + const idleCommand: SetStatusBarCreateCommand = { + commandType: 'setStatusBar', + params: { animation: 'idle' }, + } - // Called when the first step of the update begins const startUpdatingAnimation = (): void => { createLiveCommand({ command: updatingCommand, @@ -167,83 +312,26 @@ export function RobotUpdateProgressModal({ ) } - let modalBodyText = t('installing_update') - if (updateStep === 'restart') modalBodyText = t('restarting_robot') + const startIdleAnimationIfFailed = (): void => { + if (isError) { + createLiveCommand({ + command: idleCommand, + waitUntilComplete: false, + }).catch((e: Error) => + console.warn(`cannot run status bar animation: ${e.message}`) + ) + } + } - // Make sure to start the animation when this modal first pops up React.useEffect(startUpdatingAnimation, []) + React.useEffect(startIdleAnimationIfFailed, [isError]) +} - // Account for update methods that do not require download & decreasing percent oddities. +function useCleanupRobotUpdateSessionOnDismount(): void { + const dispatch = useDispatch() React.useEffect(() => { - const explicitStepProgress = stepProgress || 0 - if (previousUpdateStep === null) { - if (updateStep === 'install') - progressPercent.current = Math.max( - progressPercent.current, - explicitStepProgress - ) - else if (updateStep === 'download') { - progressPercent.current = Math.max( - progressPercent.current, - Math.floor(explicitStepProgress / 2) - ) - if (progressPercent.current === 50) setPreviousUpdateStep('download') - } else progressPercent.current = 100 - } else { - progressPercent.current = Math.max( - progressPercent.current, - 50 + Math.floor(explicitStepProgress / 2) - ) + return () => { + dispatch(clearRobotUpdateSession()) } - }, [updateStep, stepProgress, previousUpdateStep]) - - const completedUpdating = error || updateStep === 'finished' - - const UPDATE_PROGRESS_BAR_STYLE = css` - margin-top: ${SPACING.spacing24}; - margin-bottom: ${SPACING.spacing24}; - border-radius: ${BORDERS.borderRadiusSize3}; - background: ${COLORS.medGreyEnabled}; - ` - const dontTurnOffMessage = css` - color: ${COLORS.darkGreyEnabled}; - ` - - return ( - - ) : null - } - > - {completedUpdating ? ( - - - - ) : ( - - {modalBodyText} - - - {t('do_not_turn_off')} - - - )} - - ) + }, []) } diff --git a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/UpdateRobotModal.tsx b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/UpdateRobotModal.tsx index 52a17eee2a9..674c30a480c 100644 --- a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/UpdateRobotModal.tsx +++ b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/UpdateRobotModal.tsx @@ -18,7 +18,6 @@ import { import { getRobotUpdateDisplayInfo, robotUpdateChangelogSeen, - startRobotUpdate, OT2_BALENA, UPGRADE, REINSTALL, @@ -29,6 +28,7 @@ import { useIsRobotBusy } from '../../hooks' import { Tooltip } from '../../../../atoms/Tooltip' import { LegacyModal } from '../../../../molecules/LegacyModal' import { Banner } from '../../../../atoms/Banner' +import { useDispatchStartRobotUpdate } from '../../../../redux/robot-update/hooks' import type { State, Dispatch } from '../../../../redux/types' import type { RobotSystemType } from '../../../../redux/robot-update/types' @@ -72,6 +72,7 @@ export function UpdateRobotModal({ const { updateFromFileDisabledReason } = useSelector((state: State) => { return getRobotUpdateDisplayInfo(state, robotName) }) + const dispatchStartRobotUpdate = useDispatchStartRobotUpdate() const isRobotBusy = useIsRobotBusy() const updateDisabled = updateFromFileDisabledReason !== null || isRobotBusy @@ -106,7 +107,7 @@ export function UpdateRobotModal({ {updateType === UPGRADE ? t('remind_me_later') : t('not_now')} dispatch(startRobotUpdate(robotName))} + onClick={() => dispatchStartRobotUpdate(robotName)} marginRight={SPACING.spacing12} css={FOOTER_BUTTON_STYLE} disabled={updateDisabled} diff --git a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/ViewUpdateModal.tsx b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/ViewUpdateModal.tsx index 1ed4e328e33..7b2207f0bb2 100644 --- a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/ViewUpdateModal.tsx +++ b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/ViewUpdateModal.tsx @@ -4,43 +4,40 @@ import { useSelector } from 'react-redux' import { OT2_BALENA, getRobotUpdateInfo, - getRobotUpdateDownloadProgress, getRobotUpdateDownloadError, + getRobotSystemType, + getRobotUpdateAvailable, } from '../../../../redux/robot-update' import { getAvailableShellUpdate } from '../../../../redux/shell' import { Portal } from '../../../../App/portal' import { UpdateAppModal } from '../../../../organisms/UpdateAppModal' import { MigrationWarningModal } from './MigrationWarningModal' -import { RobotUpdateProgressModal } from './RobotUpdateProgressModal' import { UpdateRobotModal } from './UpdateRobotModal' -import type { - RobotUpdateType, - RobotSystemType, -} from '../../../../redux/robot-update/types' import type { State } from '../../../../redux/types' +import { ReachableRobot, Robot } from '../../../../redux/discovery/types' export interface ViewUpdateModalProps { robotName: string - robotUpdateType: RobotUpdateType | null - robotSystemType: RobotSystemType | null + robot: Robot | ReachableRobot closeModal: () => void } export function ViewUpdateModal( props: ViewUpdateModalProps ): JSX.Element | null { - const { robotName, robotUpdateType, robotSystemType, closeModal } = props + const { robotName, robot, closeModal } = props const updateInfo = useSelector((state: State) => getRobotUpdateInfo(state, robotName) ) - const downloadProgress = useSelector((state: State) => - getRobotUpdateDownloadProgress(state, robotName) - ) const downloadError = useSelector((state: State) => getRobotUpdateDownloadError(state, robotName) ) + const robotUpdateType = useSelector((state: State) => + getRobotUpdateAvailable(state, robot) + ) + const robotSystemType = getRobotSystemType(robot) const availableAppUpdateVersion = useSelector(getAvailableShellUpdate) const [ @@ -73,16 +70,6 @@ export function ViewUpdateModal( ) } - if (updateInfo === null) - return ( - - ) - if (robotSystemType != null) return ( +const mockUseRobotUpdateInfo = useRobotUpdateInfo as jest.MockedFunction< + typeof useRobotUpdateInfo +> +const mockGetRobotSessionIsManualFile = getRobotSessionIsManualFile as jest.MockedFunction< + typeof getRobotSessionIsManualFile +> +const mockUseDispatchStartRobotUpdate = useDispatchStartRobotUpdate as jest.MockedFunction< + typeof useDispatchStartRobotUpdate +> const render = ( props: React.ComponentProps @@ -24,22 +42,36 @@ const render = ( } describe('DownloadUpdateModal', () => { + const mockRobotUpdateSession: RobotUpdateSession | null = { + robotName: 'testRobot', + fileInfo: null, + token: null, + pathPrefix: null, + step: 'getToken', + stage: 'validating', + progress: 50, + error: null, + } + let props: React.ComponentProps - let mockCreateLiveCommand = jest.fn() + const mockCreateLiveCommand = jest.fn() beforeEach(() => { - mockCreateLiveCommand = jest.fn() mockCreateLiveCommand.mockResolvedValue(null) props = { robotName: 'testRobot', - updateStep: 'download', - error: null, - stepProgress: 50, + session: mockRobotUpdateSession, closeUpdateBuildroot: jest.fn(), } mockUseCreateLiveCommandMutation.mockReturnValue({ createLiveCommand: mockCreateLiveCommand, } as any) + mockUseRobotUpdateInfo.mockReturnValue({ + updateStep: 'install', + progressPercent: 50, + }) + mockGetRobotSessionIsManualFile.mockReturnValue(false) + mockUseDispatchStartRobotUpdate.mockReturnValue(jest.fn) }) afterEach(() => { @@ -67,42 +99,36 @@ describe('DownloadUpdateModal', () => { }) it('renders the correct text when installing the robot update with no close button', () => { - props = { - ...props, - updateStep: 'install', - } - const [{ queryByRole, getByText }] = render(props) expect(getByText('Installing update...')).toBeInTheDocument() expect( - getByText('Do not turn off the robot while updating') + getByText("This could take up to 15 minutes. Don't turn off the robot.") ).toBeInTheDocument() expect(queryByRole('button')).not.toBeInTheDocument() }) it('renders the correct text when finalizing the robot update with no close button', () => { - props = { - ...props, + mockUseRobotUpdateInfo.mockReturnValue({ updateStep: 'restart', - } - + progressPercent: 100, + }) const [{ queryByRole, getByText }] = render(props) expect( getByText('Install complete, robot restarting...') ).toBeInTheDocument() expect( - getByText('Do not turn off the robot while updating') + getByText("This could take up to 15 minutes. Don't turn off the robot.") ).toBeInTheDocument() expect(queryByRole('button')).not.toBeInTheDocument() }) it('renders a success modal and exit button upon finishing the update process', () => { - props = { - ...props, + mockUseRobotUpdateInfo.mockReturnValue({ updateStep: 'finished', - } + progressPercent: 100, + }) const [{ getByText }] = render(props) const exitButton = getByText('exit') @@ -115,16 +141,19 @@ describe('DownloadUpdateModal', () => { }) it('renders an error modal and exit button if an error occurs', () => { + props = { + ...props, + session: { + ...mockRobotUpdateSession, + error: 'test error', + }, + } const idleCommand: SetStatusBarCreateCommand = { commandType: 'setStatusBar', params: { animation: 'idle' }, } - props = { - ...props, - error: 'test error', - } - const [{ getByText }] = render(props) + const [{ getByText }] = render(props) const exitButton = getByText('exit') expect(getByText('test error')).toBeInTheDocument() @@ -139,4 +168,15 @@ describe('DownloadUpdateModal', () => { waitUntilComplete: false, }) }) + + it('renders alternative text if update takes too long', () => { + const [{ findByText }] = render(props) + + act(() => { + jest.advanceTimersByTime(TIME_BEFORE_ALLOWING_EXIT_MS) + }) + + findByText('Try restarting the update.') + findByText('testRobot restart is taking longer than expected to restart.') + }) }) diff --git a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/__tests__/UpdateBuildroot.test.tsx b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/__tests__/UpdateBuildroot.test.tsx index 98ac277b92d..b7e3d475409 100644 --- a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/__tests__/UpdateBuildroot.test.tsx +++ b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/__tests__/UpdateBuildroot.test.tsx @@ -1,10 +1,11 @@ import React from 'react' +import NiceModal from '@ebay/nice-modal-react' import { mockConnectableRobot as mockRobot } from '../../../../../redux/discovery/__fixtures__' import * as RobotUpdate from '../../../../../redux/robot-update' import { mountWithStore, WrapperWithStore } from '@opentrons/components' -import { UpdateBuildroot } from '..' +import { handleUpdateBuildroot } from '..' import { ViewUpdateModal } from '../ViewUpdateModal' import { RobotUpdateProgressModal } from '../RobotUpdateProgressModal' @@ -33,12 +34,16 @@ const getRobotSystemType = RobotUpdate.getRobotSystemType as jest.MockedFunction const MOCK_STATE: State = { mockState: true } as any describe('UpdateBuildroot', () => { - const closeModal = jest.fn() const render = (): WrapperWithStore< - React.ComponentProps + React.ComponentProps > => { - return mountWithStore>( - , + return mountWithStore>( + +