diff --git a/.github/workflows/app-test-build-deploy.yaml b/.github/workflows/app-test-build-deploy.yaml index 8c3bd21503d..d3c03e1f500 100644 --- a/.github/workflows/app-test-build-deploy.yaml +++ b/.github/workflows/app-test-build-deploy.yaml @@ -97,7 +97,11 @@ jobs: strategy: matrix: os: ['windows-2022', 'ubuntu-22.04', 'macos-latest'] - name: 'opentrons app backend unit tests on ${{matrix.os}}' + shell: ['app-shell', 'app-shell-odd', 'discovery-client'] + exclude: + - os: 'windows-2022' + shell: 'app-shell-odd' + name: 'opentrons ${{matrix.shell}} unit tests on ${{matrix.os}}' timeout-minutes: 60 runs-on: ${{ matrix.os }} steps: @@ -144,7 +148,7 @@ jobs: yarn config set cache-folder ${{ github.workspace }}/.yarn-cache make setup-js - name: 'test native(er) packages' - run: make test-js-internal tests="app-shell/src app-shell-odd/src discovery-client/src" cov_opts="--coverage=true" + run: make test-js-internal tests="${{}matrix.shell}/src" cov_opts="--coverage=true" - name: 'Upload coverage report' uses: 'codecov/codecov-action@v3' with: diff --git a/.github/workflows/opentrons-ai-client-staging-continuous-deploy.yaml b/.github/workflows/opentrons-ai-client-staging-continuous-deploy.yaml index af767b36adc..7a89bfa02dd 100644 --- a/.github/workflows/opentrons-ai-client-staging-continuous-deploy.yaml +++ b/.github/workflows/opentrons-ai-client-staging-continuous-deploy.yaml @@ -52,6 +52,9 @@ jobs: yarn config set cache-folder ${{ github.workspace }}/.yarn-cache make setup-js - name: 'build' + env: + # inject dev id since this is for staging + OT_AI_CLIENT_MIXPANEL_ID: ${{ secrets.OT_AI_CLIENT_MIXPANEL_DEV_ID }} run: | make -C opentrons-ai-client build-staging - name: Configure AWS Credentials diff --git a/.github/workflows/opentrons-ai-client-test-build-deploy.yaml b/.github/workflows/opentrons-ai-client-test.yaml similarity index 90% rename from .github/workflows/opentrons-ai-client-test-build-deploy.yaml rename to .github/workflows/opentrons-ai-client-test.yaml index 2f569d9bf78..2c5cc6cfc64 100644 --- a/.github/workflows/opentrons-ai-client-test-build-deploy.yaml +++ b/.github/workflows/opentrons-ai-client-test.yaml @@ -9,12 +9,9 @@ on: paths: - 'Makefile' - 'opentrons-ai-client/**/*' - - 'components/**/*' - - '*.js' - - '*.json' - - 'yarn.lock' - - '.github/workflows/app-test-build-deploy.yaml' - - '.github/workflows/utils.js' + - 'components/**' + - 'shared-data/**' + - '.github/workflows/opentrons-ai-client-test.yml' branches: - '**' tags: @@ -24,10 +21,9 @@ on: paths: - 'Makefile' - 'opentrons-ai-client/**/*' - - 'components/**/*' - - '*.js' - - '*.json' - - 'yarn.lock' + - 'components/**' + - 'shared-data/**' + - '.github/workflows/opentrons-ai-client-test.yml' workflow_dispatch: concurrency: diff --git a/.github/workflows/opentrons-ai-production-deploy.yaml b/.github/workflows/opentrons-ai-production-deploy.yaml index 825c3561f25..2327b48ecad 100644 --- a/.github/workflows/opentrons-ai-production-deploy.yaml +++ b/.github/workflows/opentrons-ai-production-deploy.yaml @@ -52,6 +52,8 @@ jobs: yarn config set cache-folder ${{ github.workspace }}/.yarn-cache make setup-js - name: 'build' + env: + OT_AI_CLIENT_MIXPANEL_ID: ${{ secrets.OT_AI_CLIENT_MIXPANEL_ID }} run: | make -C opentrons-ai-client build-production - name: Configure AWS Credentials diff --git a/abr-testing/Makefile b/abr-testing/Makefile index f711579ff57..b9f92229177 100644 --- a/abr-testing/Makefile +++ b/abr-testing/Makefile @@ -88,3 +88,14 @@ push-no-restart-ot3: sdist Pipfile.lock .PHONY: push-ot3 push-ot3: push-no-restart-ot3 + +.PHONY: abr-setup +abr-setup: + $(python) abr_testing/tools/abr_setup.py + +.PHONY: simulate +PROTOCOL_DIR := abr_testing/protocols +SIMULATION_TOOL := protocol_simulation/abr_sim_check.py +EXTENSION := .py +simulate: + $(python) $(SIMULATION_TOOL) \ No newline at end of file diff --git a/abr-testing/abr_testing/automation/google_sheets_tool.py b/abr-testing/abr_testing/automation/google_sheets_tool.py index 3ca3bd38f9b..d284a13a241 100644 --- a/abr-testing/abr_testing/automation/google_sheets_tool.py +++ b/abr-testing/abr_testing/automation/google_sheets_tool.py @@ -167,6 +167,7 @@ def column_letter_to_index(column_letter: str) -> int: self.spread_sheet.batch_update(body=body) except gspread.exceptions.APIError as e: print(f"ERROR MESSAGE: {e}") + raise def update_cell( self, sheet_title: str, row: int, column: int, single_data: Any diff --git a/abr-testing/abr_testing/data_collection/abr_calibration_logs.py b/abr-testing/abr_testing/data_collection/abr_calibration_logs.py index 82d9d9c45bc..f25c89d8435 100644 --- a/abr-testing/abr_testing/data_collection/abr_calibration_logs.py +++ b/abr-testing/abr_testing/data_collection/abr_calibration_logs.py @@ -1,129 +1,327 @@ """Get Calibration logs from robots.""" -from typing import Dict, Any, List, Union +from typing import Dict, Any, List, Set import argparse import os import json import sys -import time as t +import traceback from abr_testing.data_collection import read_robot_logs from abr_testing.automation import google_drive_tool, google_sheets_tool -def check_for_duplicates( - sheet_location: str, - google_sheet: Any, - col_1: int, - col_2: int, - row: List[str], - headers: List[str], -) -> Union[List[str], None]: - """Check google sheet for duplicates.""" - t.sleep(5) - serials = google_sheet.get_column(col_1) - modify_dates = google_sheet.get_column(col_2) - # Check for calibration time stamp. - if row[-1] is not None: - if len(row[-1]) > 0: - for serial, modify_date in zip(serials, modify_dates): - if row[col_1 - 1] == serial and row[col_2 - 1] == modify_date: - print( - f"Skipped row for instrument {serial}. Already on Google Sheet." - ) - return None - read_robot_logs.write_to_sheets(sheet_location, google_sheet, row, headers) - print(f"Writing calibration for: {row[7]}") - return row - - -def upload_calibration_offsets( - calibration: Dict[str, Any], storage_directory: str -) -> None: - """Upload calibration data to google_sheet.""" - # Common Headers - headers_beg = list(calibration.keys())[:4] - headers_end = list(["X", "Y", "Z", "lastModified"]) +def instrument_helper( + headers_beg: List[str], + headers_end: List[str], + calibration_log: Dict[Any, Any], + google_sheet_name: str, + inst_sheet_serials: Set[str], + inst_sheet_modify_dates: Set[str], + storage_directory: str, +) -> List[Any]: + """Helper for parsing instrument calibration data.""" + # Populate Instruments # INSTRUMENT SHEET + instruments_upload_rows: List[Any] = [] instrument_headers = ( - headers_beg + list(calibration["Instruments"][0].keys())[:7] + headers_end + headers_beg + list(calibration_log["Instruments"][0].keys())[:7] + headers_end ) local_instrument_file = google_sheet_name + "-Instruments" - instrument_sheet_location = read_robot_logs.create_abr_data_sheet( + read_robot_logs.create_abr_data_sheet( storage_directory, local_instrument_file, instrument_headers ) # INSTRUMENTS DATA - instruments = calibration["Instruments"] + instruments = calibration_log["Instruments"] for instrument in range(len(instruments)): one_instrument = instruments[instrument] + inst_serial = one_instrument["serialNumber"] + modified = one_instrument["data"]["calibratedOffset"].get("last_modified", "") + if inst_serial in inst_sheet_serials and modified in inst_sheet_modify_dates: + continue x = one_instrument["data"]["calibratedOffset"]["offset"].get("x", "") y = one_instrument["data"]["calibratedOffset"]["offset"].get("y", "") z = one_instrument["data"]["calibratedOffset"]["offset"].get("z", "") - modified = one_instrument["data"]["calibratedOffset"].get("last_modified", "") instrument_row = ( - list(calibration.values())[:4] + list(calibration_log.values())[:4] + list(one_instrument.values())[:7] + list([x, y, z, modified]) ) - check_for_duplicates( - instrument_sheet_location, - google_sheet_instruments, - 8, - 15, - instrument_row, - instrument_headers, - ) + instruments_upload_rows.append(instrument_row) + return instruments_upload_rows + +def module_helper( + headers_beg: List[str], + headers_end: List[str], + calibration_log: Dict[Any, Any], + google_sheet_name: str, + module_sheet_serials: Set[str], + module_modify_dates: Set[str], + storage_directory: str, +) -> List[Any]: + """Helper for parsing module calibration data.""" + # Populate Modules # MODULE SHEET - if len(calibration.get("Modules", "")) > 0: + modules_upload_rows: List[Any] = [] + if len(calibration_log.get("Modules", "")) > 0: module_headers = ( - headers_beg + list(calibration["Modules"][0].keys())[:7] + headers_end + headers_beg + list(calibration_log["Modules"][0].keys())[:7] + headers_end ) local_modules_file = google_sheet_name + "-Modules" - modules_sheet_location = read_robot_logs.create_abr_data_sheet( + read_robot_logs.create_abr_data_sheet( storage_directory, local_modules_file, module_headers ) # MODULES DATA - modules = calibration["Modules"] + modules = calibration_log["Modules"] for module in range(len(modules)): one_module = modules[module] - x = one_module["moduleOffset"]["offset"].get("x", "") - y = one_module["moduleOffset"]["offset"].get("y", "") - z = one_module["moduleOffset"]["offset"].get("z", "") - modified = one_module["moduleOffset"].get("last_modified", "") + mod_serial = one_module["serialNumber"] + modified = "No data" + x = "" + y = "" + z = "" + try: + modified = one_module["moduleOffset"].get("last_modified", "") + x = one_module["moduleOffset"]["offset"].get("x", "") + y = one_module["moduleOffset"]["offset"].get("y", "") + z = one_module["moduleOffset"]["offset"].get("z", "") + except KeyError: + pass + if mod_serial in module_sheet_serials and modified in module_modify_dates: + continue module_row = ( - list(calibration.values())[:4] + list(calibration_log.values())[:4] + list(one_module.values())[:7] + list([x, y, z, modified]) ) - check_for_duplicates( - modules_sheet_location, - google_sheet_modules, - 8, - 15, - module_row, - module_headers, - ) + modules_upload_rows.append(module_row) + return modules_upload_rows + + +def deck_helper( + headers_beg: List[str], + headers_end: List[str], + calibration_log: Dict[Any, Any], + google_sheet_name: str, + deck_sheet_serials: Set[str], + deck_sheet_modify_dates: Set[str], + storage_directory: str, +) -> List[Any]: + """Helper for parsing deck calibration data.""" + deck_upload_rows: List[Any] = [] + # Populate Deck # DECK SHEET local_deck_file = google_sheet_name + "-Deck" deck_headers = headers_beg + list(["pipetteCalibratedWith", "Slot"]) + headers_end - deck_sheet_location = read_robot_logs.create_abr_data_sheet( + read_robot_logs.create_abr_data_sheet( storage_directory, local_deck_file, deck_headers ) # DECK DATA - deck = calibration["Deck"] - slots = ["D3", "D1", "A1"] + deck = calibration_log["Deck"] deck_modified = deck["data"].get("lastModified", "") + slots = ["D3", "D1", "A1"] pipette_calibrated_with = deck["data"].get("pipetteCalibratedWith", "") for i in range(len(deck["data"]["matrix"])): + if slots[i] in deck_sheet_serials and deck_modified in deck_sheet_modify_dates: + continue coords = deck["data"]["matrix"][i] x = coords[0] y = coords[1] z = coords[2] - deck_row = list(calibration.values())[:4] + list( + deck_row = list(calibration_log.values())[:4] + list( [pipette_calibrated_with, slots[i], x, y, z, deck_modified] ) - check_for_duplicates( - deck_sheet_location, google_sheet_deck, 6, 10, deck_row, deck_headers + deck_upload_rows.append(deck_row) + return deck_upload_rows + + +def send_batch_update( + instruments_upload_rows: List[str], + google_sheet_instruments: google_sheets_tool.google_sheet, + modules_upload_rows: List[str], + google_sheet_modules: google_sheets_tool.google_sheet, + deck_upload_rows: List[str], + google_sheet_deck: google_sheets_tool.google_sheet, +) -> None: + """Executes batch updates.""" + # Prepare for batch updates + try: + transposed_instruments_upload_rows = list( + map(list, zip(*instruments_upload_rows)) + ) + google_sheet_instruments.batch_update_cells( + transposed_instruments_upload_rows, + "A", + google_sheet_instruments.get_index_row() + 1, + "0", + ) + except Exception: + print("No new instrument data") + try: + transposed_module_upload_rows = list(map(list, zip(*modules_upload_rows))) + google_sheet_modules.batch_update_cells( + transposed_module_upload_rows, + "A", + google_sheet_modules.get_index_row() + 1, + "1020695883", + ) + except Exception: + print("No new module data") + try: + transposed_deck_upload_rows = list(map(list, zip(*deck_upload_rows))) + google_sheet_deck.batch_update_cells( + transposed_deck_upload_rows, + "A", + google_sheet_deck.get_index_row() + 1, + "1332568460", + ) + except Exception: + print("No new deck data") + + +def upload_calibration_offsets( + calibration_data: List[Dict[str, Any]], + storage_directory: str, + google_sheet_instruments: google_sheets_tool.google_sheet, + google_sheet_modules: google_sheets_tool.google_sheet, + google_sheet_deck: google_sheets_tool.google_sheet, + google_sheet_name: str, +) -> None: + """Upload calibration data to google_sheet.""" + # Common Headers + headers_beg = list(calibration_data[0].keys())[:4] + headers_end = list(["X", "Y", "Z", "lastModified"]) + sheets = [google_sheet_instruments, google_sheet_modules, google_sheet_deck] + instruments_upload_rows: List[Any] = [] + modules_upload_rows: List[Any] = [] + deck_upload_rows: List[Any] = [] + inst_sheet_serials: Set[str] = set() + inst_sheet_modify_dates: Set[str] = set() + module_sheet_serials: Set[str] = set() + deck_sheet_serials: Set[str] = set() + deck_sheet_modify_dates: Set[str] = set() + + # Get current serials, and modified info from google sheet + for i, sheet in enumerate(sheets): + if i == 0: + inst_sheet_serials = sheet.get_column(8) + inst_sheet_modify_dates = sheet.get_column(15) + if i == 1: + module_sheet_serials = sheet.get_column(8) + module_modify_dates = sheet.get_column(15) + elif i == 2: + deck_sheet_serials = sheet.get_column(6) + deck_sheet_modify_dates = sheet.get_column(10) + + # Go through caliration logs and deterine what should be added to the sheet + for calibration_log in calibration_data: + for sheet_ind, sheet in enumerate(sheets): + if sheet_ind == 0: + instruments_upload_rows += instrument_helper( + headers_beg, + headers_end, + calibration_log, + google_sheet_name, + inst_sheet_serials, + inst_sheet_modify_dates, + storage_directory, + ) + elif sheet_ind == 1: + modules_upload_rows += module_helper( + headers_beg, + headers_end, + calibration_log, + google_sheet_name, + module_sheet_serials, + module_modify_dates, + storage_directory, + ) + elif sheet_ind == 2: + deck_upload_rows += deck_helper( + headers_beg, + headers_end, + calibration_log, + google_sheet_name, + deck_sheet_serials, + deck_sheet_modify_dates, + storage_directory, + ) + send_batch_update( + instruments_upload_rows, + google_sheet_instruments, + modules_upload_rows, + google_sheet_modules, + deck_upload_rows, + google_sheet_deck, + ) + + +def run( + storage_directory: str, folder_name: str, google_sheet_name_param: str, email: str +) -> None: + """Main control function.""" + # Connect to google drive. + google_sheet_name = google_sheet_name_param + try: + credentials_path = os.path.join(storage_directory, "credentials.json") + except FileNotFoundError: + print(f"Add credentials.json file to: {storage_directory}.") + sys.exit() + google_drive = google_drive_tool.google_drive(credentials_path, folder_name, email) + # Connect to google sheet + google_sheet_instruments = google_sheets_tool.google_sheet( + credentials_path, google_sheet_name, 0 + ) + google_sheet_modules = google_sheets_tool.google_sheet( + credentials_path, google_sheet_name, 1 + ) + google_sheet_deck = google_sheets_tool.google_sheet( + credentials_path, google_sheet_name, 2 + ) + ip_json_file = os.path.join(storage_directory, "IPs.json") + try: + ip_file = json.load(open(ip_json_file)) + except FileNotFoundError: + print(f"Add .json file with robot IPs to: {storage_directory}.") + sys.exit() + ip_or_all = "" + while not ip_or_all: + ip_or_all = input("IP Address or ALL: ") + calibration_data = [] + if ip_or_all.upper() == "ALL": + ip_address_list = ip_file["ip_address_list"] + for ip in ip_address_list: + saved_file_path, calibration = read_robot_logs.get_calibration_offsets( + ip, storage_directory + ) + calibration_data.append(calibration) + # upload_calibration_offsets(calibration, storage_directory) + else: + try: + ( + saved_file_path, + calibration, + ) = read_robot_logs.get_calibration_offsets( + ip_or_all, storage_directory + ) + calibration_data.append(calibration) + except Exception: + print("Invalid IP try again") + ip_or_all = "" + try: + upload_calibration_offsets( + calibration_data, + storage_directory, + google_sheet_instruments, + google_sheet_modules, + google_sheet_deck, + google_sheet_name, ) + print("Successfully uploaded callibration data!") + except Exception: + print("No calibration data to upload: ") + traceback.print_exc() + sys.exit(1) + google_drive.upload_missing_files(storage_directory) if __name__ == "__main__": @@ -160,42 +358,3 @@ def upload_calibration_offsets( folder_name = args.folder_name[0] google_sheet_name = args.google_sheet_name[0] email = args.email[0] - # Connect to google drive. - try: - credentials_path = os.path.join(storage_directory, "credentials.json") - except FileNotFoundError: - print(f"Add credentials.json file to: {storage_directory}.") - sys.exit() - google_drive = google_drive_tool.google_drive(credentials_path, folder_name, email) - # Connect to google sheet - google_sheet_instruments = google_sheets_tool.google_sheet( - credentials_path, google_sheet_name, 0 - ) - google_sheet_modules = google_sheets_tool.google_sheet( - credentials_path, google_sheet_name, 1 - ) - google_sheet_deck = google_sheets_tool.google_sheet( - credentials_path, google_sheet_name, 2 - ) - ip_json_file = os.path.join(storage_directory, "IPs.json") - try: - ip_file = json.load(open(ip_json_file)) - except FileNotFoundError: - print(f"Add .json file with robot IPs to: {storage_directory}.") - sys.exit() - ip_or_all = input("IP Address or ALL: ") - - if ip_or_all == "ALL": - ip_address_list = ip_file["ip_address_list"] - for ip in ip_address_list: - saved_file_path, calibration = read_robot_logs.get_calibration_offsets( - ip, storage_directory - ) - upload_calibration_offsets(calibration, storage_directory) - else: - saved_file_path, calibration = read_robot_logs.get_calibration_offsets( - ip_or_all, storage_directory - ) - upload_calibration_offsets(calibration, storage_directory) - - google_drive.upload_missing_files(storage_directory) diff --git a/abr-testing/abr_testing/data_collection/abr_google_drive.py b/abr-testing/abr_testing/data_collection/abr_google_drive.py index e1924e3c53e..88ed55cab82 100644 --- a/abr-testing/abr_testing/data_collection/abr_google_drive.py +++ b/abr-testing/abr_testing/data_collection/abr_google_drive.py @@ -158,38 +158,10 @@ def create_data_dictionary( return transposed_runs_and_robots, headers, transposed_runs_and_lpc, headers_lpc -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Read run logs on google drive.") - parser.add_argument( - "storage_directory", - metavar="STORAGE_DIRECTORY", - type=str, - nargs=1, - help="Path to long term storage directory for run logs.", - ) - parser.add_argument( - "folder_name", - metavar="FOLDER_NAME", - type=str, - nargs=1, - help="Google Drive folder name. Open desired folder and copy string after drive/folders/.", - ) - parser.add_argument( - "google_sheet_name", - metavar="GOOGLE_SHEET_NAME", - type=str, - nargs=1, - help="Google sheet name.", - ) - parser.add_argument( - "email", metavar="EMAIL", type=str, nargs=1, help="opentrons gmail." - ) - args = parser.parse_args() - folder_name = args.folder_name[0] - storage_directory = args.storage_directory[0] - google_sheet_name = args.google_sheet_name[0] - email = args.email[0] - +def run( + storage_directory: str, folder_name: str, google_sheet_name: str, email: str +) -> None: + """Main control function.""" try: credentials_path = os.path.join(storage_directory, "credentials.json") except FileNotFoundError: @@ -203,7 +175,6 @@ def create_data_dictionary( # Get run ids on google sheet run_ids_on_gs = set(google_sheet.get_column(2)) # Get robots on google sheet - robots = list(set(google_sheet.get_column(1))) # Uploads files that are not in google drive directory google_drive.upload_missing_files(storage_directory) @@ -229,7 +200,6 @@ def create_data_dictionary( hellma_plate_standards=file_values, ) start_row = google_sheet.get_index_row() + 1 - print(start_row) google_sheet.batch_update_cells(transposed_runs_and_robots, "A", start_row, "0") # Add LPC to google sheet @@ -238,6 +208,40 @@ def create_data_dictionary( google_sheet_lpc.batch_update_cells( transposed_runs_and_lpc, "A", start_row_lpc, "0" ) - robots = list(set(google_sheet.get_column(1))) # Calculate Robot Lifetimes sync_abr_sheet.determine_lifetime(google_sheet) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Read run logs on google drive.") + parser.add_argument( + "storage_directory", + metavar="STORAGE_DIRECTORY", + type=str, + nargs=1, + help="Path to long term storage directory for run logs.", + ) + parser.add_argument( + "folder_name", + metavar="FOLDER_NAME", + type=str, + nargs=1, + help="Google Drive folder name. Open desired folder and copy string after drive/folders/.", + ) + parser.add_argument( + "google_sheet_name", + metavar="GOOGLE_SHEET_NAME", + type=str, + nargs=1, + help="Google sheet name.", + ) + parser.add_argument( + "email", metavar="EMAIL", type=str, nargs=1, help="opentrons gmail." + ) + args = parser.parse_args() + folder_name = args.folder_name[0] + storage_directory = args.storage_directory[0] + google_sheet_name = args.google_sheet_name[0] + email = args.email[0] + + run(storage_directory, folder_name, google_sheet_name, email) diff --git a/abr-testing/abr_testing/data_collection/get_run_logs.py b/abr-testing/abr_testing/data_collection/get_run_logs.py index 3d8eb851197..24d5aaf4f3b 100644 --- a/abr-testing/abr_testing/data_collection/get_run_logs.py +++ b/abr-testing/abr_testing/data_collection/get_run_logs.py @@ -92,7 +92,9 @@ def save_runs(runs_to_save: Set[str], ip: str, storage_directory: str) -> Set[st return saved_file_paths -def get_all_run_logs(storage_directory: str) -> None: +def get_all_run_logs( + storage_directory: str, google_drive: google_drive_tool.google_drive +) -> None: """GET ALL RUN LOGS. Connect to each ABR robot to read run log data. @@ -114,6 +116,17 @@ def get_all_run_logs(storage_directory: str) -> None: google_drive.upload_missing_files(storage_directory) +def run(storage_directory: str, folder_name: str, email: str) -> None: + """Main control function.""" + try: + credentials_path = os.path.join(storage_directory, "credentials.json") + except FileNotFoundError: + print(f"Add credentials.json file to: {storage_directory}.") + sys.exit() + google_drive = google_drive_tool.google_drive(credentials_path, folder_name, email) + get_all_run_logs(storage_directory, google_drive) + + if __name__ == "__main__": """Get run logs.""" parser = argparse.ArgumentParser(description="Pulls run logs from ABR robots.") @@ -138,10 +151,4 @@ def get_all_run_logs(storage_directory: str) -> None: storage_directory = args.storage_directory[0] folder_name = args.folder_name[0] email = args.email[0] - try: - credentials_path = os.path.join(storage_directory, "credentials.json") - except FileNotFoundError: - print(f"Add credentials.json file to: {storage_directory}.") - sys.exit() - google_drive = google_drive_tool.google_drive(credentials_path, folder_name, email) - get_all_run_logs(storage_directory) + run(storage_directory, folder_name, email) diff --git a/abr-testing/abr_testing/data_collection/read_robot_logs.py b/abr-testing/abr_testing/data_collection/read_robot_logs.py index be74294fbe5..ff650335d84 100644 --- a/abr-testing/abr_testing/data_collection/read_robot_logs.py +++ b/abr-testing/abr_testing/data_collection/read_robot_logs.py @@ -13,7 +13,6 @@ import time as t import json import requests -import sys from abr_testing.tools import plate_reader @@ -695,7 +694,7 @@ def get_calibration_offsets( print(f"Connected to {ip}") except Exception: print(f"ERROR: Failed to read IP address: {ip}") - sys.exit() + raise health_data = response.json() robot_name = health_data.get("name", "") api_version = health_data.get("api_version", "") diff --git a/abr-testing/abr_testing/tools/abr_setup.py b/abr-testing/abr_testing/tools/abr_setup.py new file mode 100644 index 00000000000..853f1c53ced --- /dev/null +++ b/abr-testing/abr_testing/tools/abr_setup.py @@ -0,0 +1,139 @@ +"""Automate ABR data collection.""" +import os +import time +import configparser +import traceback +import sys +from hardware_testing.scripts import ABRAsairScript # type: ignore +from abr_testing.data_collection import ( + get_run_logs, + abr_google_drive, + abr_calibration_logs, +) + + +def run_temp_sensor(ip_file: str) -> None: + """Run temperature sensors on all robots.""" + processes = ABRAsairScript.run(ip_file) + for process in processes: + process.start() + time.sleep(20) + for process in processes: + process.join() + + +def get_abr_logs(storage_directory: str, folder_name: str, email: str) -> None: + """Retrieve run logs on all robots and record missing run logs in google drive.""" + try: + get_run_logs.run(storage_directory, folder_name, email) + except Exception as e: + print("Cannot Get Run Logs", e) + traceback.print_exc + + +def record_abr_logs( + storage_directory: str, folder_name: str, google_sheet_name: str, email: str +) -> None: + """Write run logs to ABR run logs in sheets.""" + try: + abr_google_drive.run(storage_directory, folder_name, google_sheet_name, email) + except Exception as e: + print(e) + + +def get_calibration_data( + storage_directory: str, folder_name: str, google_sheet_name: str, email: str +) -> None: + """Download calibration logs and write to ABR-calibration-data in sheets.""" + try: + abr_calibration_logs.run( + storage_directory, folder_name, google_sheet_name, email + ) + except Exception as e: + print("Cannot get callibration data", e) + traceback.print_exc() + + +def main(configurations: configparser.ConfigParser) -> None: + """Main function.""" + ip_file = None + storage_directory = None + email = None + drive_folder = None + sheet_name = None + + has_defaults = False + # If default is not specified get all values + default = configurations["DEFAULT"] + if len(default) > 0: + has_defaults = True + try: + if has_defaults: + storage_directory = default["Storage"] + email = default["Email"] + drive_folder = default["Drive_Folder"] + sheet_name = default["Sheet_Name"] + except KeyError as e: + print("Cannot read config file\n" + str(e)) + + # Run Temperature Sensors + if not has_defaults: + ip_file = configurations["TEMP-SENSOR"]["Robo_List"] + print("Starting temp sensors...") + if ip_file: + run_temp_sensor(ip_file) + print("Temp Sensors Started") + else: + print("Missing ip_file location, please fix configs") + sys.exit(1) + # Get Run Logs and Record + if not has_defaults: + storage_directory = configurations["RUN-LOG"]["Storage"] + email = configurations["RUN-LOG"]["Email"] + drive_folder = configurations["RUN-LOG"]["Drive_Folder"] + sheet_name = configurations["RUN-LOG"]["Sheet_Name"] + print(sheet_name) + if storage_directory and drive_folder and sheet_name and email: + print("Retrieving robot run logs...") + get_abr_logs(storage_directory, drive_folder, email) + print("Recording robot run logs...") + record_abr_logs(storage_directory, drive_folder, sheet_name, email) + print("Run logs updated") + else: + print("Storage, Email, or Drive Folder is missing, please fix configs") + sys.exit(1) + + # Collect calibration data + if not has_defaults: + storage_directory = configurations["CALIBRATION"]["Storage"] + email = configurations["CALIBRATION"]["Email"] + drive_folder = configurations["CALIBRATION"]["Drive_Folder"] + sheet_name = configurations["CALIBRATION"]["Sheet_Name"] + if storage_directory and drive_folder and sheet_name and email: + print("Retrieving and recording robot calibration data...") + get_calibration_data(storage_directory, drive_folder, sheet_name, email) + print("Calibration logs updated") + else: + print( + "Storage, Email, Drive Folder, or Sheet name is missing, please fix configs" + ) + sys.exit(1) + + +if __name__ == "__main__": + configurations = None + configs_file = None + while not configs_file: + configs_file = input("Please enter path to config.ini: ") + if os.path.exists(configs_file): + break + else: + configs_file = None + print("Please enter a valid path") + try: + configurations = configparser.ConfigParser() + configurations.read(configs_file) + except configparser.ParsingError as e: + print("Cannot read configuration file\n" + str(e)) + if configurations: + main(configurations) diff --git a/abr-testing/abr_testing/tools/sync_abr_sheet.py b/abr-testing/abr_testing/tools/sync_abr_sheet.py index aca116292a8..569f0f9b834 100644 --- a/abr-testing/abr_testing/tools/sync_abr_sheet.py +++ b/abr-testing/abr_testing/tools/sync_abr_sheet.py @@ -7,6 +7,8 @@ import csv import sys import os +import time +import traceback from typing import Dict, Tuple, Any, List from statistics import mean, StatisticsError @@ -27,76 +29,94 @@ def determine_lifetime(abr_google_sheet: Any) -> None: ) # Goes through dataframe per robot for index, run in df_sheet_data.iterrows(): - end_time = run["End_Time"] - robot = run["Robot"] - robot_lifetime = ( - float(run["Robot Lifetime (%)"]) if run["Robot Lifetime (%)"] != "" else 0 + max_retries = 5 + retries = 0 + while retries < max_retries: + try: + update_df(abr_google_sheet, lifetime_index, df_sheet_data, dict(run)) + break + except Exception as e: + if "Quota exceeded for quota metric" in str(e): + retries += 1 + print( + f"Read/write limit reached on attempt: {retries}, pausing then retrying..." + ) + time.sleep(65) + else: + print("unrecoverable error:", e) + traceback.print_exc() + sys.exit(1) + + +def update_df( + abr_google_sheet: Any, lifetime_index: int, df_sheet_data: Any, run: Dict[Any, Any] +) -> None: + """Update google sheets with new run log data.""" + end_time = run["End_Time"] + robot = run["Robot"] + robot_lifetime = ( + float(run["Robot Lifetime (%)"]) if run["Robot Lifetime (%)"] != "" else 0 + ) + if robot_lifetime < 1 and len(run["Run_ID"]) > 1: + # Get Robot % Lifetime + robot_runs_before = df_sheet_data[ + (df_sheet_data["End_Time"] <= end_time) & (df_sheet_data["Robot"] == robot) + ] + robot_percent_lifetime = ( + (robot_runs_before["Run_Time (min)"].sum() / 60) / 3750 * 100 ) - if robot_lifetime < 1 and len(run["Run_ID"]) > 1: - # Get Robot % Lifetime - robot_runs_before = df_sheet_data[ + # Get Left Pipette % Lifetime + left_pipette = run["Left Mount"] + if len(left_pipette) > 1: + left_pipette_runs_before = df_sheet_data[ (df_sheet_data["End_Time"] <= end_time) - & (df_sheet_data["Robot"] == robot) + & ( + (df_sheet_data["Left Mount"] == left_pipette) + | (df_sheet_data["Right Mount"] == left_pipette) + ) ] - robot_percent_lifetime = ( - (robot_runs_before["Run_Time (min)"].sum() / 60) / 3750 * 100 + left_pipette_percent_lifetime = ( + (left_pipette_runs_before["Run_Time (min)"].sum() / 60) / 1248 * 100 ) - # Get Left Pipette % Lifetime - left_pipette = run["Left Mount"] - if len(left_pipette) > 1: - left_pipette_runs_before = df_sheet_data[ - (df_sheet_data["End_Time"] <= end_time) - & ( - (df_sheet_data["Left Mount"] == left_pipette) - | (df_sheet_data["Right Mount"] == left_pipette) - ) - ] - left_pipette_percent_lifetime = ( - (left_pipette_runs_before["Run_Time (min)"].sum() / 60) / 1248 * 100 - ) - else: - left_pipette_percent_lifetime = "" - # Get Right Pipette % Lifetime - right_pipette = run["Right Mount"] - if len(right_pipette) > 1: - right_pipette_runs_before = df_sheet_data[ - (df_sheet_data["End_Time"] <= end_time) - & ( - (df_sheet_data["Left Mount"] == right_pipette) - | (df_sheet_data["Right Mount"] == right_pipette) - ) - ] - right_pipette_percent_lifetime = ( - (right_pipette_runs_before["Run_Time (min)"].sum() / 60) - / 1248 - * 100 - ) - else: - right_pipette_percent_lifetime = "" - # Get Gripper % Lifetime - gripper = run["Extension"] - if len(gripper) > 1: - gripper_runs_before = df_sheet_data[ - (df_sheet_data["End_Time"] <= end_time) - & (df_sheet_data["Extension"] == gripper) - ] - gripper_percent_lifetime = ( - (gripper_runs_before["Run_Time (min)"].sum() / 60) / 3750 * 100 + else: + left_pipette_percent_lifetime = "" + # Get Right Pipette % Lifetime + right_pipette = run["Right Mount"] + if len(right_pipette) > 1: + right_pipette_runs_before = df_sheet_data[ + (df_sheet_data["End_Time"] <= end_time) + & ( + (df_sheet_data["Left Mount"] == right_pipette) + | (df_sheet_data["Right Mount"] == right_pipette) ) - else: - gripper_percent_lifetime = "" - run_id = run["Run_ID"] - row_num = abr_google_sheet.get_row_index_with_value(run_id, 2) - update_list = [ - [robot_percent_lifetime], - [left_pipette_percent_lifetime], - [right_pipette_percent_lifetime], - [gripper_percent_lifetime], ] - abr_google_sheet.batch_update_cells( - update_list, lifetime_index, row_num, "0" + right_pipette_percent_lifetime = ( + (right_pipette_runs_before["Run_Time (min)"].sum() / 60) / 1248 * 100 ) - print(f"Updated row {row_num} for run: {run_id}") + else: + right_pipette_percent_lifetime = "" + # Get Gripper % Lifetime + gripper = run["Extension"] + if len(gripper) > 1: + gripper_runs_before = df_sheet_data[ + (df_sheet_data["End_Time"] <= end_time) + & (df_sheet_data["Extension"] == gripper) + ] + gripper_percent_lifetime = ( + (gripper_runs_before["Run_Time (min)"].sum() / 60) / 3750 * 100 + ) + else: + gripper_percent_lifetime = "" + run_id = run["Run_ID"] + row_num = abr_google_sheet.get_row_index_with_value(run_id, 2) + update_list = [ + [robot_percent_lifetime], + [left_pipette_percent_lifetime], + [right_pipette_percent_lifetime], + [gripper_percent_lifetime], + ] + abr_google_sheet.batch_update_cells(update_list, lifetime_index, row_num, "0") + print(f"Updated row {row_num} for run: {run_id}") def compare_run_to_temp_data( diff --git a/abr-testing/protocol_simulation/abr_sim_check.py b/abr-testing/protocol_simulation/abr_sim_check.py new file mode 100644 index 00000000000..a97a0b3692e --- /dev/null +++ b/abr-testing/protocol_simulation/abr_sim_check.py @@ -0,0 +1,33 @@ +from protocol_simulation import simulation_metrics +import os +import traceback +from pathlib import Path + +def run(file_to_simulate: Path): + protocol_name = file_to_simulate.stem + try: + simulation_metrics.main(file_to_simulate, False) + except Exception as e: + print(f"Error in protocol: {protocol_name}") + traceback.print_exc() + + + + +if __name__ == "__main__": + # Directory to search + root_dir = 'abr_testing/protocols' + + exclude = [ + '__init__.py', + 'shared_vars_and_funcs.py', + ] + # Walk through the root directory and its subdirectories + for root, dirs, files in os.walk(root_dir): + for file in files: + if file.endswith(".py"): # If it's a Python file + if file in exclude: + continue + file_path = os.path.join(root, file) + print(f"Simulating protocol: {file_path}") + run(Path(file_path)) \ No newline at end of file diff --git a/abr-testing/protocol_simulation/simulation_metrics.py b/abr-testing/protocol_simulation/simulation_metrics.py index 544bc3fb4bc..dfbba90949b 100644 --- a/abr-testing/protocol_simulation/simulation_metrics.py +++ b/abr-testing/protocol_simulation/simulation_metrics.py @@ -12,22 +12,9 @@ from typing import Set, Dict, Any, Tuple, List, Union from abr_testing.tools import plate_reader -def look_for_air_gaps(protocol_file_path: str) -> int: - instances = 0 - try: - with open(protocol_file_path, "r") as open_file: - protocol_lines = open_file.readlines() - for line in protocol_lines: - if "air_gap" in line: - print(line) - instances += 1 - print(f'Found {instances} instance(s) of the air gap function') - open_file.close() - except Exception as error: - print("Error reading protocol:", error.with_traceback()) - return instances - -def set_api_level(protocol_file_path) -> None: + + +def set_api_level(protocol_file_path: str) -> None: with open(protocol_file_path, "r") as file: file_contents = file.readlines() # Look for current'apiLevel:' @@ -47,13 +34,33 @@ def set_api_level(protocol_file_path) -> None: file.writelines(file_contents) print("File updated successfully.") -original_exit = sys.exit +def look_for_air_gaps(protocol_file_path: str) -> int: + """Search Protocol for Air Gaps""" + instances = 0 + try: + with open(protocol_file_path, "r") as open_file: + protocol_lines = open_file.readlines() + for line in protocol_lines: + if "air_gap" in line: + print(line) + instances += 1 + print(f'Found {instances} instance(s) of the air gap function') + open_file.close() + except Exception as error: + print("Error reading protocol:", error.with_traceback()) + return instances + -def mock_exit(code=None) -> None: +# Mock sys.exit to avoid program termination +original_exit = sys.exit # Save the original sys.exit function + +def mock_exit(code: Any = None) -> None: + """Prevents program from exiting after analyze""" print(f"sys.exit() called with code: {code}") - raise SystemExit(code) + raise SystemExit(code) # Raise the exception but catch it to prevent termination def get_labware_name(id: str, object_dict: dict, json_data: dict) -> str: + """Recursively find the labware_name""" slot = "" for obj in object_dict: if obj['id'] == id: @@ -62,6 +69,7 @@ def get_labware_name(id: str, object_dict: dict, json_data: dict) -> str: slot = obj['location']['slotName'] return " SLOT: " + slot except KeyError: + # Handle KeyError when location or slotName is missing location = obj.get('location', {}) # Check if location contains 'moduleId' @@ -74,15 +82,18 @@ def get_labware_name(id: str, object_dict: dict, json_data: dict) -> str: return " Labware not found" + def parse_results_volume(json_data_file: str) -> Tuple[ List[str], List[str], List[str], List[str], List[str], List[str], List[str], List[str], List[str], List[str], List[str] ]: + """Pars run log and extract neccessay information""" json_data = [] with open(json_data_file, "r") as json_file: json_data = json.load(json_file) commands = json_data.get("commands", []) + start_time = datetime.fromisoformat(commands[0]["createdAt"]) end_time = datetime.fromisoformat(commands[len(commands)-1]["completedAt"]) header = ["", "Protocol Name", "Date", "Time"] @@ -127,6 +138,7 @@ def parse_results_volume(json_data_file: str) -> Tuple[ "Average Liquid Probe Time (sec)", ] values_row = ["Value"] + labware_well_dict = {} hs_dict, temp_module_dict, thermo_cycler_dict, plate_reader_dict, instrument_dict = {}, {}, {}, {}, {} try: @@ -140,53 +152,52 @@ def parse_results_volume(json_data_file: str) -> Tuple[ metrics = [hs_dict, temp_module_dict, thermo_cycler_dict, plate_reader_dict, instrument_dict] - # Iterate through all the commands executed in the protocol run log for x, command in enumerate(commands): if x != 0: prev_command = commands[x-1] if command["commandType"] == "aspirate": - if not (prev_command["commandType"] == "comment" and (prev_command['params']['message'] == "AIR GAP" or prev_command['params']['message'] == "MIXING")): - labware_id = command["params"]["labwareId"] - labware_name = "" - for labware in json_data.get("labware"): - if labware["id"] == labware_id: - labware_name = (labware["loadName"]) + get_labware_name(labware["id"], json_data["labware"], json_data) - well_name = command["params"]["wellName"] + labware_id = command["params"]["labwareId"] + labware_name = "" + for labware in json_data.get("labware"): + if labware["id"] == labware_id: + labware_name = (labware["loadName"]) + get_labware_name(labware["id"], json_data["labware"], json_data) + well_name = command["params"]["wellName"] - if labware_id not in labware_well_dict: - labware_well_dict[labware_id] = {} + if labware_id not in labware_well_dict: + labware_well_dict[labware_id] = {} - if well_name not in labware_well_dict[labware_id]: - labware_well_dict[labware_id][well_name] = (labware_name, 0, 0, "") + if well_name not in labware_well_dict[labware_id]: + labware_well_dict[labware_id][well_name] = (labware_name, 0, 0, "") - vol = int(command["params"]["volume"]) + vol = int(command["params"]["volume"]) - labware_name, added_volumes, subtracted_volumes, log = labware_well_dict[labware_id][well_name] + labware_name, added_volumes, subtracted_volumes, log = labware_well_dict[labware_id][well_name] + + subtracted_volumes += vol + log+=(f"aspirated {vol} ") + labware_well_dict[labware_id][well_name] = (labware_name, added_volumes, subtracted_volumes, log) - subtracted_volumes += vol - log+=(f"aspirated {vol} ") - labware_well_dict[labware_id][well_name] = (labware_name, added_volumes, subtracted_volumes, log) elif command["commandType"] == "dispense": - if not (prev_command["commandType"] == "comment" and (prev_command['params']['message'] == "MIXING")): - labware_id = command["params"]["labwareId"] - labware_name = "" - for labware in json_data.get("labware"): - if labware["id"] == labware_id: - labware_name = (labware["loadName"]) + get_labware_name(labware["id"], json_data["labware"], json_data) - well_name = command["params"]["wellName"] - - if labware_id not in labware_well_dict: - labware_well_dict[labware_id] = {} - - if well_name not in labware_well_dict[labware_id]: - labware_well_dict[labware_id][well_name] = (labware_name, 0, 0, "") - - vol = int(command["params"]["volume"]) - labware_name, added_volumes, subtracted_volumes, log = labware_well_dict[labware_id][well_name] - added_volumes += vol - log+=(f"dispensed {vol} ") - labware_well_dict[labware_id][well_name] = (labware_name, added_volumes, subtracted_volumes, log) - # file_date_formatted = file_date.strftime("%Y-%m-%d_%H-%M-%S") + labware_id = command["params"]["labwareId"] + labware_name = "" + for labware in json_data.get("labware"): + if labware["id"] == labware_id: + labware_name = (labware["loadName"]) + get_labware_name(labware["id"], json_data["labware"], json_data) + well_name = command["params"]["wellName"] + + if labware_id not in labware_well_dict: + labware_well_dict[labware_id] = {} + + if well_name not in labware_well_dict[labware_id]: + labware_well_dict[labware_id][well_name] = (labware_name, 0, 0, "") + + vol = int(command["params"]["volume"]) + + labware_name, added_volumes, subtracted_volumes, log = labware_well_dict[labware_id][well_name] + + added_volumes += vol + log+=(f"dispensed {vol} ") + labware_well_dict[labware_id][well_name] = (labware_name, added_volumes, subtracted_volumes, log) with open(f"{os.path.dirname(json_data_file)}\\{protocol_name}_well_volumes_{file_date_formatted}.json", "w") as output_file: json.dump(labware_well_dict, output_file) output_file.close() @@ -224,9 +235,10 @@ def parse_results_volume(json_data_file: str) -> Tuple[ metrics_row, values_row) -def main(storage_directory, google_sheet_name, protocol_file_path): - sys.exit = mock_exit +def main(protocol_file_path: Path, save: bool, storage_directory: str = os.curdir, google_sheet_name: str = "") -> None: + """Main module control""" + sys.exit = mock_exit # Replace sys.exit with the mock function # Read file path from arguments protocol_file_path = Path(protocol_file_path) global protocol_name @@ -236,27 +248,41 @@ def main(storage_directory, google_sheet_name, protocol_file_path): file_date = datetime.now() global file_date_formatted file_date_formatted = file_date.strftime("%Y-%m-%d_%H-%M-%S") - # Prepare output file - json_file_path = f"{storage_directory}\\{protocol_name}_{file_date_formatted}.json" - json_file_output = open(json_file_path, "wb+") - error_output = f"{storage_directory}\\error_log" + error_output = f"{storage_directory}\\test_debug" # Run protocol simulation try: with Context(analyze) as ctx: - ctx.invoke( - analyze, - files=[protocol_file_path], - json_output=json_file_output, - human_json_output=None, - log_output=error_output, - log_level="ERROR", - check=False - ) + if save: + # Prepare output file + json_file_path = f"{storage_directory}\\{protocol_name}_{file_date_formatted}.json" + json_file_output = open(json_file_path, "wb+") + # log_output_file = f"{protocol_name}_log" + ctx.invoke( + analyze, + files=[protocol_file_path], + json_output=json_file_output, + human_json_output=None, + log_output=error_output, + log_level="ERROR", + check=False + ) + json_file_output.close() + else: + ctx.invoke( + analyze, + files=[protocol_file_path], + json_output=None, + human_json_output=None, + log_output=error_output, + log_level="ERROR", + check=True + ) + except SystemExit as e: print(f"SystemExit caught with code: {e}") finally: + # Reset sys.exit to the original behavior sys.exit = original_exit - json_file_output.close() with open(error_output, "r") as open_file: try: errors = open_file.readlines() @@ -267,32 +293,30 @@ def main(storage_directory, google_sheet_name, protocol_file_path): except: print("error simulating ...") sys.exit() + if save: + try: + credentials_path = os.path.join(storage_directory, "credentials.json") + print(credentials_path) - try: - credentials_path = os.path.join(storage_directory, "credentials.json") - print(credentials_path) - except FileNotFoundError: - print(f"Add credentials.json file to: {storage_directory}.") - sys.exit() - - global hellma_plate_standards - - try: - hellma_plate_standards = plate_reader.read_hellma_plate_files(storage_directory, 101934) - except: - print(f"Add helma plate standard files to {storage_directory}.") - sys.exit() - - google_sheet = google_sheets_tool.google_sheet( - credentials_path, google_sheet_name, 0 - ) - - google_sheet.write_to_row([]) - - for row in parse_results_volume(json_file_path): - print("Writing results to", google_sheet_name) - print(str(row)) - google_sheet.write_to_row(row) + except FileNotFoundError: + print(f"Add credentials.json file to: {storage_directory}.") + sys.exit() + + global hellma_plate_standards + try: + hellma_plate_standards = plate_reader.read_hellma_plate_files(storage_directory, 101934) + + except: + print(f"Add helma plate standard files to {storage_directory}.") + sys.exit() + google_sheet = google_sheets_tool.google_sheet( + credentials_path, google_sheet_name, 0 + ) + google_sheet.write_to_row([]) + for row in parse_results_volume(json_file_path): + print("Writing results to", google_sheet_name) + print(str(row)) + google_sheet.write_to_row(row) if __name__ == "__main__": CLEAN_PROTOCOL = True @@ -343,11 +367,13 @@ def main(storage_directory, google_sheet_name, protocol_file_path): choice = "" print("Please enter a valid response.") SETUP = False - + + # set_api_level() if CLEAN_PROTOCOL: + set_api_level(Path(protocol_file_path)) main( - storage_directory, - sheet_name, protocol_file_path, - ) + True, + storage_directory, + sheet_name,) else: sys.exit(0) \ No newline at end of file diff --git a/api-client/src/runs/types.ts b/api-client/src/runs/types.ts index c42752b0eb9..b9403c12c68 100644 --- a/api-client/src/runs/types.ts +++ b/api-client/src/runs/types.ts @@ -61,6 +61,7 @@ export interface LegacyGoodRunData { export interface KnownGoodRunData extends LegacyGoodRunData { ok: true runTimeParameters: RunTimeParameter[] + outputFileIds: string[] } export interface KnownInvalidRunData extends LegacyGoodRunData { @@ -99,7 +100,7 @@ export interface RunsLinks { } export interface RunCommandLink { - current: CommandLinkNoMeta + lastCompleted: CommandLinkNoMeta } export interface CommandLinkNoMeta { diff --git a/api/src/opentrons/cli/analyze.py b/api/src/opentrons/cli/analyze.py index f311adce402..8489da83d68 100644 --- a/api/src/opentrons/cli/analyze.py +++ b/api/src/opentrons/cli/analyze.py @@ -332,6 +332,7 @@ async def _do_analyze( liquids=[], wells=[], hasEverEnteredErrorRecovery=False, + files=[], ), parameters=[], ) diff --git a/api/src/opentrons/config/__init__.py b/api/src/opentrons/config/__init__.py index a4571521211..71ba78d39b0 100644 --- a/api/src/opentrons/config/__init__.py +++ b/api/src/opentrons/config/__init__.py @@ -202,6 +202,15 @@ class ConfigElement(NamedTuple): " absolute path, it will be used directly. If it is a " "relative path it will be relative to log_dir", ), + ConfigElement( + "sensor_log_file", + "Sensor Log File", + Path("logs") / "sensor.log", + ConfigElementType.FILE, + "The location of the file to save sensor logs to. If this is an" + " absolute path, it will be used directly. If it is a " + "relative path it will be relative to log_dir", + ), ConfigElement( "serial_log_file", "Serial Log File", diff --git a/api/src/opentrons/config/defaults_ot3.py b/api/src/opentrons/config/defaults_ot3.py index 08b86f16c95..55565745d3a 100644 --- a/api/src/opentrons/config/defaults_ot3.py +++ b/api/src/opentrons/config/defaults_ot3.py @@ -15,7 +15,6 @@ LiquidProbeSettings, ZSenseSettings, EdgeSenseSettings, - OutputOptions, ) @@ -27,13 +26,11 @@ plunger_speed=15, plunger_impulse_time=0.2, sensor_threshold_pascals=15, - output_option=OutputOptions.sync_buffer_to_csv, aspirate_while_sensing=False, z_overlap_between_passes_mm=0.1, plunger_reset_offset=2.0, samples_for_baselining=20, sample_time_sec=0.004, - data_files={InstrumentProbeType.PRIMARY: "/data/pressure_sensor_data.csv"}, ) DEFAULT_CALIBRATION_SETTINGS: Final[OT3CalibrationSettings] = OT3CalibrationSettings( @@ -43,7 +40,6 @@ max_overrun_distance_mm=5.0, speed_mm_per_s=1.0, sensor_threshold_pf=3.0, - output_option=OutputOptions.sync_only, ), ), edge_sense=EdgeSenseSettings( @@ -54,7 +50,6 @@ max_overrun_distance_mm=0.5, speed_mm_per_s=1, sensor_threshold_pf=3.0, - output_option=OutputOptions.sync_only, ), search_initial_tolerance_mm=12.0, search_iteration_limit=8, @@ -195,23 +190,6 @@ ) -def _build_output_option_with_default( - from_conf: Any, default: OutputOptions -) -> OutputOptions: - if from_conf is None: - return default - else: - if isinstance(from_conf, OutputOptions): - return from_conf - else: - try: - enumval = OutputOptions[from_conf] - except KeyError: # not an enum entry - return default - else: - return enumval - - def _build_log_files_with_default( from_conf: Any, default: Optional[Dict[InstrumentProbeType, str]], @@ -316,24 +294,12 @@ def _build_default_cap_pass( sensor_threshold_pf=from_conf.get( "sensor_threshold_pf", default.sensor_threshold_pf ), - output_option=from_conf.get("output_option", default.output_option), ) def _build_default_liquid_probe( from_conf: Any, default: LiquidProbeSettings ) -> LiquidProbeSettings: - output_option = _build_output_option_with_default( - from_conf.get("output_option", None), default.output_option - ) - data_files: Optional[Dict[InstrumentProbeType, str]] = None - if ( - output_option is OutputOptions.sync_buffer_to_csv - or output_option is OutputOptions.stream_to_csv - ): - data_files = _build_log_files_with_default( - from_conf.get("data_files", None), default.data_files - ) return LiquidProbeSettings( mount_speed=from_conf.get("mount_speed", default.mount_speed), plunger_speed=from_conf.get("plunger_speed", default.plunger_speed), @@ -343,7 +309,6 @@ def _build_default_liquid_probe( sensor_threshold_pascals=from_conf.get( "sensor_threshold_pascals", default.sensor_threshold_pascals ), - output_option=from_conf.get("output_option", default.output_option), aspirate_while_sensing=from_conf.get( "aspirate_while_sensing", default.aspirate_while_sensing ), @@ -357,7 +322,6 @@ def _build_default_liquid_probe( "samples_for_baselining", default.samples_for_baselining ), sample_time_sec=from_conf.get("sample_time_sec", default.sample_time_sec), - data_files=data_files, ) diff --git a/api/src/opentrons/config/types.py b/api/src/opentrons/config/types.py index 5a6c67725d0..d35b58578ca 100644 --- a/api/src/opentrons/config/types.py +++ b/api/src/opentrons/config/types.py @@ -1,8 +1,8 @@ from enum import Enum from dataclasses import dataclass, asdict, fields -from typing import Dict, Tuple, TypeVar, Generic, List, cast, Optional +from typing import Dict, Tuple, TypeVar, Generic, List, cast from typing_extensions import TypedDict, Literal -from opentrons.hardware_control.types import OT3AxisKind, InstrumentProbeType +from opentrons.hardware_control.types import OT3AxisKind class AxisDict(TypedDict): @@ -103,25 +103,12 @@ def by_gantry_load( ) -class OutputOptions(int, Enum): - """Specifies where we should report sensor data to during a sensor pass.""" - - stream_to_csv = 0x1 # compile sensor data stream into a csv file, in addition to can_bus_only behavior - sync_buffer_to_csv = 0x2 # collect sensor data on pipette mcu, then stream to robot server and compile into a csv file, in addition to can_bus_only behavior - can_bus_only = ( - 0x4 # stream sensor data over CAN bus, in addition to sync_only behavior - ) - sync_only = 0x8 # trigger pipette sync line upon sensor's detection of something - - @dataclass(frozen=True) class CapacitivePassSettings: prep_distance_mm: float max_overrun_distance_mm: float speed_mm_per_s: float sensor_threshold_pf: float - output_option: OutputOptions - data_files: Optional[Dict[InstrumentProbeType, str]] = None @dataclass(frozen=True) @@ -135,13 +122,11 @@ class LiquidProbeSettings: plunger_speed: float plunger_impulse_time: float sensor_threshold_pascals: float - output_option: OutputOptions aspirate_while_sensing: bool z_overlap_between_passes_mm: float plunger_reset_offset: float samples_for_baselining: int sample_time_sec: float - data_files: Optional[Dict[InstrumentProbeType, str]] @dataclass(frozen=True) diff --git a/api/src/opentrons/execute.py b/api/src/opentrons/execute.py index ade74b1aadd..a9b3562d82b 100644 --- a/api/src/opentrons/execute.py +++ b/api/src/opentrons/execute.py @@ -546,6 +546,7 @@ def _create_live_context_pe( hardware_api=hardware_api_wrapped, config=_get_protocol_engine_config(), deck_configuration=entrypoint_util.get_deck_configuration(), + file_provider=None, error_recovery_policy=error_recovery_policy.never_recover, drop_tips_after_run=False, post_run_hardware_state=PostRunHardwareState.STAY_ENGAGED_IN_PLACE, diff --git a/api/src/opentrons/hardware_control/backends/flex_protocol.py b/api/src/opentrons/hardware_control/backends/flex_protocol.py index 6f3299cf92d..466e7890026 100644 --- a/api/src/opentrons/hardware_control/backends/flex_protocol.py +++ b/api/src/opentrons/hardware_control/backends/flex_protocol.py @@ -15,7 +15,7 @@ from opentrons_shared_data.pipette.types import ( PipetteName, ) -from opentrons.config.types import GantryLoad, OutputOptions +from opentrons.config.types import GantryLoad from opentrons.hardware_control.types import ( BoardRevision, Axis, @@ -38,6 +38,8 @@ StatusBarState, ) from opentrons.hardware_control.module_control import AttachedModulesControl +from opentrons_hardware.firmware_bindings.constants import SensorId +from opentrons_hardware.sensors.types import SensorDataType from ..dev_types import OT3AttachedInstruments from .types import HWStopCondition @@ -152,10 +154,11 @@ async def liquid_probe( threshold_pascals: float, plunger_impulse_time: float, num_baseline_reads: int, - output_format: OutputOptions = OutputOptions.can_bus_only, - data_files: Optional[Dict[InstrumentProbeType, str]] = None, probe: InstrumentProbeType = InstrumentProbeType.PRIMARY, force_both_sensors: bool = False, + response_queue: Optional[ + asyncio.Queue[Dict[SensorId, List[SensorDataType]]] + ] = None, ) -> float: ... @@ -371,8 +374,6 @@ async def capacitive_probe( speed_mm_per_s: float, sensor_threshold_pf: float, probe: InstrumentProbeType = InstrumentProbeType.PRIMARY, - output_format: OutputOptions = OutputOptions.sync_only, - data_files: Optional[Dict[InstrumentProbeType, str]] = None, ) -> bool: ... diff --git a/api/src/opentrons/hardware_control/backends/ot3controller.py b/api/src/opentrons/hardware_control/backends/ot3controller.py index 84c95c8fbc4..48787e86933 100644 --- a/api/src/opentrons/hardware_control/backends/ot3controller.py +++ b/api/src/opentrons/hardware_control/backends/ot3controller.py @@ -25,7 +25,7 @@ Union, Mapping, ) -from opentrons.config.types import OT3Config, GantryLoad, OutputOptions +from opentrons.config.types import OT3Config, GantryLoad from opentrons.config import gripper_config from .ot3utils import ( axis_convert, @@ -102,7 +102,9 @@ NodeId, PipetteName as FirmwarePipetteName, ErrorCode, + SensorId, ) +from opentrons_hardware.sensors.types import SensorDataType from opentrons_hardware.firmware_bindings.messages.message_definitions import ( StopRequest, ) @@ -1368,28 +1370,14 @@ async def liquid_probe( threshold_pascals: float, plunger_impulse_time: float, num_baseline_reads: int, - output_option: OutputOptions = OutputOptions.can_bus_only, - data_files: Optional[Dict[InstrumentProbeType, str]] = None, probe: InstrumentProbeType = InstrumentProbeType.PRIMARY, force_both_sensors: bool = False, + response_queue: Optional[ + asyncio.Queue[Dict[SensorId, List[SensorDataType]]] + ] = None, ) -> float: head_node = axis_to_node(Axis.by_mount(mount)) tool = sensor_node_for_pipette(OT3Mount(mount.value)) - csv_output = bool(output_option.value & OutputOptions.stream_to_csv.value) - sync_buffer_output = bool( - output_option.value & OutputOptions.sync_buffer_to_csv.value - ) - can_bus_only_output = bool( - output_option.value & OutputOptions.can_bus_only.value - ) - data_files_transposed = ( - None - if data_files is None - else { - sensor_id_for_instrument(probe): data_files[probe] - for probe in data_files.keys() - } - ) positions = await liquid_probe( messenger=self._messenger, tool=tool, @@ -1400,12 +1388,9 @@ async def liquid_probe( threshold_pascals=threshold_pascals, plunger_impulse_time=plunger_impulse_time, num_baseline_reads=num_baseline_reads, - csv_output=csv_output, - sync_buffer_output=sync_buffer_output, - can_bus_only_output=can_bus_only_output, - data_files=data_files_transposed, sensor_id=sensor_id_for_instrument(probe), force_both_sensors=force_both_sensors, + response_queue=response_queue, ) for node, point in positions.items(): self._position.update({node: point.motor_position}) @@ -1432,41 +1417,13 @@ async def capacitive_probe( speed_mm_per_s: float, sensor_threshold_pf: float, probe: InstrumentProbeType = InstrumentProbeType.PRIMARY, - output_option: OutputOptions = OutputOptions.sync_only, - data_files: Optional[Dict[InstrumentProbeType, str]] = None, ) -> bool: - if output_option == OutputOptions.sync_buffer_to_csv: - assert ( - self._subsystem_manager.device_info[ - SubSystem.of_mount(mount) - ].revision.tertiary - == "1" - ) - csv_output = bool(output_option.value & OutputOptions.stream_to_csv.value) - sync_buffer_output = bool( - output_option.value & OutputOptions.sync_buffer_to_csv.value - ) - can_bus_only_output = bool( - output_option.value & OutputOptions.can_bus_only.value - ) - data_files_transposed = ( - None - if data_files is None - else { - sensor_id_for_instrument(probe): data_files[probe] - for probe in data_files.keys() - } - ) status = await capacitive_probe( messenger=self._messenger, tool=sensor_node_for_mount(mount), mover=axis_to_node(moving), distance=distance_mm, mount_speed=speed_mm_per_s, - csv_output=csv_output, - sync_buffer_output=sync_buffer_output, - can_bus_only_output=can_bus_only_output, - data_files=data_files_transposed, sensor_id=sensor_id_for_instrument(probe), relative_threshold_pf=sensor_threshold_pf, ) diff --git a/api/src/opentrons/hardware_control/backends/ot3simulator.py b/api/src/opentrons/hardware_control/backends/ot3simulator.py index 034531892d8..017c90c45b3 100644 --- a/api/src/opentrons/hardware_control/backends/ot3simulator.py +++ b/api/src/opentrons/hardware_control/backends/ot3simulator.py @@ -17,7 +17,7 @@ Mapping, ) -from opentrons.config.types import OT3Config, GantryLoad, OutputOptions +from opentrons.config.types import OT3Config, GantryLoad from opentrons.config import gripper_config from opentrons.hardware_control.module_control import AttachedModulesControl @@ -63,7 +63,8 @@ from opentrons.util.async_helpers import ensure_yield from .types import HWStopCondition from .flex_protocol import FlexBackend - +from opentrons_hardware.firmware_bindings.constants import SensorId +from opentrons_hardware.sensors.types import SensorDataType log = logging.getLogger(__name__) @@ -347,10 +348,11 @@ async def liquid_probe( threshold_pascals: float, plunger_impulse_time: float, num_baseline_reads: int, - output_format: OutputOptions = OutputOptions.can_bus_only, - data_files: Optional[Dict[InstrumentProbeType, str]] = None, probe: InstrumentProbeType = InstrumentProbeType.PRIMARY, force_both_sensors: bool = False, + response_queue: Optional[ + asyncio.Queue[Dict[SensorId, List[SensorDataType]]] + ] = None, ) -> float: z_axis = Axis.by_mount(mount) pos = self._position @@ -750,8 +752,6 @@ async def capacitive_probe( speed_mm_per_s: float, sensor_threshold_pf: float, probe: InstrumentProbeType = InstrumentProbeType.PRIMARY, - output_format: OutputOptions = OutputOptions.sync_only, - data_files: Optional[Dict[InstrumentProbeType, str]] = None, ) -> bool: self._position[moving] += distance_mm return True diff --git a/api/src/opentrons/hardware_control/ot3api.py b/api/src/opentrons/hardware_control/ot3api.py index 499592a10eb..856b755565c 100644 --- a/api/src/opentrons/hardware_control/ot3api.py +++ b/api/src/opentrons/hardware_control/ot3api.py @@ -143,7 +143,8 @@ from .backends.flex_protocol import FlexBackend from .backends.ot3simulator import OT3Simulator from .backends.errors import SubsystemUpdating - +from opentrons_hardware.firmware_bindings.constants import SensorId +from opentrons_hardware.sensors.types import SensorDataType mod_log = logging.getLogger(__name__) @@ -2643,6 +2644,9 @@ async def _liquid_probe_pass( probe: InstrumentProbeType, p_travel: float, force_both_sensors: bool = False, + response_queue: Optional[ + asyncio.Queue[Dict[SensorId, List[SensorDataType]]] + ] = None, ) -> float: plunger_direction = -1 if probe_settings.aspirate_while_sensing else 1 end_z = await self._backend.liquid_probe( @@ -2653,10 +2657,9 @@ async def _liquid_probe_pass( probe_settings.sensor_threshold_pascals, probe_settings.plunger_impulse_time, probe_settings.samples_for_baselining, - probe_settings.output_option, - probe_settings.data_files, probe=probe, force_both_sensors=force_both_sensors, + response_queue=response_queue, ) machine_pos = await self._backend.update_position() machine_pos[Axis.by_mount(mount)] = end_z @@ -2677,6 +2680,9 @@ async def liquid_probe( # noqa: C901 probe_settings: Optional[LiquidProbeSettings] = None, probe: Optional[InstrumentProbeType] = None, force_both_sensors: bool = False, + response_queue: Optional[ + asyncio.Queue[Dict[SensorId, List[SensorDataType]]] + ] = None, ) -> float: """Search for and return liquid level height. @@ -2802,6 +2808,8 @@ async def prep_plunger_for_probe_move( probe_settings, checked_probe, plunger_travel_mm + sensor_baseline_plunger_move_mm, + force_both_sensors, + response_queue, ) # if we made it here without an error we found the liquid error = None @@ -2870,8 +2878,6 @@ async def capacitive_probe( pass_settings.speed_mm_per_s, pass_settings.sensor_threshold_pf, probe, - pass_settings.output_option, - pass_settings.data_files, ) end_pos = await self.gantry_position(mount, refresh=True) if retract_after: diff --git a/api/src/opentrons/protocol_api/core/engine/module_core.py b/api/src/opentrons/protocol_api/core/engine/module_core.py index 47b49c54e23..1d800dee7ea 100644 --- a/api/src/opentrons/protocol_api/core/engine/module_core.py +++ b/api/src/opentrons/protocol_api/core/engine/module_core.py @@ -586,11 +586,20 @@ def initialize( ) self._initialized_value = wavelengths - def read(self) -> Optional[Dict[int, Dict[str, float]]]: - """Initiate a read on the Absorbance Reader, and return the results. During Analysis, this will return None.""" + def read(self, filename: Optional[str]) -> Dict[int, Dict[str, float]]: + """Initiate a read on the Absorbance Reader, and return the results. During Analysis, this will return a measurement of zero for all wells.""" + wavelengths = self._engine_client.state.modules.get_absorbance_reader_substate( + self.module_id + ).configured_wavelengths + if wavelengths is None: + raise CannotPerformModuleAction( + "Cannot perform Read action on Absorbance Reader without calling `.initialize(...)` first." + ) if self._initialized_value: self._engine_client.execute_command( - cmd.absorbance_reader.ReadAbsorbanceParams(moduleId=self.module_id) + cmd.absorbance_reader.ReadAbsorbanceParams( + moduleId=self.module_id, fileName=filename + ) ) if not self._engine_client.state.config.use_virtual_modules: read_result = ( @@ -603,7 +612,17 @@ def read(self) -> Optional[Dict[int, Dict[str, float]]]: raise CannotPerformModuleAction( "Absorbance Reader failed to return expected read result." ) - return None + + # When using virtual modules, return all zeroes + virtual_asbsorbance_result: Dict[int, Dict[str, float]] = {} + for wavelength in wavelengths: + converted_values = ( + self._engine_client.state.modules.convert_absorbance_reader_data_points( + data=[0] * 96 + ) + ) + virtual_asbsorbance_result[wavelength] = converted_values + return virtual_asbsorbance_result def close_lid( self, diff --git a/api/src/opentrons/protocol_api/core/module.py b/api/src/opentrons/protocol_api/core/module.py index 90abea1d0ec..c93e8ce8de8 100644 --- a/api/src/opentrons/protocol_api/core/module.py +++ b/api/src/opentrons/protocol_api/core/module.py @@ -365,7 +365,7 @@ def initialize( """Initialize the Absorbance Reader by taking zero reading.""" @abstractmethod - def read(self) -> Optional[Dict[int, Dict[str, float]]]: + def read(self, filename: Optional[str]) -> Dict[int, Dict[str, float]]: """Get an absorbance reading from the Absorbance Reader.""" @abstractmethod diff --git a/api/src/opentrons/protocol_api/module_contexts.py b/api/src/opentrons/protocol_api/module_contexts.py index 5d182843dcc..f7541da1836 100644 --- a/api/src/opentrons/protocol_api/module_contexts.py +++ b/api/src/opentrons/protocol_api/module_contexts.py @@ -1035,6 +1035,17 @@ def initialize( ) @requires_version(2, 21) - def read(self) -> Optional[Dict[int, Dict[str, float]]]: - """Initiate read on the Absorbance Reader. Returns a dictionary of wavelengths to dictionary of values ordered by well name.""" - return self._core.read() + def read(self, export_filename: Optional[str]) -> Dict[int, Dict[str, float]]: + """Initiate read on the Absorbance Reader. + + Returns a dictionary of wavelengths to dictionary of values ordered by well name. + + :param export_filename: Optional, if a filename is provided a CSV file will be saved + as a result of the read action containing measurement data. The filename will + be modified to include the wavelength used during measurement. If multiple + measurements are taken, then a file will be generated for each wavelength provided. + + Example: If `export_filename="my_data"` and wavelengths 450 and 531 are used during + measurement, the output files will be "my_data_450.csv" and "my_data_531.csv". + """ + return self._core.read(filename=export_filename) diff --git a/api/src/opentrons/protocol_engine/commands/absorbance_reader/read.py b/api/src/opentrons/protocol_engine/commands/absorbance_reader/read.py index b101cdb70b8..caf8a738f09 100644 --- a/api/src/opentrons/protocol_engine/commands/absorbance_reader/read.py +++ b/api/src/opentrons/protocol_engine/commands/absorbance_reader/read.py @@ -1,14 +1,22 @@ """Command models to read absorbance.""" from __future__ import annotations -from typing import Optional, Dict, TYPE_CHECKING +from datetime import datetime +from typing import Optional, Dict, TYPE_CHECKING, List from typing_extensions import Literal, Type from pydantic import BaseModel, Field from ..command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData -from ...errors import CannotPerformModuleAction +from ...errors import CannotPerformModuleAction, StorageLimitReachedError from ...errors.error_occurrence import ErrorOccurrence +from ...resources.file_provider import ( + PlateReaderData, + ReadData, + MAXIMUM_CSV_FILE_LIMIT, +) +from ...resources import FileProvider + if TYPE_CHECKING: from opentrons.protocol_engine.state.state import StateView from opentrons.protocol_engine.execution import EquipmentHandler @@ -21,6 +29,10 @@ class ReadAbsorbanceParams(BaseModel): """Input parameters for an absorbance reading.""" moduleId: str = Field(..., description="Unique ID of the Absorbance Reader.") + fileName: Optional[str] = Field( + None, + description="Optional file name to use when storing the results of a measurement.", + ) class ReadAbsorbanceResult(BaseModel): @@ -29,6 +41,10 @@ class ReadAbsorbanceResult(BaseModel): data: Optional[Dict[int, Dict[str, float]]] = Field( ..., description="Absorbance data points per wavelength." ) + fileIds: Optional[List[str]] = Field( + ..., + description="List of file IDs for files output as a result of a Read action.", + ) class ReadAbsorbanceImpl( @@ -40,18 +56,21 @@ def __init__( self, state_view: StateView, equipment: EquipmentHandler, + file_provider: FileProvider, **unused_dependencies: object, ) -> None: self._state_view = state_view self._equipment = equipment + self._file_provider = file_provider - async def execute( + async def execute( # noqa: C901 self, params: ReadAbsorbanceParams ) -> SuccessData[ReadAbsorbanceResult, None]: """Initiate an absorbance measurement.""" abs_reader_substate = self._state_view.modules.get_absorbance_reader_substate( module_id=params.moduleId ) + # Allow propagation of ModuleNotAttachedError. abs_reader = self._equipment.get_module_hardware_api( abs_reader_substate.module_id @@ -62,10 +81,29 @@ async def execute( "Cannot perform Read action on Absorbance Reader without calling `.initialize(...)` first." ) + # TODO: we need to return a file ID and increase the file count even when a moduel is not attached + if ( + params.fileName is not None + and abs_reader_substate.configured_wavelengths is not None + ): + # Validate that the amount of files we are about to generate does not put us higher than the limit + if ( + self._state_view.files.get_filecount() + + len(abs_reader_substate.configured_wavelengths) + > MAXIMUM_CSV_FILE_LIMIT + ): + raise StorageLimitReachedError( + message=f"Attempt to write file {params.fileName} exceeds file creation limit of {MAXIMUM_CSV_FILE_LIMIT} files." + ) + + asbsorbance_result: Dict[int, Dict[str, float]] = {} + transform_results = [] + # Handle the measurement and begin building data for return if abs_reader is not None: + start_time = datetime.now() results = await abs_reader.start_measure() + finish_time = datetime.now() if abs_reader._measurement_config is not None: - asbsorbance_result: Dict[int, Dict[str, float]] = {} sample_wavelengths = abs_reader._measurement_config.sample_wavelengths for wavelength, result in zip(sample_wavelengths, results): converted_values = ( @@ -74,13 +112,67 @@ async def execute( ) ) asbsorbance_result[wavelength] = converted_values + transform_results.append( + ReadData.construct(wavelength=wavelength, data=converted_values) + ) + # Handle the virtual module case for data creation (all zeroes) + elif self._state_view.config.use_virtual_modules: + start_time = finish_time = datetime.now() + if abs_reader_substate.configured_wavelengths is not None: + for wavelength in abs_reader_substate.configured_wavelengths: + converted_values = ( + self._state_view.modules.convert_absorbance_reader_data_points( + data=[0] * 96 + ) + ) + asbsorbance_result[wavelength] = converted_values + transform_results.append( + ReadData.construct(wavelength=wavelength, data=converted_values) + ) + else: + raise CannotPerformModuleAction( + "Plate Reader data cannot be requested with a module that has not been initialized." + ) + + # TODO (cb, 10-17-2024): FILE PROVIDER - Some day we may want to break the file provider behavior into a seperate API function. + # When this happens, we probably will to have the change the command results handler we utilize to track file IDs in engine. + # Today, the action handler for the FileStore looks for a ReadAbsorbanceResult command action, this will need to be delinked. + + # Begin interfacing with the file provider if the user provided a filename + file_ids = [] + if params.fileName is not None: + # Create the Plate Reader Transform + plate_read_result = PlateReaderData.construct( + read_results=transform_results, + reference_wavelength=abs_reader_substate.reference_wavelength, + start_time=start_time, + finish_time=finish_time, + serial_number=abs_reader.serial_number + if (abs_reader is not None and abs_reader.serial_number is not None) + else "VIRTUAL_SERIAL", + ) + + if isinstance(plate_read_result, PlateReaderData): + # Write a CSV file for each of the measurements taken + for measurement in plate_read_result.read_results: + file_id = await self._file_provider.write_csv( + write_data=plate_read_result.build_generic_csv( + filename=params.fileName, + measurement=measurement, + ) + ) + file_ids.append(file_id) + + # Return success data to api return SuccessData( - public=ReadAbsorbanceResult(data=asbsorbance_result), + public=ReadAbsorbanceResult( + data=asbsorbance_result, fileIds=file_ids + ), private=None, ) return SuccessData( - public=ReadAbsorbanceResult(data=None), + public=ReadAbsorbanceResult(data=asbsorbance_result, fileIds=file_ids), private=None, ) diff --git a/api/src/opentrons/protocol_engine/commands/command.py b/api/src/opentrons/protocol_engine/commands/command.py index 759606899c0..9ba9404af1f 100644 --- a/api/src/opentrons/protocol_engine/commands/command.py +++ b/api/src/opentrons/protocol_engine/commands/command.py @@ -274,6 +274,7 @@ def __init__( state_view: StateView, hardware_api: HardwareControlAPI, equipment: execution.EquipmentHandler, + file_provider: execution.FileProvider, movement: execution.MovementHandler, gantry_mover: execution.GantryMover, labware_movement: execution.LabwareMovementHandler, diff --git a/api/src/opentrons/protocol_engine/commands/prepare_to_aspirate.py b/api/src/opentrons/protocol_engine/commands/prepare_to_aspirate.py index d427b38dc1e..d63e42a7f90 100644 --- a/api/src/opentrons/protocol_engine/commands/prepare_to_aspirate.py +++ b/api/src/opentrons/protocol_engine/commands/prepare_to_aspirate.py @@ -1,18 +1,28 @@ """Prepare to aspirate command request, result, and implementation models.""" from __future__ import annotations +from opentrons_shared_data.errors.exceptions import PipetteOverpressureError from pydantic import BaseModel -from typing import TYPE_CHECKING, Optional, Type +from typing import TYPE_CHECKING, Optional, Type, Union from typing_extensions import Literal from .pipetting_common import ( + OverpressureError, PipetteIdMixin, ) -from .command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData +from .command import ( + AbstractCommandImpl, + BaseCommand, + BaseCommandCreate, + DefinedErrorData, + SuccessData, +) from ..errors.error_occurrence import ErrorOccurrence if TYPE_CHECKING: - from ..execution.pipetting import PipettingHandler + from ..execution import PipettingHandler, GantryMover + from ..resources import ModelUtils + PrepareToAspirateCommandType = Literal["prepareToAspirate"] @@ -29,25 +39,60 @@ class PrepareToAspirateResult(BaseModel): pass +_ExecuteReturn = Union[ + SuccessData[PrepareToAspirateResult, None], + DefinedErrorData[OverpressureError], +] + + class PrepareToAspirateImplementation( - AbstractCommandImpl[ - PrepareToAspirateParams, SuccessData[PrepareToAspirateResult, None] - ] + AbstractCommandImpl[PrepareToAspirateParams, _ExecuteReturn] ): """Prepare for aspirate command implementation.""" - def __init__(self, pipetting: PipettingHandler, **kwargs: object) -> None: + def __init__( + self, + pipetting: PipettingHandler, + model_utils: ModelUtils, + gantry_mover: GantryMover, + **kwargs: object, + ) -> None: self._pipetting_handler = pipetting + self._model_utils = model_utils + self._gantry_mover = gantry_mover - async def execute( - self, params: PrepareToAspirateParams - ) -> SuccessData[PrepareToAspirateResult, None]: + async def execute(self, params: PrepareToAspirateParams) -> _ExecuteReturn: """Prepare the pipette to aspirate.""" - await self._pipetting_handler.prepare_for_aspirate( - pipette_id=params.pipetteId, - ) - - return SuccessData(public=PrepareToAspirateResult(), private=None) + current_position = await self._gantry_mover.get_position(params.pipetteId) + try: + await self._pipetting_handler.prepare_for_aspirate( + pipette_id=params.pipetteId, + ) + except PipetteOverpressureError as e: + return DefinedErrorData( + public=OverpressureError( + id=self._model_utils.generate_id(), + createdAt=self._model_utils.get_timestamp(), + wrappedErrors=[ + ErrorOccurrence.from_failed( + id=self._model_utils.generate_id(), + createdAt=self._model_utils.get_timestamp(), + error=e, + ) + ], + errorInfo=( + { + "retryLocation": ( + current_position.x, + current_position.y, + current_position.z, + ) + } + ), + ), + ) + else: + return SuccessData(public=PrepareToAspirateResult(), private=None) class PrepareToAspirate( diff --git a/api/src/opentrons/protocol_engine/create_protocol_engine.py b/api/src/opentrons/protocol_engine/create_protocol_engine.py index d3d50da14df..dc66591eff2 100644 --- a/api/src/opentrons/protocol_engine/create_protocol_engine.py +++ b/api/src/opentrons/protocol_engine/create_protocol_engine.py @@ -10,7 +10,7 @@ from opentrons_shared_data.robot import load as load_robot from .protocol_engine import ProtocolEngine -from .resources import DeckDataProvider, ModuleDataProvider +from .resources import DeckDataProvider, ModuleDataProvider, FileProvider from .state.config import Config from .state.state import StateStore from .types import PostRunHardwareState, DeckConfigurationType @@ -26,6 +26,7 @@ async def create_protocol_engine( error_recovery_policy: ErrorRecoveryPolicy, load_fixed_trash: bool = False, deck_configuration: typing.Optional[DeckConfigurationType] = None, + file_provider: typing.Optional[FileProvider] = None, notify_publishers: typing.Optional[typing.Callable[[], None]] = None, ) -> ProtocolEngine: """Create a ProtocolEngine instance. @@ -37,6 +38,7 @@ async def create_protocol_engine( See documentation on `ErrorRecoveryPolicy`. load_fixed_trash: Automatically load fixed trash labware in engine. deck_configuration: The initial deck configuration the engine will be instantiated with. + file_provider: Provides access to robot server file writing procedures for protocol output. notify_publishers: Notifies robot server publishers of internal state change. """ deck_data = DeckDataProvider(config.deck_type) @@ -47,6 +49,7 @@ async def create_protocol_engine( module_calibration_offsets = ModuleDataProvider.load_module_calibrations() robot_definition = load_robot(config.robot_type) + state_store = StateStore( config=config, deck_definition=deck_definition, @@ -62,6 +65,7 @@ async def create_protocol_engine( return ProtocolEngine( state_store=state_store, hardware_api=hardware_api, + file_provider=file_provider, ) @@ -70,6 +74,7 @@ def create_protocol_engine_in_thread( hardware_api: HardwareControlAPI, config: Config, deck_configuration: typing.Optional[DeckConfigurationType], + file_provider: typing.Optional[FileProvider], error_recovery_policy: ErrorRecoveryPolicy, drop_tips_after_run: bool, post_run_hardware_state: PostRunHardwareState, @@ -97,6 +102,7 @@ def create_protocol_engine_in_thread( with async_context_manager_in_thread( _protocol_engine( hardware_api, + file_provider, config, deck_configuration, error_recovery_policy, @@ -114,6 +120,7 @@ def create_protocol_engine_in_thread( @contextlib.asynccontextmanager async def _protocol_engine( hardware_api: HardwareControlAPI, + file_provider: typing.Optional[FileProvider], config: Config, deck_configuration: typing.Optional[DeckConfigurationType], error_recovery_policy: ErrorRecoveryPolicy, @@ -123,6 +130,7 @@ async def _protocol_engine( ) -> typing.AsyncGenerator[ProtocolEngine, None]: protocol_engine = await create_protocol_engine( hardware_api=hardware_api, + file_provider=file_provider, config=config, error_recovery_policy=error_recovery_policy, load_fixed_trash=load_fixed_trash, diff --git a/api/src/opentrons/protocol_engine/engine_support.py b/api/src/opentrons/protocol_engine/engine_support.py index 9d6bdcbdd69..b822b97914d 100644 --- a/api/src/opentrons/protocol_engine/engine_support.py +++ b/api/src/opentrons/protocol_engine/engine_support.py @@ -6,7 +6,8 @@ def create_run_orchestrator( - hardware_api: HardwareControlAPI, protocol_engine: ProtocolEngine + hardware_api: HardwareControlAPI, + protocol_engine: ProtocolEngine, ) -> RunOrchestrator: """Create a RunOrchestrator instance.""" return RunOrchestrator( diff --git a/api/src/opentrons/protocol_engine/errors/__init__.py b/api/src/opentrons/protocol_engine/errors/__init__.py index 304f7db1fff..9bbe3aae9b8 100644 --- a/api/src/opentrons/protocol_engine/errors/__init__.py +++ b/api/src/opentrons/protocol_engine/errors/__init__.py @@ -75,6 +75,7 @@ IncompleteWellDefinitionError, OperationLocationNotInWellError, InvalidDispenseVolumeError, + StorageLimitReachedError, ) from .error_occurrence import ErrorOccurrence, ProtocolCommandFailedError @@ -158,4 +159,5 @@ "IncompleteWellDefinitionError", "OperationLocationNotInWellError", "InvalidDispenseVolumeError", + "StorageLimitReachedError", ] diff --git a/api/src/opentrons/protocol_engine/errors/exceptions.py b/api/src/opentrons/protocol_engine/errors/exceptions.py index dd9dc6e1d51..5656942b338 100644 --- a/api/src/opentrons/protocol_engine/errors/exceptions.py +++ b/api/src/opentrons/protocol_engine/errors/exceptions.py @@ -1108,3 +1108,16 @@ def __init__( ) -> None: """Build an OperationLocationNotInWellError.""" super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + + +class StorageLimitReachedError(ProtocolEngineError): + """Raised to indicate that a file cannot be created due to storage limitations.""" + + def __init__( + self, + message: Optional[str] = None, + detail: Optional[Dict[str, str]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build an StorageLimitReached.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, detail, wrapping) diff --git a/api/src/opentrons/protocol_engine/execution/__init__.py b/api/src/opentrons/protocol_engine/execution/__init__.py index 80f2dfd0d99..482a16d787f 100644 --- a/api/src/opentrons/protocol_engine/execution/__init__.py +++ b/api/src/opentrons/protocol_engine/execution/__init__.py @@ -21,6 +21,7 @@ from .hardware_stopper import HardwareStopper from .door_watcher import DoorWatcher from .status_bar import StatusBarHandler +from ..resources.file_provider import FileProvider # .thermocycler_movement_flagger omitted from package's public interface. @@ -45,4 +46,5 @@ "DoorWatcher", "RailLightsHandler", "StatusBarHandler", + "FileProvider", ] diff --git a/api/src/opentrons/protocol_engine/execution/command_executor.py b/api/src/opentrons/protocol_engine/execution/command_executor.py index e9dd2ec73b9..1d30b8756d2 100644 --- a/api/src/opentrons/protocol_engine/execution/command_executor.py +++ b/api/src/opentrons/protocol_engine/execution/command_executor.py @@ -14,7 +14,7 @@ from opentrons.protocol_engine.commands.command import SuccessData from ..state.state import StateStore -from ..resources import ModelUtils +from ..resources import ModelUtils, FileProvider from ..commands import CommandStatus from ..actions import ( ActionDispatcher, @@ -72,6 +72,7 @@ class CommandExecutor: def __init__( self, hardware_api: HardwareControlAPI, + file_provider: FileProvider, state_store: StateStore, action_dispatcher: ActionDispatcher, equipment: EquipmentHandler, @@ -88,6 +89,7 @@ def __init__( ) -> None: """Initialize the CommandExecutor with access to its dependencies.""" self._hardware_api = hardware_api + self._file_provider = file_provider self._state_store = state_store self._action_dispatcher = action_dispatcher self._equipment = equipment @@ -116,6 +118,7 @@ async def execute(self, command_id: str) -> None: command_impl = queued_command._ImplementationCls( state_view=self._state_store, hardware_api=self._hardware_api, + file_provider=self._file_provider, equipment=self._equipment, movement=self._movement, gantry_mover=self._gantry_mover, diff --git a/api/src/opentrons/protocol_engine/execution/create_queue_worker.py b/api/src/opentrons/protocol_engine/execution/create_queue_worker.py index e449a013008..e37a2c0716b 100644 --- a/api/src/opentrons/protocol_engine/execution/create_queue_worker.py +++ b/api/src/opentrons/protocol_engine/execution/create_queue_worker.py @@ -6,6 +6,7 @@ from ..state.state import StateStore from ..actions import ActionDispatcher +from ..resources import FileProvider from .equipment import EquipmentHandler from .movement import MovementHandler from .gantry_mover import create_gantry_mover @@ -20,6 +21,7 @@ def create_queue_worker( hardware_api: HardwareControlAPI, + file_provider: FileProvider, state_store: StateStore, action_dispatcher: ActionDispatcher, command_generator: Callable[[], AsyncGenerator[str, None]], @@ -28,6 +30,7 @@ def create_queue_worker( Arguments: hardware_api: Hardware control API to pass down to dependencies. + file_provider: Provides access to robot server file writing procedures for protocol output. state_store: StateStore to pass down to dependencies. action_dispatcher: ActionDispatcher to pass down to dependencies. error_recovery_policy: ErrorRecoveryPolicy to pass down to dependencies. @@ -78,6 +81,7 @@ def create_queue_worker( command_executor = CommandExecutor( hardware_api=hardware_api, + file_provider=file_provider, state_store=state_store, action_dispatcher=action_dispatcher, equipment=equipment_handler, diff --git a/api/src/opentrons/protocol_engine/protocol_engine.py b/api/src/opentrons/protocol_engine/protocol_engine.py index c5219e889a3..d93ab5dd42d 100644 --- a/api/src/opentrons/protocol_engine/protocol_engine.py +++ b/api/src/opentrons/protocol_engine/protocol_engine.py @@ -20,7 +20,7 @@ from .errors import ProtocolCommandFailedError, ErrorOccurrence, CommandNotAllowedError from .errors.exceptions import EStopActivatedError from . import commands, slot_standardization -from .resources import ModelUtils, ModuleDataProvider +from .resources import ModelUtils, ModuleDataProvider, FileProvider from .types import ( LabwareOffset, LabwareOffsetCreate, @@ -95,6 +95,7 @@ def __init__( hardware_stopper: Optional[HardwareStopper] = None, door_watcher: Optional[DoorWatcher] = None, module_data_provider: Optional[ModuleDataProvider] = None, + file_provider: Optional[FileProvider] = None, ) -> None: """Initialize a ProtocolEngine instance. @@ -104,6 +105,7 @@ def __init__( Prefer the `create_protocol_engine()` factory function. """ self._hardware_api = hardware_api + self._file_provider = file_provider or FileProvider() self._state_store = state_store self._model_utils = model_utils or ModelUtils() self._action_dispatcher = action_dispatcher or ActionDispatcher( @@ -616,6 +618,7 @@ def set_and_start_queue_worker( assert self._queue_worker is None self._queue_worker = create_queue_worker( hardware_api=self._hardware_api, + file_provider=self._file_provider, state_store=self._state_store, action_dispatcher=self._action_dispatcher, command_generator=command_generator, diff --git a/api/src/opentrons/protocol_engine/resources/__init__.py b/api/src/opentrons/protocol_engine/resources/__init__.py index 94b71831589..a77075c95bb 100644 --- a/api/src/opentrons/protocol_engine/resources/__init__.py +++ b/api/src/opentrons/protocol_engine/resources/__init__.py @@ -9,6 +9,7 @@ from .deck_data_provider import DeckDataProvider, DeckFixedLabware from .labware_data_provider import LabwareDataProvider from .module_data_provider import ModuleDataProvider +from .file_provider import FileProvider from .ot3_validation import ensure_ot3_hardware @@ -18,6 +19,7 @@ "DeckDataProvider", "DeckFixedLabware", "ModuleDataProvider", + "FileProvider", "ensure_ot3_hardware", "pipette_data_provider", "labware_validation", diff --git a/api/src/opentrons/protocol_engine/resources/file_provider.py b/api/src/opentrons/protocol_engine/resources/file_provider.py new file mode 100644 index 00000000000..d4ed7b71522 --- /dev/null +++ b/api/src/opentrons/protocol_engine/resources/file_provider.py @@ -0,0 +1,157 @@ +"""File interaction resource provider.""" +from datetime import datetime +from typing import List, Optional, Callable, Awaitable, Dict +from pydantic import BaseModel +from ..errors import StorageLimitReachedError + + +MAXIMUM_CSV_FILE_LIMIT = 40 + + +class GenericCsvTransform: + """Generic CSV File Type data for rows of data to be seperated by a delimeter.""" + + filename: str + rows: List[List[str]] + delimiter: str = "," + + @staticmethod + def build( + filename: str, rows: List[List[str]], delimiter: str = "," + ) -> "GenericCsvTransform": + """Build a Generic CSV datatype class.""" + if "." in filename and not filename.endswith(".csv"): + raise ValueError( + f"Provided filename {filename} invalid. Only CSV file format is accepted." + ) + elif "." not in filename: + filename = f"{filename}.csv" + csv = GenericCsvTransform() + csv.filename = filename + csv.rows = rows + csv.delimiter = delimiter + return csv + + +class ReadData(BaseModel): + """Read Data type containing the wavelength for a Plate Reader read alongside the Measurement Data of that read.""" + + wavelength: int + data: Dict[str, float] + + +class PlateReaderData(BaseModel): + """Data from a Opentrons Plate Reader Read. Can be converted to CSV template format.""" + + read_results: List[ReadData] + reference_wavelength: Optional[int] = None + start_time: datetime + finish_time: datetime + serial_number: str + + def build_generic_csv( # noqa: C901 + self, filename: str, measurement: ReadData + ) -> GenericCsvTransform: + """Builds a CSV compatible object containing Plate Reader Measurements. + + This will also automatically reformat the provided filename to include the wavelength of those measurements. + """ + plate_alpharows = ["A", "B", "C", "D", "E", "F", "G", "H"] + rows = [] + + rows.append(["", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"]) + for i in range(8): + row = [plate_alpharows[i]] + for j in range(12): + row.append(str(measurement.data[f"{plate_alpharows[i]}{j+1}"])) + rows.append(row) + for i in range(3): + rows.append([""]) + rows.append(["", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"]) + for i in range(8): + row = [plate_alpharows[i]] + for j in range(12): + row.append("") + rows.append(row) + for i in range(3): + rows.append([""]) + rows.append( + [ + "", + "ID", + "Well", + "Absorbance (OD)", + "Mean Absorbance (OD)", + "Absorbance %CV", + ] + ) + for i in range(3): + rows.append([""]) + rows.append( + [ + "", + "ID", + "Well", + "Absorbance (OD)", + "Mean Absorbance (OD)", + "Dilution Factor", + "Absorbance %CV", + ] + ) + rows.append(["1", "Sample 1", "", "", "", "1", "", "", "", "", "", ""]) + for i in range(3): + rows.append([""]) + + # end of file metadata + rows.append(["Protocol"]) + rows.append(["Assay"]) + rows.append(["Sample Wavelength (nm)", str(measurement.wavelength)]) + if self.reference_wavelength is not None: + rows.append(["Reference Wavelength (nm)", str(self.reference_wavelength)]) + rows.append(["Serial No.", self.serial_number]) + rows.append(["Measurement started at", str(self.start_time)]) + rows.append(["Measurement finished at", str(self.finish_time)]) + + # Ensure the filename adheres to ruleset contains the wavelength for a given measurement + if filename.endswith(".csv"): + filename = filename[:-4] + filename = filename + "_" + str(measurement.wavelength) + ".csv" + + return GenericCsvTransform.build( + filename=filename, + rows=rows, + delimiter=",", + ) + + +class FileProvider: + """Provider class to wrap file read write interactions to the data files directory in the engine.""" + + def __init__( + self, + data_files_write_csv_callback: Optional[ + Callable[[GenericCsvTransform], Awaitable[str]] + ] = None, + data_files_filecount: Optional[Callable[[], Awaitable[int]]] = None, + ) -> None: + """Initialize the interface callbacks of the File Provider for data file handling within the Protocol Engine. + + Params: + data_files_write_csv_callback: Callback to write a CSV file to the data files directory and add it to the database. + data_files_filecount: Callback to check the amount of data files already present in the data files directory. + """ + self._data_files_write_csv_callback = data_files_write_csv_callback + self._data_files_filecount = data_files_filecount + + async def write_csv(self, write_data: GenericCsvTransform) -> str: + """Writes the provided CSV object to a file in the Data Files directory. Returns the File ID of the file created.""" + if self._data_files_filecount is not None: + file_count = await self._data_files_filecount() + if file_count >= MAXIMUM_CSV_FILE_LIMIT: + raise StorageLimitReachedError( + f"Not enough space to store file {write_data.filename}." + ) + if self._data_files_write_csv_callback is not None: + return await self._data_files_write_csv_callback(write_data) + # If we are in an analysis or simulation state, return an empty file ID + return "" diff --git a/api/src/opentrons/protocol_engine/state/files.py b/api/src/opentrons/protocol_engine/state/files.py new file mode 100644 index 00000000000..655d038df34 --- /dev/null +++ b/api/src/opentrons/protocol_engine/state/files.py @@ -0,0 +1,59 @@ +"""Basic protocol engine create file data state and store.""" +from dataclasses import dataclass +from typing import List + +from ._abstract_store import HasState, HandlesActions +from ..actions import Action, SucceedCommandAction +from ..commands import ( + Command, + absorbance_reader, +) + + +@dataclass +class FileState: + """State of Engine created files.""" + + file_ids: List[str] + + +class FileStore(HasState[FileState], HandlesActions): + """File state container.""" + + _state: FileState + + def __init__(self) -> None: + """Initialize a File store and its state.""" + self._state = FileState(file_ids=[]) + + def handle_action(self, action: Action) -> None: + """Modify state in reaction to an action.""" + if isinstance(action, SucceedCommandAction): + self._handle_command(action.command) + + def _handle_command(self, command: Command) -> None: + if isinstance(command.result, absorbance_reader.ReadAbsorbanceResult): + if command.result.fileIds is not None: + self._state.file_ids.extend(command.result.fileIds) + + +class FileView(HasState[FileState]): + """Read-only engine created file state view.""" + + _state: FileState + + def __init__(self, state: FileState) -> None: + """Initialize the view of file state. + + Arguments: + state: File state dataclass used for tracking file creation status. + """ + self._state = state + + def get_filecount(self) -> int: + """Get the number of files currently created by the protocol.""" + return len(self._state.file_ids) + + def get_file_id_list(self) -> List[str]: + """Get the list of files by file ID created by the protocol.""" + return self._state.file_ids diff --git a/api/src/opentrons/protocol_engine/state/frustum_helpers.py b/api/src/opentrons/protocol_engine/state/frustum_helpers.py index 09f726de767..dfdb0eec56f 100644 --- a/api/src/opentrons/protocol_engine/state/frustum_helpers.py +++ b/api/src/opentrons/protocol_engine/state/frustum_helpers.py @@ -11,6 +11,7 @@ SphericalSegment, ConicalFrustum, CuboidalFrustum, + SquaredConeSegment, ) @@ -127,6 +128,15 @@ def _volume_from_height_spherical( return volume +def _volume_from_height_squared_cone( + target_height: float, segment: SquaredConeSegment +) -> float: + """Find the volume given a height within a squared cone segment.""" + heights = segment.height_to_volume_table.keys() + best_fit_height = min(heights, key=lambda x: abs(x - target_height)) + return segment.height_to_volume_table[best_fit_height] + + def _height_from_volume_circular( volume: float, total_frustum_height: float, @@ -197,7 +207,17 @@ def _height_from_volume_spherical( return height +def _height_from_volume_squared_cone( + target_volume: float, segment: SquaredConeSegment +) -> float: + """Find the height given a volume within a squared cone segment.""" + volumes = segment.volume_to_height_table.keys() + best_fit_volume = min(volumes, key=lambda x: abs(x - target_volume)) + return segment.volume_to_height_table[best_fit_volume] + + def _get_segment_capacity(segment: WellSegment) -> float: + section_height = segment.topHeight - segment.bottomHeight match segment: case SphericalSegment(): return _volume_from_height_spherical( @@ -205,7 +225,6 @@ def _get_segment_capacity(segment: WellSegment) -> float: radius_of_curvature=segment.radiusOfCurvature, ) case CuboidalFrustum(): - section_height = segment.topHeight - segment.bottomHeight return _volume_from_height_rectangular( target_height=section_height, bottom_length=segment.bottomYDimension, @@ -215,13 +234,14 @@ def _get_segment_capacity(segment: WellSegment) -> float: total_frustum_height=section_height, ) case ConicalFrustum(): - section_height = segment.topHeight - segment.bottomHeight return _volume_from_height_circular( target_height=section_height, total_frustum_height=section_height, bottom_radius=(segment.bottomDiameter / 2), top_radius=(segment.topDiameter / 2), ) + case SquaredConeSegment(): + return _volume_from_height_squared_cone(section_height, segment) case _: # TODO: implement volume calculations for truncated circular and rounded rectangular segments raise NotImplementedError( @@ -275,6 +295,8 @@ def height_at_volume_within_section( top_width=section.topXDimension, top_length=section.topYDimension, ) + case SquaredConeSegment(): + return _height_from_volume_squared_cone(target_volume_relative, section) case _: raise NotImplementedError( "Height from volume calculation not yet implemented for this well shape." @@ -309,6 +331,8 @@ def volume_at_height_within_section( top_width=section.topXDimension, top_length=section.topYDimension, ) + case SquaredConeSegment(): + return _volume_from_height_squared_cone(target_height_relative, section) case _: # TODO(cm): this would be the NEST-96 2uL wells referenced in EXEC-712 # we need to input the math attached to that issue diff --git a/api/src/opentrons/protocol_engine/state/state.py b/api/src/opentrons/protocol_engine/state/state.py index 7fc23a8ee2f..6743e1f44fc 100644 --- a/api/src/opentrons/protocol_engine/state/state.py +++ b/api/src/opentrons/protocol_engine/state/state.py @@ -29,6 +29,7 @@ from .wells import WellState, WellView, WellStore from .geometry import GeometryView from .motion import MotionView +from .files import FileView, FileState, FileStore from .config import Config from .state_summary import StateSummary from ..types import DeckConfigurationType @@ -50,6 +51,7 @@ class State: liquids: LiquidState tips: TipState wells: WellState + files: FileState class StateView(HasState[State]): @@ -66,6 +68,7 @@ class StateView(HasState[State]): _wells: WellView _geometry: GeometryView _motion: MotionView + _files: FileView _config: Config @property @@ -118,6 +121,11 @@ def motion(self) -> MotionView: """Get state view selectors for derived motion state.""" return self._motion + @property + def files(self) -> FileView: + """Get state view selectors for engine create file state.""" + return self._files + @property def config(self) -> Config: """Get ProtocolEngine configuration.""" @@ -139,6 +147,7 @@ def get_summary(self) -> StateSummary: liquids=self._liquid.get_all(), wells=self._wells.get_all(), hasEverEnteredErrorRecovery=self._commands.get_has_entered_recovery_mode(), + files=self._state.files.file_ids, ) @@ -206,6 +215,7 @@ def __init__( self._liquid_store = LiquidStore() self._tip_store = TipStore() self._well_store = WellStore() + self._file_store = FileStore() self._substores: List[HandlesActions] = [ self._command_store, @@ -216,6 +226,7 @@ def __init__( self._liquid_store, self._tip_store, self._well_store, + self._file_store, ] self._config = config self._change_notifier = change_notifier or ChangeNotifier() @@ -333,6 +344,7 @@ def _get_next_state(self) -> State: liquids=self._liquid_store.state, tips=self._tip_store.state, wells=self._well_store.state, + files=self._file_store.state, ) def _initialize_state(self) -> None: @@ -349,6 +361,7 @@ def _initialize_state(self) -> None: self._liquid = LiquidView(state.liquids) self._tips = TipView(state.tips) self._wells = WellView(state.wells) + self._files = FileView(state.files) # Derived states self._geometry = GeometryView( diff --git a/api/src/opentrons/protocol_engine/state/state_summary.py b/api/src/opentrons/protocol_engine/state/state_summary.py index 66fc4249851..b1c4dd8f766 100644 --- a/api/src/opentrons/protocol_engine/state/state_summary.py +++ b/api/src/opentrons/protocol_engine/state/state_summary.py @@ -31,3 +31,4 @@ class StateSummary(BaseModel): completedAt: Optional[datetime] liquids: List[Liquid] = Field(default_factory=list) wells: List[LiquidHeightSummary] = Field(default_factory=list) + files: List[str] = Field(default_factory=list) diff --git a/api/src/opentrons/protocol_runner/run_orchestrator.py b/api/src/opentrons/protocol_runner/run_orchestrator.py index 697e4a14e3a..69d9feaf524 100644 --- a/api/src/opentrons/protocol_runner/run_orchestrator.py +++ b/api/src/opentrons/protocol_runner/run_orchestrator.py @@ -257,6 +257,22 @@ def get_current_command(self) -> Optional[CommandPointer]: """Get the "current" command, if any.""" return self._protocol_engine.state_view.commands.get_current() + def get_most_recently_finalized_command(self) -> Optional[CommandPointer]: + """Get the most recently finalized command, if any.""" + most_recently_finalized_command = ( + self._protocol_engine.state_view.commands.get_most_recently_finalized_command() + ) + return ( + CommandPointer( + command_id=most_recently_finalized_command.command.id, + command_key=most_recently_finalized_command.command.key, + created_at=most_recently_finalized_command.command.createdAt, + index=most_recently_finalized_command.index, + ) + if most_recently_finalized_command + else None + ) + def get_command_slice( self, cursor: Optional[int], length: int, include_fixit_commands: bool ) -> CommandSlice: diff --git a/api/src/opentrons/simulate.py b/api/src/opentrons/simulate.py index 23f6c7fdfb9..e565bab83e0 100644 --- a/api/src/opentrons/simulate.py +++ b/api/src/opentrons/simulate.py @@ -815,6 +815,7 @@ def _create_live_context_pe( robot_type, use_pe_virtual_hardware=use_pe_virtual_hardware ), deck_configuration=None, + file_provider=None, error_recovery_policy=error_recovery_policy.never_recover, drop_tips_after_run=False, post_run_hardware_state=PostRunHardwareState.STAY_ENGAGED_IN_PLACE, diff --git a/api/src/opentrons/util/logging_config.py b/api/src/opentrons/util/logging_config.py index e9a4d2042a2..944f4d3d5ed 100644 --- a/api/src/opentrons/util/logging_config.py +++ b/api/src/opentrons/util/logging_config.py @@ -5,10 +5,13 @@ from opentrons.config import CONFIG, ARCHITECTURE, SystemArchitecture +from opentrons_hardware.sensors import SENSOR_LOG_NAME + def _host_config(level_value: int) -> Dict[str, Any]: serial_log_filename = CONFIG["serial_log_file"] api_log_filename = CONFIG["api_log_file"] + sensor_log_filename = CONFIG["sensor_log_file"] return { "version": 1, "disable_existing_loggers": False, @@ -41,6 +44,14 @@ def _host_config(level_value: int) -> Dict[str, Any]: "level": logging.DEBUG, "backupCount": 5, }, + "sensor": { + "class": "logging.handlers.RotatingFileHandler", + "formatter": "basic", + "filename": sensor_log_filename, + "maxBytes": 1000000, + "level": logging.DEBUG, + "backupCount": 5, + }, }, "loggers": { "opentrons": { @@ -66,6 +77,11 @@ def _host_config(level_value: int) -> Dict[str, Any]: "level": logging.DEBUG, "propagate": False, }, + SENSOR_LOG_NAME: { + "handlers": ["sensor"], + "level": logging.DEBUG, + "propagate": False, + }, "__main__": {"handlers": ["api"], "level": level_value}, }, } @@ -75,6 +91,7 @@ def _buildroot_config(level_value: int) -> Dict[str, Any]: # Import systemd.journald here since it is generally unavailble on non # linux systems and we probably don't want to use it on linux desktops # either + sensor_log_filename = CONFIG["sensor_log_file"] return { "version": 1, "disable_existing_loggers": False, @@ -106,6 +123,14 @@ def _buildroot_config(level_value: int) -> Dict[str, Any]: "formatter": "message_only", "SYSLOG_IDENTIFIER": "opentrons-api-serial-usbbin", }, + "sensor": { + "class": "logging.handlers.RotatingFileHandler", + "formatter": "basic", + "filename": sensor_log_filename, + "maxBytes": 1000000, + "level": logging.DEBUG, + "backupCount": 3, + }, }, "loggers": { "opentrons.drivers.asyncio.communication.serial_connection": { @@ -131,6 +156,11 @@ def _buildroot_config(level_value: int) -> Dict[str, Any]: "level": logging.DEBUG, "propagate": False, }, + SENSOR_LOG_NAME: { + "handlers": ["sensor"], + "level": logging.DEBUG, + "propagate": False, + }, "__main__": {"handlers": ["api"], "level": level_value}, }, } diff --git a/api/tests/opentrons/config/ot3_settings.py b/api/tests/opentrons/config/ot3_settings.py index 38353c05a3c..04370fd6c09 100644 --- a/api/tests/opentrons/config/ot3_settings.py +++ b/api/tests/opentrons/config/ot3_settings.py @@ -1,5 +1,3 @@ -from opentrons.config.types import OutputOptions - ot3_dummy_settings = { "name": "Marie Curie", "model": "OT-3 Standard", @@ -122,13 +120,11 @@ "plunger_speed": 10, "plunger_impulse_time": 0.2, "sensor_threshold_pascals": 17, - "output_option": OutputOptions.stream_to_csv, "aspirate_while_sensing": False, "z_overlap_between_passes_mm": 0.1, "plunger_reset_offset": 2.0, "samples_for_baselining": 20, "sample_time_sec": 0.004, - "data_files": {"PRIMARY": "/data/pressure_sensor_data.csv"}, }, "calibration": { "z_offset": { @@ -137,8 +133,6 @@ "max_overrun_distance_mm": 2, "speed_mm_per_s": 3, "sensor_threshold_pf": 4, - "output_option": OutputOptions.sync_only, - "data_files": None, }, }, "edge_sense": { @@ -149,8 +143,6 @@ "max_overrun_distance_mm": 5, "speed_mm_per_s": 6, "sensor_threshold_pf": 7, - "output_option": OutputOptions.sync_only, - "data_files": None, }, "search_initial_tolerance_mm": 18, "search_iteration_limit": 3, diff --git a/api/tests/opentrons/conftest.py b/api/tests/opentrons/conftest.py index a52e95248c0..cf8fdd0e97c 100755 --- a/api/tests/opentrons/conftest.py +++ b/api/tests/opentrons/conftest.py @@ -335,6 +335,7 @@ def _make_ot3_pe_ctx( block_on_door_open=False, ), deck_configuration=None, + file_provider=None, error_recovery_policy=error_recovery_policy.never_recover, drop_tips_after_run=False, post_run_hardware_state=PostRunHardwareState.STAY_ENGAGED_IN_PLACE, diff --git a/api/tests/opentrons/hardware_control/backends/test_ot3_controller.py b/api/tests/opentrons/hardware_control/backends/test_ot3_controller.py index ac25d19a3e2..5ffee581de4 100644 --- a/api/tests/opentrons/hardware_control/backends/test_ot3_controller.py +++ b/api/tests/opentrons/hardware_control/backends/test_ot3_controller.py @@ -39,7 +39,6 @@ OT3Config, GantryLoad, LiquidProbeSettings, - OutputOptions, ) from opentrons.config.robot_configs import build_config_ot3 from opentrons_hardware.firmware_bindings.arbitration_id import ArbitrationId @@ -61,7 +60,6 @@ UpdateState, EstopState, CurrentConfig, - InstrumentProbeType, ) from opentrons.hardware_control.errors import ( InvalidPipetteName, @@ -180,13 +178,11 @@ def fake_liquid_settings() -> LiquidProbeSettings: plunger_speed=10, plunger_impulse_time=0.2, sensor_threshold_pascals=15, - output_option=OutputOptions.can_bus_only, aspirate_while_sensing=False, z_overlap_between_passes_mm=0.1, plunger_reset_offset=2.0, samples_for_baselining=20, sample_time_sec=0.004, - data_files={InstrumentProbeType.PRIMARY: "fake_file_name"}, ) @@ -707,6 +703,17 @@ async def test_ready_for_movement( assert controller.check_motor_status(axes) == ready +def probe_move_group_run_side_effect( + head: NodeId, tool: NodeId +) -> Iterator[Dict[NodeId, MotorPositionStatus]]: + """Return homed position for axis that is present and was commanded to home.""" + positions = { + head: MotorPositionStatus(0.0, 0.0, True, True, MoveCompleteAck(1)), + tool: MotorPositionStatus(0.0, 0.0, True, True, MoveCompleteAck(1)), + } + yield positions + + @pytest.mark.parametrize("mount", [OT3Mount.LEFT, OT3Mount.RIGHT]) async def test_liquid_probe( mount: OT3Mount, @@ -716,6 +723,11 @@ async def test_liquid_probe( mock_send_stop_threshold: mock.AsyncMock, ) -> None: fake_max_p_dist = 70 + head_node = axis_to_node(Axis.by_mount(mount)) + tool_node = sensor_node_for_mount(mount) + mock_move_group_run.side_effect = probe_move_group_run_side_effect( + head_node, tool_node + ) try: await controller.liquid_probe( mount=mount, @@ -725,18 +737,17 @@ async def test_liquid_probe( threshold_pascals=fake_liquid_settings.sensor_threshold_pascals, plunger_impulse_time=fake_liquid_settings.plunger_impulse_time, num_baseline_reads=fake_liquid_settings.samples_for_baselining, - output_option=fake_liquid_settings.output_option, ) except PipetteLiquidNotFoundError: # the move raises a liquid not found now since we don't call the move group and it doesn't # get any positions back pass move_groups = mock_move_group_run.call_args_list[0][0][0]._move_groups - head_node = axis_to_node(Axis.by_mount(mount)) - tool_node = sensor_node_for_mount(mount) # in tool_sensors, pipette moves down, then sensor move goes assert move_groups[0][0][tool_node].stop_condition == MoveStopCondition.none - assert move_groups[1][0][tool_node].stop_condition == MoveStopCondition.sync_line + assert ( + move_groups[1][0][tool_node].stop_condition == MoveStopCondition.sensor_report + ) assert len(move_groups) == 2 assert move_groups[0][0][tool_node] assert move_groups[1][0][head_node], move_groups[2][0][tool_node] diff --git a/api/tests/opentrons/hardware_control/test_ot3_api.py b/api/tests/opentrons/hardware_control/test_ot3_api.py index 3c574e4373a..064ea087c6b 100644 --- a/api/tests/opentrons/hardware_control/test_ot3_api.py +++ b/api/tests/opentrons/hardware_control/test_ot3_api.py @@ -1,5 +1,6 @@ """ Tests for behaviors specific to the OT3 hardware controller. """ +import asyncio from typing import ( AsyncIterator, Iterator, @@ -26,7 +27,6 @@ GantryLoad, CapacitivePassSettings, LiquidProbeSettings, - OutputOptions, ) from opentrons.hardware_control.dev_types import ( AttachedGripper, @@ -98,6 +98,8 @@ from opentrons.hardware_control.module_control import AttachedModulesControl from opentrons.hardware_control.backends.types import HWStopCondition +from opentrons_hardware.firmware_bindings.constants import SensorId +from opentrons_hardware.sensors.types import SensorDataType # TODO (spp, 2023-08-22): write tests for ot3api.stop & ot3api.halt @@ -109,7 +111,6 @@ def fake_settings() -> CapacitivePassSettings: max_overrun_distance_mm=2, speed_mm_per_s=4, sensor_threshold_pf=1.0, - output_option=OutputOptions.sync_only, ) @@ -120,13 +121,11 @@ def fake_liquid_settings() -> LiquidProbeSettings: plunger_speed=15, plunger_impulse_time=0.2, sensor_threshold_pascals=15, - output_option=OutputOptions.can_bus_only, aspirate_while_sensing=False, z_overlap_between_passes_mm=0.1, plunger_reset_offset=2.0, samples_for_baselining=20, sample_time_sec=0.004, - data_files={InstrumentProbeType.PRIMARY: "fake_file_name"}, ) @@ -488,8 +487,6 @@ def _update_position( speed_mm_per_s: float, threshold_pf: float, probe: InstrumentProbeType, - output_option: OutputOptions = OutputOptions.sync_only, - data_file: Optional[str] = None, ) -> None: hardware_backend._position[moving] += distance_mm / 2 @@ -827,13 +824,11 @@ async def test_liquid_probe( plunger_speed=15, plunger_impulse_time=0.2, sensor_threshold_pascals=15, - output_option=OutputOptions.can_bus_only, aspirate_while_sensing=True, z_overlap_between_passes_mm=0.1, plunger_reset_offset=2.0, samples_for_baselining=20, sample_time_sec=0.004, - data_files={InstrumentProbeType.PRIMARY: "fake_file_name"}, ) fake_max_z_dist = 10.0 non_responsive_z_mm = ot3_hardware.liquid_probe_non_responsive_z_distance( @@ -860,10 +855,9 @@ async def test_liquid_probe( fake_settings_aspirate.sensor_threshold_pascals, fake_settings_aspirate.plunger_impulse_time, fake_settings_aspirate.samples_for_baselining, - fake_settings_aspirate.output_option, - fake_settings_aspirate.data_files, probe=InstrumentProbeType.PRIMARY, force_both_sensors=False, + response_queue=None, ) await ot3_hardware.liquid_probe( @@ -1098,13 +1092,11 @@ async def test_multi_liquid_probe( plunger_speed=71.5, plunger_impulse_time=0.2, sensor_threshold_pascals=15, - output_option=OutputOptions.can_bus_only, aspirate_while_sensing=True, z_overlap_between_passes_mm=0.1, plunger_reset_offset=2.0, samples_for_baselining=20, sample_time_sec=0.004, - data_files={InstrumentProbeType.PRIMARY: "fake_file_name"}, ) fake_max_z_dist = 10.0 await ot3_hardware.liquid_probe( @@ -1119,10 +1111,9 @@ async def test_multi_liquid_probe( fake_settings_aspirate.sensor_threshold_pascals, fake_settings_aspirate.plunger_impulse_time, fake_settings_aspirate.samples_for_baselining, - fake_settings_aspirate.output_option, - fake_settings_aspirate.data_files, probe=InstrumentProbeType.PRIMARY, force_both_sensors=False, + response_queue=None, ) assert mock_liquid_probe.call_count == 3 @@ -1155,10 +1146,11 @@ async def _fake_pos_update_and_raise( threshold_pascals: float, plunger_impulse_time: float, num_baseline_reads: int, - output_format: OutputOptions = OutputOptions.can_bus_only, - data_files: Optional[Dict[InstrumentProbeType, str]] = None, probe: InstrumentProbeType = InstrumentProbeType.PRIMARY, force_both_sensors: bool = False, + response_queue: Optional[ + asyncio.Queue[Dict[SensorId, List[SensorDataType]]] + ] = None, ) -> float: pos = self._position pos[Axis.by_mount(mount)] += mount_speed * ( @@ -1176,13 +1168,11 @@ async def _fake_pos_update_and_raise( plunger_speed=71.5, plunger_impulse_time=0.2, sensor_threshold_pascals=15, - output_option=OutputOptions.can_bus_only, aspirate_while_sensing=True, z_overlap_between_passes_mm=0.1, plunger_reset_offset=2.0, samples_for_baselining=20, sample_time_sec=0.004, - data_files={InstrumentProbeType.PRIMARY: "fake_file_name"}, ) # with a mount speed of 5, pass overlap of 0.5 and a 0.2s delay on z # the actual distance traveled is 3.5mm per pass @@ -1233,8 +1223,6 @@ async def test_capacitive_probe( 4, 1.0, InstrumentProbeType.PRIMARY, - fake_settings.output_option, - fake_settings.data_files, ) original = moving.set_in_point(here, 0) diff --git a/api/tests/opentrons/protocol_api/core/engine/test_absorbance_reader_core.py b/api/tests/opentrons/protocol_api/core/engine/test_absorbance_reader_core.py index 405d737d55b..a5fadde09cc 100644 --- a/api/tests/opentrons/protocol_api/core/engine/test_absorbance_reader_core.py +++ b/api/tests/opentrons/protocol_api/core/engine/test_absorbance_reader_core.py @@ -11,6 +11,11 @@ from opentrons.protocol_engine.clients import SyncClient as EngineClient from opentrons.protocol_api.core.engine.module_core import AbsorbanceReaderCore from opentrons.protocol_api import MAX_SUPPORTED_VERSION +from opentrons.protocol_engine.state.module_substates import AbsorbanceReaderSubState +from opentrons.protocol_engine.state.module_substates.absorbance_reader_substate import ( + AbsorbanceReaderId, + AbsorbanceReaderMeasureMode, +) SyncAbsorbanceReaderHardware = SynchronousAdapter[AbsorbanceReader] @@ -115,7 +120,23 @@ def test_read( ) -> None: """It should call absorbance reader to read with the engine client.""" subject._initialized_value = [123] - subject.read() + substate = AbsorbanceReaderSubState( + module_id=AbsorbanceReaderId(subject.module_id), + configured=True, + measured=False, + is_lid_on=True, + data=None, + configured_wavelengths=subject._initialized_value, + measure_mode=AbsorbanceReaderMeasureMode("single"), + reference_wavelength=None, + lid_id="pr_lid_labware", + ) + decoy.when( + mock_engine_client.state.modules.get_absorbance_reader_substate( + subject.module_id + ) + ).then_return(substate) + subject.read(filename=None) decoy.verify( mock_engine_client.execute_command( diff --git a/api/tests/opentrons/protocol_engine/commands/test_prepare_to_aspirate.py b/api/tests/opentrons/protocol_engine/commands/test_prepare_to_aspirate.py index b11254af481..45e8db96837 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_prepare_to_aspirate.py +++ b/api/tests/opentrons/protocol_engine/commands/test_prepare_to_aspirate.py @@ -1,25 +1,41 @@ """Test prepare to aspirate commands.""" - -from decoy import Decoy +from datetime import datetime +from opentrons.types import Point +import pytest +from decoy import Decoy, matchers from opentrons.protocol_engine.execution import ( PipettingHandler, ) -from opentrons.protocol_engine.commands.command import SuccessData +from opentrons.protocol_engine.commands.command import DefinedErrorData, SuccessData from opentrons.protocol_engine.commands.prepare_to_aspirate import ( PrepareToAspirateParams, PrepareToAspirateImplementation, PrepareToAspirateResult, ) +from opentrons.protocol_engine.execution.gantry_mover import GantryMover +from opentrons.protocol_engine.resources.model_utils import ModelUtils +from opentrons.protocol_engine.commands.pipetting_common import OverpressureError +from opentrons_shared_data.errors.exceptions import PipetteOverpressureError + + +@pytest.fixture +def subject( + pipetting: PipettingHandler, + model_utils: ModelUtils, + gantry_mover: GantryMover, +) -> PrepareToAspirateImplementation: + """Get the implementation subject.""" + return PrepareToAspirateImplementation( + pipetting=pipetting, model_utils=model_utils, gantry_mover=gantry_mover + ) async def test_prepare_to_aspirate_implmenetation( - decoy: Decoy, pipetting: PipettingHandler + decoy: Decoy, subject: PrepareToAspirateImplementation, pipetting: PipettingHandler ) -> None: """A PrepareToAspirate command should have an executing implementation.""" - subject = PrepareToAspirateImplementation(pipetting=pipetting) - data = PrepareToAspirateParams(pipetteId="some id") decoy.when(await pipetting.prepare_for_aspirate(pipette_id="some id")).then_return( @@ -28,3 +44,44 @@ async def test_prepare_to_aspirate_implmenetation( result = await subject.execute(data) assert result == SuccessData(public=PrepareToAspirateResult(), private=None) + + +async def test_overpressure_error( + decoy: Decoy, + gantry_mover: GantryMover, + pipetting: PipettingHandler, + subject: PrepareToAspirateImplementation, + model_utils: ModelUtils, +) -> None: + """It should return an overpressure error if the hardware API indicates that.""" + pipette_id = "pipette-id" + + position = Point(x=1, y=2, z=3) + + error_id = "error-id" + error_timestamp = datetime(year=2020, month=1, day=2) + + data = PrepareToAspirateParams( + pipetteId=pipette_id, + ) + + decoy.when( + await pipetting.prepare_for_aspirate( + pipette_id=pipette_id, + ), + ).then_raise(PipetteOverpressureError()) + + decoy.when(model_utils.generate_id()).then_return(error_id) + decoy.when(model_utils.get_timestamp()).then_return(error_timestamp) + decoy.when(await gantry_mover.get_position(pipette_id)).then_return(position) + + result = await subject.execute(data) + + assert result == DefinedErrorData( + public=OverpressureError.construct( + id=error_id, + createdAt=error_timestamp, + wrappedErrors=[matchers.Anything()], + errorInfo={"retryLocation": (position.x, position.y, position.z)}, + ), + ) diff --git a/api/tests/opentrons/protocol_engine/conftest.py b/api/tests/opentrons/protocol_engine/conftest.py index 7040f8497ea..76c5d754f3e 100644 --- a/api/tests/opentrons/protocol_engine/conftest.py +++ b/api/tests/opentrons/protocol_engine/conftest.py @@ -22,6 +22,7 @@ from opentrons.hardware_control.api import API from opentrons.hardware_control.protocols.types import FlexRobotType, OT2RobotType from opentrons.protocol_engine.notes import CommandNoteAdder +from opentrons.protocol_engine.resources.file_provider import FileProvider if TYPE_CHECKING: from opentrons.hardware_control.ot3api import OT3API @@ -252,3 +253,9 @@ def supported_tip_fixture() -> pipette_definition.SupportedTipsDefinition: def mock_command_note_adder(decoy: Decoy) -> CommandNoteAdder: """Get a command note adder.""" return decoy.mock(cls=CommandNoteAdder) + + +@pytest.fixture +def file_provider(decoy: Decoy) -> FileProvider: + """Get a mocked out FileProvider.""" + return decoy.mock(cls=FileProvider) diff --git a/api/tests/opentrons/protocol_engine/execution/test_command_executor.py b/api/tests/opentrons/protocol_engine/execution/test_command_executor.py index 2df3a2cdd25..f5f0ec063b0 100644 --- a/api/tests/opentrons/protocol_engine/execution/test_command_executor.py +++ b/api/tests/opentrons/protocol_engine/execution/test_command_executor.py @@ -18,7 +18,7 @@ from opentrons.protocol_engine.errors.exceptions import ( EStopActivatedError as PE_EStopActivatedError, ) -from opentrons.protocol_engine.resources import ModelUtils +from opentrons.protocol_engine.resources import ModelUtils, FileProvider from opentrons.protocol_engine.state.state import StateStore from opentrons.protocol_engine.actions import ( ActionDispatcher, @@ -174,6 +174,7 @@ def subject( state_store: StateStore, action_dispatcher: ActionDispatcher, equipment: EquipmentHandler, + file_provider: FileProvider, movement: MovementHandler, mock_gantry_mover: GantryMover, labware_movement: LabwareMovementHandler, @@ -188,6 +189,7 @@ def subject( """Get a CommandExecutor test subject with its dependencies mocked out.""" return CommandExecutor( hardware_api=hardware_api, + file_provider=file_provider, state_store=state_store, action_dispatcher=action_dispatcher, equipment=equipment, @@ -234,6 +236,7 @@ async def test_execute( state_store: StateStore, action_dispatcher: ActionDispatcher, equipment: EquipmentHandler, + file_provider: FileProvider, movement: MovementHandler, mock_gantry_mover: GantryMover, labware_movement: LabwareMovementHandler, @@ -329,6 +332,7 @@ class _TestCommand( queued_command._ImplementationCls( state_view=state_store, hardware_api=hardware_api, + file_provider=file_provider, equipment=equipment, movement=movement, gantry_mover=mock_gantry_mover, @@ -392,6 +396,7 @@ async def test_execute_undefined_error( state_store: StateStore, action_dispatcher: ActionDispatcher, equipment: EquipmentHandler, + file_provider: FileProvider, movement: MovementHandler, mock_gantry_mover: GantryMover, labware_movement: LabwareMovementHandler, @@ -474,6 +479,7 @@ class _TestCommand( queued_command._ImplementationCls( state_view=state_store, hardware_api=hardware_api, + file_provider=file_provider, equipment=equipment, movement=movement, gantry_mover=mock_gantry_mover, @@ -528,6 +534,7 @@ async def test_execute_defined_error( state_store: StateStore, action_dispatcher: ActionDispatcher, equipment: EquipmentHandler, + file_provider: FileProvider, movement: MovementHandler, mock_gantry_mover: GantryMover, labware_movement: LabwareMovementHandler, @@ -610,6 +617,7 @@ class _TestCommand( queued_command._ImplementationCls( state_view=state_store, hardware_api=hardware_api, + file_provider=file_provider, equipment=equipment, movement=movement, gantry_mover=mock_gantry_mover, diff --git a/app-shell-odd/src/__tests__/http.test.ts b/app-shell-odd/src/__tests__/http.test.ts index 7b2c72578c0..c7ea4443a96 100644 --- a/app-shell-odd/src/__tests__/http.test.ts +++ b/app-shell-odd/src/__tests__/http.test.ts @@ -9,6 +9,7 @@ import type { Request, Response } from 'node-fetch' vi.mock('../config') vi.mock('node-fetch') +vi.mock('../log') describe('app-shell main http module', () => { beforeEach(() => { diff --git a/app-shell-odd/src/__tests__/update.test.ts b/app-shell-odd/src/__tests__/update.test.ts deleted file mode 100644 index 26adb67684b..00000000000 --- a/app-shell-odd/src/__tests__/update.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -// app-shell self-update tests -import { when } from 'vitest-when' -import { describe, it, vi, beforeEach, afterEach, expect } from 'vitest' -import * as http from '../http' -import { registerUpdate, FLEX_MANIFEST_URL } from '../update' -import * as Cfg from '../config' - -import type { Dispatch } from '../types' - -vi.unmock('electron-updater') -vi.mock('electron-updater') -vi.mock('../log') -vi.mock('../config') -vi.mock('../http') -vi.mock('fs-extra') - -describe('update', () => { - let dispatch: Dispatch - let handleAction: Dispatch - - beforeEach(() => { - dispatch = vi.fn() - handleAction = registerUpdate(dispatch) - }) - - afterEach(() => { - vi.resetAllMocks() - }) - - it('handles shell:CHECK_UPDATE with available update', () => { - when(vi.mocked(Cfg.getConfig)) - // @ts-expect-error getConfig mock not recognizing correct type overload - .calledWith('update') - .thenReturn({ - channel: 'latest', - } as any) - - when(vi.mocked(http.fetchJson)) - .calledWith(FLEX_MANIFEST_URL) - .thenResolve({ production: { '5.0.0': {}, '6.0.0': {} } }) - handleAction({ type: 'shell:CHECK_UPDATE', meta: { shell: true } }) - - expect(vi.mocked(Cfg.getConfig)).toHaveBeenCalledWith('update') - - expect(vi.mocked(http.fetchJson)).toHaveBeenCalledWith(FLEX_MANIFEST_URL) - }) -}) diff --git a/app-shell-odd/src/actions.ts b/app-shell-odd/src/actions.ts index 588dc88b3e4..bb7c0450210 100644 --- a/app-shell-odd/src/actions.ts +++ b/app-shell-odd/src/actions.ts @@ -119,6 +119,7 @@ import type { export const configInitialized = (config: Config): ConfigInitializedAction => ({ type: CONFIG_INITIALIZED, payload: { config }, + meta: { shell: true }, }) // config value has been updated @@ -128,6 +129,7 @@ export const configValueUpdated = ( ): ConfigValueUpdatedAction => ({ type: VALUE_UPDATED, payload: { path, value }, + meta: { shell: true }, }) export const customLabwareList = ( diff --git a/app-shell-odd/src/config/index.ts b/app-shell-odd/src/config/index.ts index df8e0cf317d..a67655976d9 100644 --- a/app-shell-odd/src/config/index.ts +++ b/app-shell-odd/src/config/index.ts @@ -5,7 +5,6 @@ import get from 'lodash/get' import forEach from 'lodash/forEach' import mergeOptions from 'merge-options' import yargsParser from 'yargs-parser' - import { UI_INITIALIZED } from '../constants' import * as Cfg from '../constants' import { configInitialized, configValueUpdated } from '../actions' @@ -13,6 +12,7 @@ import systemd from '../systemd' import { createLogger } from '../log' import { DEFAULTS_V12, migrate } from './migrate' import { shouldUpdate, getNextValue } from './update' +import { setUserDataPath } from '../early' import type { ConfigV12, @@ -24,8 +24,6 @@ import type { Config, Overrides } from './types' export * from './types' -export const ODD_DIR = '/data/ODD' - // make sure all arguments are included in production const argv = process.argv0.endsWith('defaultApp') ? process.argv.slice(2) @@ -48,8 +46,7 @@ const store = (): Store => { // perform store migration if loading for the first time _store = (new Store({ defaults: DEFAULTS_V12, - // dont overwrite config dir if in dev mode because it causes issues - ...(process.env.NODE_ENV === 'production' && { cwd: ODD_DIR }), + cwd: setUserDataPath(), }) as unknown) as Store _store.store = migrate((_store.store as unknown) as ConfigV12) } @@ -66,7 +63,14 @@ const log = (): Logger => _log ?? (_log = createLogger('config')) export function registerConfig(dispatch: Dispatch): (action: Action) => void { return function handleIncomingAction(action: Action) { if (action.type === UI_INITIALIZED) { + log().info('initializing configuration') dispatch(configInitialized(getFullConfig())) + log().info( + `flow route: ${ + getConfig('onDeviceDisplaySettings').unfinishedUnboxingFlowRoute + }` + ) + log().info('configuration initialized') } else if ( action.type === Cfg.UPDATE_VALUE || action.type === Cfg.RESET_VALUE || @@ -120,8 +124,8 @@ export function getOverrides(path?: string): unknown { return path != null ? get(overrides(), path) : overrides() } -export function getConfig

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

(path: P): Config[P] export function getConfig(path?: any): any { const result = store().get(path) const over = getOverrides(path as string | undefined) diff --git a/app-shell-odd/src/constants.ts b/app-shell-odd/src/constants.ts index a78e9274ae0..8b92e639cf6 100644 --- a/app-shell-odd/src/constants.ts +++ b/app-shell-odd/src/constants.ts @@ -257,3 +257,5 @@ export const FAILURE_STATUSES = { } as const export const SEND_FILE_PATHS: 'shell:SEND_FILE_PATHS' = 'shell:SEND_FILE_PATHS' + +export const ODD_DATA_DIR = '/data/ODD' diff --git a/app-shell-odd/src/early.ts b/app-shell-odd/src/early.ts new file mode 100644 index 00000000000..134c8957804 --- /dev/null +++ b/app-shell-odd/src/early.ts @@ -0,0 +1,22 @@ +// things intended to execute early in app-shell initialization +// do as little as possible in this file and do none of it at import time + +import { app } from 'electron' +import { ODD_DATA_DIR } from './constants' + +let path: string + +export const setUserDataPath = (): string => { + if (path == null) { + console.log( + `node env is ${process.env.NODE_ENV}, path is ${app.getPath('userData')}` + ) + if (process.env.NODE_ENV === 'production') { + console.log(`setting app path to ${ODD_DATA_DIR}`) + app.setPath('userData', ODD_DATA_DIR) + } + path = app.getPath('userData') + console.log(`app path becomes ${app.getPath('userData')}`) + } + return app.getPath('userData') +} diff --git a/app-shell-odd/src/http.ts b/app-shell-odd/src/http.ts index 6392340fbe7..90d01530da8 100644 --- a/app-shell-odd/src/http.ts +++ b/app-shell-odd/src/http.ts @@ -7,10 +7,13 @@ import FormData from 'form-data' import { Transform } from 'stream' import { HTTP_API_VERSION } from './constants' +import { createLogger } from './log' import type { Readable } from 'stream' import type { Request, RequestInit, Response } from 'node-fetch' +const log = createLogger('http') + type RequestInput = Request | string export interface DownloadProgress { @@ -18,6 +21,16 @@ export interface DownloadProgress { size: number | null } +export class LocalAbortError extends Error { + declare readonly name: 'LocalAbortError' + declare readonly type: 'aborted' + constructor(message: string) { + super(message) + this.name = 'LocalAbortError' + this.type = 'aborted' + } +} + export function fetch( input: RequestInput, init?: RequestInit @@ -35,21 +48,29 @@ export function fetch( }) } -export function fetchJson(input: RequestInput): Promise { - return fetch(input).then(response => response.json()) +export function fetchJson( + input: RequestInput, + init?: RequestInit +): Promise { + return fetch(input, init).then(response => response.json()) +} + +export function fetchText(input: Request, init?: RequestInit): Promise { + return fetch(input, init).then(response => response.text()) } -export function fetchText(input: Request): Promise { - return fetch(input).then(response => response.text()) +export interface FetchToFileOptions { + onProgress: (progress: DownloadProgress) => unknown + signal: AbortSignal } // TODO(mc, 2019-07-02): break this function up and test its components export function fetchToFile( input: RequestInput, destination: string, - options?: Partial<{ onProgress: (progress: DownloadProgress) => unknown }> + options?: Partial ): Promise { - return fetch(input).then(response => { + return fetch(input, { signal: options?.signal }).then(response => { let downloaded = 0 const size = Number(response.headers.get('Content-Length')) || null @@ -75,13 +96,26 @@ export function fetchToFile( // pump calls stream.pipe, handles teardown if streams error, and calls // its callbacks when the streams are done pump(inputStream, progressReader, outputStream, error => { - // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions - if (error) { + const handleError = (problem: Error): void => { // if we error out, delete the temp dir to clean up - return remove(destination).then(() => { + log.error(`Aborting fetchToFile: ${problem.name}: ${problem.message}`) + remove(destination).then(() => { reject(error) }) } + const listener = (): void => { + handleError( + new LocalAbortError( + (options?.signal?.reason as string | null) ?? 'aborted' + ) + ) + } + options?.signal?.addEventListener('abort', listener, { once: true }) + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions + if (error) { + handleError(error) + } + options?.signal?.removeEventListener('abort', listener, {}) resolve(destination) }) }) diff --git a/app-shell-odd/src/log.ts b/app-shell-odd/src/log.ts index 0c6a087be3f..100c7f275fb 100644 --- a/app-shell-odd/src/log.ts +++ b/app-shell-odd/src/log.ts @@ -4,13 +4,13 @@ import path from 'path' import dateFormat from 'dateformat' import winston from 'winston' +import { setUserDataPath } from './early' import { getConfig } from './config' import type Transport from 'winston-transport' import type { Config } from './config' -const ODD_DIR = '/data/ODD' -const LOG_DIR = path.join(ODD_DIR, 'logs') +const LOG_DIR = path.join(setUserDataPath(), 'logs') const ERROR_LOG = path.join(LOG_DIR, 'error.log') const COMBINED_LOG = path.join(LOG_DIR, 'combined.log') diff --git a/app-shell-odd/src/main.ts b/app-shell-odd/src/main.ts index d271bb1dc87..b0f285fa194 100644 --- a/app-shell-odd/src/main.ts +++ b/app-shell-odd/src/main.ts @@ -6,11 +6,7 @@ import path from 'path' import { createUi, waitForRobotServerAndShowMainWindow } from './ui' import { createLogger } from './log' import { registerDiscovery } from './discovery' -import { - registerUpdate, - updateLatestVersion, - registerUpdateBrightness, -} from './update' +import { registerUpdateBrightness } from './system' import { registerRobotSystemUpdate } from './system-update' import { registerAppRestart } from './restart' import { @@ -19,7 +15,6 @@ import { getOverrides, registerConfig, resetStore, - ODD_DIR, } from './config' import systemd from './systemd' import { registerDataFiles, watchForMassStorage } from './usb' @@ -28,7 +23,9 @@ import { establishBrokerConnection, closeBrokerConnection, } from './notifications' +import { setUserDataPath } from './early' +import type { OTLogger } from './log' import type { BrowserWindow } from 'electron' import type { Action, Dispatch, Logger } from './types' import type { LogEntry } from 'winston' @@ -39,6 +36,7 @@ import type { LogEntry } from 'winston' * https://github.com/node-fetch/node-fetch/issues/1624 */ dns.setDefaultResultOrder('ipv4first') +setUserDataPath() systemd.sendStatus('starting app') const config = getConfig() @@ -87,12 +85,14 @@ function startUp(): void { log.info('Starting App') console.log('Starting App') const storeNeedsReset = fse.existsSync( - path.join(ODD_DIR, `_CONFIG_TO_BE_DELETED_ON_REBOOT`) + path.join(setUserDataPath(), `_CONFIG_TO_BE_DELETED_ON_REBOOT`) ) if (storeNeedsReset) { log.debug('store marked to be reset, resetting store') resetStore() - fse.removeSync(path.join(ODD_DIR, `_CONFIG_TO_BE_DELETED_ON_REBOOT`)) + fse.removeSync( + path.join(app.getPath('userData'), `_CONFIG_TO_BE_DELETED_ON_REBOOT`) + ) } systemd.sendStatus('loading app') process.on('uncaughtException', error => log.error('Uncaught: ', { error })) @@ -102,11 +102,28 @@ function startUp(): void { // wire modules to UI dispatches const dispatch: Dispatch = action => { - // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions - if (mainWindow) { - log.silly('Sending action via IPC to renderer', { action }) - mainWindow.webContents.send('dispatch', action) - } + // This function now dispatches actions to all the handlers in the app shell. That would make it + // vulnerable to infinite recursion: + // - handler handles action A + // - handler dispatches action A as a response (calls this function) + // - this function calls handler with action A + // By deferring to nextTick(), we would still be executing the code over and over but we should have + // broken the stack. + process.nextTick(() => { + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions + if (mainWindow) { + log.silly('Sending action via IPC to renderer', { action }) + mainWindow.webContents.send('dispatch', action) + } + log.debug( + `bouncing action ${action.type} to ${actionHandlers.length} handlers` + ) + // Make actions that are sourced from the shell also go to the app shell without needing + // round tripping. This call is the reason for the nextTick() above. + actionHandlers.forEach(handler => { + handler(action) + }) + }) } mainWindow = createUi(dispatch) @@ -114,15 +131,9 @@ function startUp(): void { void establishBrokerConnection() mainWindow.once('closed', () => (mainWindow = null)) - log.info('Fetching latest software version') - updateLatestVersion().catch((error: Error) => { - log.error('Error fetching latest software version: ', { error }) - }) - const actionHandlers: Dispatch[] = [ registerConfig(dispatch), registerDiscovery(dispatch), - registerUpdate(dispatch), registerRobotSystemUpdate(dispatch), registerAppRestart(), registerUpdateBrightness(), @@ -143,8 +154,19 @@ function startUp(): void { log.info('First dispatch, showing') systemd.sendStatus('started') systemd.ready() - const stopWatching = watchForMassStorage(dispatch) - ipcMain.once('quit', stopWatching) + try { + const stopWatching = watchForMassStorage(dispatch) + ipcMain.once('quit', stopWatching) + } catch (err: any) { + if (err instanceof Error) { + console.log( + `Failed to watch for mass storage: ${err.name}: ${err.message}`, + err + ) + } else { + console.log(`Failed to watch for mass storage: ${err}`) + } + } // TODO: This is where we render the main window for the first time. See ui.ts // in the createUI function for more. if (!!!mainWindow) { @@ -155,7 +177,7 @@ function startUp(): void { }) } -function createRendererLogger(): Logger { +function createRendererLogger(): OTLogger { log.info('Creating renderer logger') const logger = createLogger('renderer') diff --git a/app-shell-odd/src/system-update/__tests__/handler.test.ts b/app-shell-odd/src/system-update/__tests__/handler.test.ts new file mode 100644 index 00000000000..65769c93729 --- /dev/null +++ b/app-shell-odd/src/system-update/__tests__/handler.test.ts @@ -0,0 +1,777 @@ +// app-shell self-update tests +import { when } from 'vitest-when' +import { rm } from 'fs-extra' +import { describe, it, vi, beforeEach, afterEach, expect } from 'vitest' +import tempy from 'tempy' + +import * as Cfg from '../../config' +import { CONFIG_INITIALIZED, VALUE_UPDATED } from '../../constants' +import { + manageDriver, + createUpdateDriver, + CURRENT_SYSTEM_VERSION, +} from '../handler' +import { FLEX_MANIFEST_URL } from '../constants' +import { getSystemUpdateDir as _getSystemUpdateDir } from '../directories' +import { getProvider as _getWebProvider } from '../from-web' +import { getProvider as _getUsbProvider } from '../from-usb' + +import type { UpdateProvider } from '../types' +import type { UpdateDriver } from '../handler' +import type { WebUpdateSource } from '../from-web' +import type { USBUpdateSource } from '../from-usb' +import type { Dispatch } from '../../types' + +import type { + ConfigInitializedAction, + ConfigValueUpdatedAction, +} from '@opentrons/app/src/redux/config' + +vi.unmock('electron-updater') // ? +vi.mock('electron-updater') +vi.mock('../../log') +vi.mock('../../config') +vi.mock('../../http') +vi.mock('../directories') +vi.mock('../from-web') +vi.mock('../from-usb') + +const getSystemUpdateDir = vi.mocked(_getSystemUpdateDir) +const getConfig = vi.mocked(Cfg.getConfig) +const getWebProvider = vi.mocked(_getWebProvider) +const getUsbProvider = vi.mocked(_getUsbProvider) + +describe('update driver manager', () => { + let dispatch: Dispatch + let testDir: string = '' + beforeEach(() => { + const thisTd = tempy.directory() + testDir = thisTd + dispatch = vi.fn() + when(getSystemUpdateDir).calledWith().thenReturn(thisTd) + }) + + afterEach(() => { + vi.resetAllMocks() + const oldTd = testDir + testDir = '' + return oldTd === '' + ? new Promise(resolve => resolve()) + : rm(oldTd, { recursive: true, force: true }) + }) + + it('creates a driver once config is loaded', () => { + when(getConfig) + .calledWith('update') + .thenReturn(({ channel: 'alpha' } as any) as Cfg.Config['update']) + const driver = manageDriver(dispatch) + expect(driver.getUpdateDriver()).toBeNull() + expect(getConfig).not.toHaveBeenCalled() + return driver + .handleAction({ + type: CONFIG_INITIALIZED, + } as ConfigInitializedAction) + .then(() => { + expect(driver.getUpdateDriver()).not.toBeNull() + expect(getConfig).toHaveBeenCalledOnce() + expect(getWebProvider).toHaveBeenCalledWith({ + manifestUrl: FLEX_MANIFEST_URL, + channel: 'alpha', + updateCacheDirectory: testDir, + currentVersion: CURRENT_SYSTEM_VERSION, + }) + }) + }) + + it('reloads the web driver when appropriate', () => { + when(getConfig) + .calledWith('update') + .thenReturn(({ channel: 'alpha' } as any) as Cfg.Config['update']) + const fakeProvider = { + teardown: vi.fn(), + refreshUpdateCache: vi.fn(), + getUpdateDetails: vi.fn(), + lockUpdateCache: vi.fn(), + unlockUpdateCache: vi.fn(), + name: vi.fn(), + source: () => (({ channel: 'alpha' } as any) as WebUpdateSource), + } + const fakeProvider2 = { + ...fakeProvider, + source: () => (({ channel: 'beta' } as any) as WebUpdateSource), + } + when(getWebProvider) + .calledWith({ + manifestUrl: FLEX_MANIFEST_URL, + channel: 'alpha', + updateCacheDirectory: testDir, + currentVersion: CURRENT_SYSTEM_VERSION, + }) + .thenReturn(fakeProvider) + when(getWebProvider) + .calledWith({ + manifestUrl: FLEX_MANIFEST_URL, + channel: 'beta', + updateCacheDirectory: testDir, + currentVersion: CURRENT_SYSTEM_VERSION, + }) + .thenReturn(fakeProvider2) + const driverManager = manageDriver(dispatch) + return driverManager + .handleAction({ + type: CONFIG_INITIALIZED, + } as ConfigInitializedAction) + .then(() => { + expect(getWebProvider).toHaveBeenCalledWith({ + manifestUrl: FLEX_MANIFEST_URL, + channel: 'alpha', + updateCacheDirectory: testDir, + currentVersion: CURRENT_SYSTEM_VERSION, + }) + expect(driverManager.getUpdateDriver()).not.toBeNull() + when(fakeProvider.teardown).calledWith().thenResolve() + return driverManager.handleAction({ + type: VALUE_UPDATED, + } as ConfigValueUpdatedAction) + }) + .then(() => { + expect(getWebProvider).toHaveBeenCalledOnce() + when(getConfig) + .calledWith('update') + .thenReturn(({ + channel: 'beta', + } as any) as Cfg.Config['update']) + return driverManager.handleAction({ + type: VALUE_UPDATED, + } as ConfigValueUpdatedAction) + }) + .then(() => { + expect(getWebProvider).toHaveBeenCalledWith({ + manifestUrl: FLEX_MANIFEST_URL, + channel: 'alpha', + updateCacheDirectory: testDir, + currentVersion: CURRENT_SYSTEM_VERSION, + }) + }) + }) +}) + +describe('update driver', () => { + let dispatch: Dispatch + let testDir: string = '' + let subject: UpdateDriver | null = null + const fakeProvider: UpdateProvider = { + teardown: vi.fn(), + refreshUpdateCache: vi.fn(), + getUpdateDetails: vi.fn(), + lockUpdateCache: vi.fn(), + unlockUpdateCache: vi.fn(), + name: vi.fn(), + source: () => (({ channel: 'alpha' } as any) as WebUpdateSource), + } + const fakeUsbProviders: Record> = { + first: { + teardown: vi.fn(), + refreshUpdateCache: vi.fn(), + getUpdateDetails: vi.fn(), + lockUpdateCache: vi.fn(), + unlockUpdateCache: vi.fn(), + name: () => '/some/usb/path', + source: () => + (({ + massStorageRootPath: '/some/usb/path', + } as any) as USBUpdateSource), + }, + } + + beforeEach(() => { + const thisTd = tempy.directory() + testDir = thisTd + dispatch = vi.fn() + when(getSystemUpdateDir).calledWith().thenReturn(thisTd) + when(getConfig) + .calledWith('update') + .thenReturn(({ channel: 'alpha' } as any) as Cfg.Config['update']) + when(getWebProvider) + .calledWith({ + manifestUrl: FLEX_MANIFEST_URL, + channel: 'alpha', + updateCacheDirectory: testDir, + currentVersion: CURRENT_SYSTEM_VERSION, + }) + .thenReturn(fakeProvider) + fakeUsbProviders.first = { + teardown: vi.fn(), + refreshUpdateCache: vi.fn(), + getUpdateDetails: vi.fn(), + lockUpdateCache: vi.fn(), + unlockUpdateCache: vi.fn(), + name: () => '/some/usb/path', + source: () => + (({ + massStorageRootPath: '/some/usb/path', + } as any) as USBUpdateSource), + } + fakeUsbProviders.second = { + teardown: vi.fn(), + refreshUpdateCache: vi.fn(), + getUpdateDetails: vi.fn(), + lockUpdateCache: vi.fn(), + unlockUpdateCache: vi.fn(), + name: () => '/some/other/usb/path', + source: () => + (({ + massStorageRootPath: '/some/other/usb/path', + } as any) as USBUpdateSource), + } + subject = createUpdateDriver(dispatch) + }) + + afterEach(() => { + vi.resetAllMocks() + const oldTd = testDir + testDir = '' + return ( + subject?.teardown() || new Promise(resolve => resolve()) + ).then(() => + oldTd === '' + ? new Promise(resolve => resolve()) + : rm(oldTd, { recursive: true, force: true }) + ) + }) + + it('checks updates when told to check updates', () => { + const thisSubject = subject as UpdateDriver + when(fakeProvider.refreshUpdateCache) + .calledWith(expect.any(Function)) + .thenDo( + progress => + new Promise(resolve => { + progress({ + version: null, + files: null, + downloadProgress: 0, + releaseNotes: null, + }) + resolve({ + version: null, + files: null, + downloadProgress: 0, + releaseNotes: null, + }) + }) + ) + return thisSubject + .handleAction({ type: 'shell:CHECK_UPDATE', meta: { shell: true } }) + .then(() => { + expect(dispatch).toHaveBeenCalledWith({ + type: 'robotUpdate:UPDATE_INFO', + payload: { + version: null, + releaseNotes: null, + force: false, + target: 'flex', + }, + }) + expect(dispatch).toHaveBeenCalledWith({ + type: 'robotUpdate:UPDATE_VERSION', + payload: { version: null, force: false, target: 'flex' }, + }) + }) + }) + it('forwards in-progress downloads when no USB updates are present', () => { + const thisSubject = subject as UpdateDriver + when(fakeProvider.refreshUpdateCache) + .calledWith(expect.any(Function)) + .thenDo( + progress => + new Promise(resolve => { + progress({ + version: null, + files: null, + downloadProgress: 0, + releaseNotes: null, + }) + progress({ + version: '1.2.3', + files: null, + downloadProgress: 0, + releaseNotes: null, + }) + progress({ + version: '1.2.3', + files: null, + downloadProgress: 50, + releaseNotes: null, + }) + progress({ + version: '1.2.3', + files: { + system: '/some/path', + releaseNotes: '/some/other/path', + }, + downloadProgress: 100, + releaseNotes: 'some release notes', + }) + resolve({ + version: '1.2.3', + files: { + system: '/some/path', + releaseNotes: '/some/other/path', + }, + downloadProgress: 100, + releaseNotes: 'some release notes', + }) + }) + ) + return thisSubject + .handleAction({ type: 'shell:CHECK_UPDATE', meta: { shell: true } }) + .then(() => { + expect(dispatch).toHaveBeenNthCalledWith(1, { + type: 'robotUpdate:UPDATE_VERSION', + payload: { version: '1.2.3', force: false, target: 'flex' }, + }) + expect(dispatch).toHaveBeenNthCalledWith(2, { + type: 'robotUpdate:DOWNLOAD_PROGRESS', + payload: { progress: 50, target: 'flex' }, + }) + expect(dispatch).toHaveBeenNthCalledWith(3, { + type: 'robotUpdate:UPDATE_INFO', + payload: { + version: '1.2.3', + releaseNotes: 'some release notes', + force: false, + target: 'flex', + }, + }) + expect(dispatch).toHaveBeenNthCalledWith(4, { + type: 'robotUpdate:UPDATE_VERSION', + payload: { version: '1.2.3', force: false, target: 'flex' }, + }) + expect(dispatch).toHaveBeenNthCalledWith(5, { + type: 'robotUpdate:UPDATE_INFO', + payload: { + version: '1.2.3', + releaseNotes: 'some release notes', + force: false, + target: 'flex', + }, + }) + expect(dispatch).toHaveBeenNthCalledWith(6, { + type: 'robotUpdate:UPDATE_VERSION', + payload: { version: '1.2.3', force: false, target: 'flex' }, + }) + }) + }) + it('creates a usb provider when it gets a message that a usb device was added', () => { + const thisSubject = subject as UpdateDriver + when(getUsbProvider) + .calledWith({ + currentVersion: CURRENT_SYSTEM_VERSION, + massStorageDeviceRoot: '/some/usb/path', + massStorageDeviceFiles: ['/some/file', '/some/other/file'], + }) + .thenReturn(fakeUsbProviders.first) + when(fakeUsbProviders.first.refreshUpdateCache) + .calledWith(expect.any(Function)) + .thenResolve({ + version: '1.2.3', + files: { system: '/some/file', releaseNotes: null }, + releaseNotes: 'some fake notes', + downloadProgress: 100, + }) + return thisSubject + .handleAction({ + type: 'shell:ROBOT_MASS_STORAGE_DEVICE_ENUMERATED', + payload: { + rootPath: '/some/usb/path', + filePaths: ['/some/file', '/some/other/file'], + }, + meta: { shell: true }, + }) + .then(() => { + expect(getUsbProvider).toHaveBeenCalledWith({ + currentVersion: CURRENT_SYSTEM_VERSION, + massStorageDeviceRoot: '/some/usb/path', + massStorageDeviceFiles: ['/some/file', '/some/other/file'], + }) + }) + }) + it('does not create a usb provider if it already has one for a path', () => { + const thisSubject = subject as UpdateDriver + when(getUsbProvider) + .calledWith({ + currentVersion: CURRENT_SYSTEM_VERSION, + massStorageDeviceRoot: '/some/usb/path', + massStorageDeviceFiles: ['/some/file', '/some/other/file'], + }) + .thenReturn(fakeUsbProviders.first) + when(fakeUsbProviders.first.refreshUpdateCache) + .calledWith(expect.any(Function)) + .thenResolve({ + version: '0.1.2', + files: { system: '/some/file', releaseNotes: null }, + releaseNotes: 'some fake notes', + downloadProgress: 100, + }) + when(fakeUsbProviders.first.getUpdateDetails) + .calledWith() + .thenReturn({ + version: '0.1.2', + files: { system: '/some/file', releaseNotes: null }, + releaseNotes: 'some fake notes', + downloadProgress: 100, + }) + return thisSubject + .handleAction({ + type: 'shell:ROBOT_MASS_STORAGE_DEVICE_ENUMERATED', + payload: { + rootPath: '/some/usb/path', + filePaths: ['/some/file', '/some/other/file'], + }, + meta: { shell: true }, + }) + .then(() => { + expect(getUsbProvider).toHaveBeenCalledWith({ + currentVersion: CURRENT_SYSTEM_VERSION, + massStorageDeviceRoot: '/some/usb/path', + massStorageDeviceFiles: ['/some/file', '/some/other/file'], + }) + return thisSubject.handleAction({ + type: 'shell:ROBOT_MASS_STORAGE_DEVICE_ENUMERATED', + payload: { + rootPath: '/some/usb/path', + filePaths: ['/some/file', '/some/other/file'], + }, + meta: { shell: true }, + }) + }) + .then(() => { + expect(getUsbProvider).toHaveBeenCalledOnce() + expect(dispatch).toHaveBeenCalledWith({ + type: 'robotUpdate:UPDATE_INFO', + payload: { + releaseNotes: 'some fake notes', + version: '0.1.2', + force: true, + target: 'flex', + }, + }) + expect(dispatch).toHaveBeenCalledWith({ + type: 'robotUpdate:UPDATE_VERSION', + payload: { + version: '0.1.2', + force: true, + target: 'flex', + }, + }) + }) + .then(() => { + vi.mocked(dispatch).mockReset() + return thisSubject.handleAction({ + type: 'robotUpdate:READ_SYSTEM_FILE', + payload: { target: 'flex' }, + meta: { shell: true }, + }) + }) + .then(() => { + expect(dispatch).toHaveBeenCalledWith({ + type: 'robotUpdate:FILE_INFO', + payload: { + systemFile: '/some/file', + version: '0.1.2', + isManualFile: false, + }, + }) + }) + }) + it('tears down a usb provider when it is removed', () => { + const thisSubject = subject as UpdateDriver + when(getUsbProvider) + .calledWith({ + currentVersion: CURRENT_SYSTEM_VERSION, + massStorageDeviceRoot: '/some/usb/path', + massStorageDeviceFiles: ['/some/file', '/some/other/file'], + }) + .thenReturn(fakeUsbProviders.first) + when(fakeUsbProviders.first.refreshUpdateCache) + .calledWith(expect.any(Function)) + .thenResolve({ + version: '1.2.3', + files: { system: '/some/file', releaseNotes: null }, + releaseNotes: 'some fake notes', + downloadProgress: 100, + }) + return thisSubject + .handleAction({ + type: 'shell:ROBOT_MASS_STORAGE_DEVICE_ENUMERATED', + payload: { + rootPath: '/some/usb/path', + filePaths: ['/some/file', '/some/other/file'], + }, + meta: { shell: true }, + }) + .then(() => { + expect(getUsbProvider).toHaveBeenCalledWith({ + currentVersion: CURRENT_SYSTEM_VERSION, + massStorageDeviceRoot: '/some/usb/path', + massStorageDeviceFiles: ['/some/file', '/some/other/file'], + }) + when(fakeUsbProviders.first.teardown).calledWith().thenResolve() + return thisSubject.handleAction({ + type: 'shell:ROBOT_MASS_STORAGE_DEVICE_REMOVED', + payload: { rootPath: '/some/usb/path' }, + meta: { shell: true }, + }) + }) + .then(() => { + expect(fakeUsbProviders.first.teardown).toHaveBeenCalledOnce() + }) + }) + it('re-adds a usb provider if it is inserted after being removed', () => { + const thisSubject = subject as UpdateDriver + when(getUsbProvider) + .calledWith({ + currentVersion: CURRENT_SYSTEM_VERSION, + massStorageDeviceRoot: '/some/usb/path', + massStorageDeviceFiles: ['/some/file', '/some/other/file'], + }) + .thenReturn(fakeUsbProviders.first) + when(fakeUsbProviders.first.refreshUpdateCache) + .calledWith(expect.any(Function)) + .thenResolve({ + version: '1.2.3', + files: { system: '/some/file', releaseNotes: null }, + releaseNotes: 'some fake notes', + downloadProgress: 100, + }) + return thisSubject + .handleAction({ + type: 'shell:ROBOT_MASS_STORAGE_DEVICE_ENUMERATED', + payload: { + rootPath: '/some/usb/path', + filePaths: ['/some/file', '/some/other/file'], + }, + meta: { shell: true }, + }) + .then(() => { + expect(getUsbProvider).toHaveBeenCalledWith({ + currentVersion: CURRENT_SYSTEM_VERSION, + massStorageDeviceRoot: '/some/usb/path', + massStorageDeviceFiles: ['/some/file', '/some/other/file'], + }) + when(fakeUsbProviders.first.teardown).calledWith().thenResolve() + return thisSubject.handleAction({ + type: 'shell:ROBOT_MASS_STORAGE_DEVICE_REMOVED', + payload: { rootPath: '/some/usb/path' }, + meta: { shell: true }, + }) + }) + .then(() => { + expect(fakeUsbProviders.first.teardown).toHaveBeenCalledOnce() + return thisSubject.handleAction({ + type: 'shell:ROBOT_MASS_STORAGE_DEVICE_ENUMERATED', + payload: { + rootPath: '/some/usb/path', + filePaths: ['/some/file', '/some/other/file'], + }, + meta: { shell: true }, + }) + }) + .then(() => { + expect(getUsbProvider).toHaveBeenCalledTimes(2) + }) + }) + it('prefers usb updates to web updates', () => { + const thisSubject = subject as UpdateDriver + when(getUsbProvider) + .calledWith({ + currentVersion: CURRENT_SYSTEM_VERSION, + massStorageDeviceRoot: '/some/usb/path', + massStorageDeviceFiles: ['/some/file', '/some/other/file'], + }) + .thenReturn(fakeUsbProviders.first) + when(fakeUsbProviders.first.getUpdateDetails) + .calledWith() + .thenReturn({ + version: '0.1.2', + files: { system: '/some/file', releaseNotes: null }, + releaseNotes: 'some fake notes', + downloadProgress: 100, + }) + when(fakeUsbProviders.first.refreshUpdateCache) + .calledWith(expect.any(Function)) + .thenResolve({ + version: '0.1.2', + files: { system: '/some/file', releaseNotes: null }, + releaseNotes: 'some fake notes', + downloadProgress: 100, + }) + when(fakeProvider.refreshUpdateCache) + .calledWith(expect.any(Function)) + .thenResolve({ + version: '1.2.3', + files: { + system: '/some/file/from/the/web', + releaseNotes: null, + }, + releaseNotes: 'some other notes', + downloadProgress: 100, + }) + return thisSubject + .handleAction({ + type: 'shell:ROBOT_MASS_STORAGE_DEVICE_ENUMERATED', + payload: { + rootPath: '/some/usb/path', + filePaths: ['/some/file', '/some/other/file'], + }, + meta: { shell: true }, + }) + .then(() => + thisSubject.handleAction({ + type: 'shell:CHECK_UPDATE', + meta: { shell: true }, + }) + ) + .then(() => { + expect(dispatch).toHaveBeenLastCalledWith({ + type: 'robotUpdate:UPDATE_VERSION', + payload: { version: '0.1.2', force: true, target: 'flex' }, + }) + }) + .then(() => { + vi.mocked(dispatch).mockReset() + return thisSubject.handleAction({ + type: 'robotUpdate:READ_SYSTEM_FILE', + payload: { target: 'flex' }, + meta: { shell: true }, + }) + }) + .then(() => { + expect(dispatch).toHaveBeenCalledWith({ + type: 'robotUpdate:FILE_INFO', + payload: { + systemFile: '/some/file', + version: '0.1.2', + isManualFile: false, + }, + }) + }) + }) + it('selects the highest version usb update', () => { + const thisSubject = subject as UpdateDriver + when(getUsbProvider) + .calledWith({ + currentVersion: CURRENT_SYSTEM_VERSION, + massStorageDeviceRoot: '/some/usb/path', + massStorageDeviceFiles: ['/some/file', '/some/other/file'], + }) + .thenReturn(fakeUsbProviders.first) + when(getUsbProvider) + .calledWith({ + currentVersion: CURRENT_SYSTEM_VERSION, + massStorageDeviceRoot: '/some/other/usb/path', + massStorageDeviceFiles: ['/some/third/file', '/some/fourth/file'], + }) + .thenReturn(fakeUsbProviders.second) + when(fakeUsbProviders.first.refreshUpdateCache) + .calledWith(expect.any(Function)) + .thenResolve({ + version: '1.2.3', + files: { system: '/some/file', releaseNotes: null }, + releaseNotes: 'some fake notes', + downloadProgress: 100, + }) + when(fakeUsbProviders.second.refreshUpdateCache) + .calledWith(expect.any(Function)) + .thenResolve({ + version: '0.1.2', + files: { system: '/some/other/file', releaseNotes: null }, + releaseNotes: 'some other fake notes', + downloadProgress: 100, + }) + when(fakeUsbProviders.first.getUpdateDetails) + .calledWith() + .thenReturn({ + version: '1.2.3', + files: { system: '/some/file', releaseNotes: null }, + releaseNotes: 'some fake notes', + downloadProgress: 100, + }) + when(fakeUsbProviders.second.getUpdateDetails) + .calledWith() + .thenReturn({ + version: '0.1.2', + files: { system: '/some/other/filefile', releaseNotes: null }, + releaseNotes: 'some other fake notes', + downloadProgress: 100, + }) + return thisSubject + .handleAction({ + type: 'shell:ROBOT_MASS_STORAGE_DEVICE_ENUMERATED', + payload: { + rootPath: '/some/usb/path', + filePaths: ['/some/file', '/some/other/file'], + }, + meta: { shell: true }, + }) + .then(() => { + expect(getUsbProvider).toHaveBeenCalledWith({ + currentVersion: CURRENT_SYSTEM_VERSION, + massStorageDeviceRoot: '/some/usb/path', + massStorageDeviceFiles: ['/some/file', '/some/other/file'], + }) + vi.mocked(dispatch).mockReset() + return thisSubject.handleAction({ + type: 'shell:ROBOT_MASS_STORAGE_DEVICE_ENUMERATED', + payload: { + rootPath: '/some/other/usb/path', + filePaths: ['/some/third/file', '/some/fourth/file'], + }, + meta: { shell: true }, + }) + }) + .then(() => { + expect(getUsbProvider).toHaveBeenCalledWith({ + currentVersion: CURRENT_SYSTEM_VERSION, + massStorageDeviceRoot: '/some/usb/path', + massStorageDeviceFiles: ['/some/file', '/some/other/file'], + }) + expect(dispatch).toHaveBeenNthCalledWith(1, { + type: 'robotUpdate:UPDATE_INFO', + payload: { + releaseNotes: 'some fake notes', + version: '1.2.3', + force: true, + target: 'flex', + }, + }) + expect(dispatch).toHaveBeenNthCalledWith(2, { + type: 'robotUpdate:UPDATE_VERSION', + payload: { + version: '1.2.3', + force: true, + target: 'flex', + }, + }) + }) + .then(() => { + vi.mocked(dispatch).mockReset() + return thisSubject.handleAction({ + type: 'robotUpdate:READ_SYSTEM_FILE', + payload: { target: 'flex' }, + meta: { shell: true }, + }) + }) + .then(() => { + expect(dispatch).toHaveBeenCalledWith({ + type: 'robotUpdate:FILE_INFO', + payload: { + systemFile: '/some/file', + version: '1.2.3', + isManualFile: false, + }, + }) + }) + }) +}) diff --git a/app-shell-odd/src/system-update/__tests__/release-files.test.ts b/app-shell-odd/src/system-update/__tests__/release-files.test.ts deleted file mode 100644 index bd2a421b910..00000000000 --- a/app-shell-odd/src/system-update/__tests__/release-files.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -// TODO(mc, 2020-06-11): test all release-files functions -import { vi, describe, it, expect, afterAll } from 'vitest' -import path from 'path' -import { promises as fs } from 'fs' -import fse from 'fs-extra' -import tempy from 'tempy' - -import { cleanupReleaseFiles } from '../release-files' -vi.mock('electron-store') -vi.mock('../../log') - -describe('system release files utilities', () => { - const tempDirs: string[] = [] - const makeEmptyDir = (): string => { - const dir: string = tempy.directory() - tempDirs.push(dir) - return dir - } - - afterAll(async () => { - await Promise.all(tempDirs.map(d => fse.remove(d))) - }) - - describe('cleanupReleaseFiles', () => { - it('should leave current version files alone', () => { - const dir = makeEmptyDir() - const releaseDir = path.join(dir, '4.0.0') - - return fs - .mkdir(releaseDir) - .then(() => cleanupReleaseFiles(dir, '4.0.0')) - .then(() => fs.readdir(dir)) - .then(files => { - expect(files).toEqual(['4.0.0']) - }) - }) - - it('should leave support files alone', () => { - const dir = makeEmptyDir() - const releaseDir = path.join(dir, '4.0.0') - const releaseManifest = path.join(dir, 'releases.json') - - return Promise.all([ - fs.mkdir(releaseDir), - fse.writeJson(releaseManifest, { hello: 'world' }), - ]) - .then(() => cleanupReleaseFiles(dir, '4.0.0')) - .then(() => fs.readdir(dir)) - .then(files => { - expect(files).toEqual(['4.0.0', 'releases.json']) - }) - }) - - it('should delete other directories', () => { - const dir = makeEmptyDir() - const releaseDir = path.join(dir, '4.0.0') - const oldReleaseDir = path.join(dir, '3.9.0') - const olderReleaseDir = path.join(dir, '3.8.0') - - return Promise.all([ - fs.mkdir(releaseDir), - fs.mkdir(oldReleaseDir), - fs.mkdir(olderReleaseDir), - ]) - .then(() => cleanupReleaseFiles(dir, '4.0.0')) - .then(() => fs.readdir(dir)) - .then(files => { - expect(files).toEqual(['4.0.0']) - }) - }) - }) -}) diff --git a/app-shell-odd/src/system-update/__tests__/release-manifest.test.ts b/app-shell-odd/src/system-update/__tests__/release-manifest.test.ts deleted file mode 100644 index 89091d2731c..00000000000 --- a/app-shell-odd/src/system-update/__tests__/release-manifest.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, it, vi, beforeEach, afterEach, expect } from 'vitest' -import * as Http from '../../http' -import * as Dirs from '../directories' -import { downloadAndCacheReleaseManifest } from '../release-manifest' - -vi.mock('../../http') -vi.mock('../directories') -vi.mock('../../log') -vi.mock('electron-store') -const fetchJson = Http.fetchJson -const getManifestCacheDir = Dirs.getManifestCacheDir - -const MOCK_DIR = 'mock_dir' -const MANIFEST_URL = 'http://example.com/releases.json' -const MOCK_MANIFEST = {} as any - -describe('release manifest utilities', () => { - beforeEach(() => { - vi.mocked(getManifestCacheDir).mockReturnValue(MOCK_DIR) - vi.mocked(fetchJson).mockResolvedValue(MOCK_MANIFEST) - }) - - afterEach(() => { - vi.resetAllMocks() - }) - - it('should download and save the manifest from a url', async () => { - await expect( - downloadAndCacheReleaseManifest(MANIFEST_URL) - ).resolves.toEqual(MOCK_MANIFEST) - expect(fetchJson).toHaveBeenCalledWith(MANIFEST_URL) - }) - - it('should pull the manifest from the file if the manifest download fails', async () => { - const error = new Error('Failed to download') - vi.mocked(fetchJson).mockRejectedValue(error) - await expect( - downloadAndCacheReleaseManifest(MANIFEST_URL) - ).resolves.toEqual(MOCK_MANIFEST) - expect(fetchJson).toHaveBeenCalledWith(MANIFEST_URL) - }) -}) diff --git a/app-shell-odd/src/system-update/constants.ts b/app-shell-odd/src/system-update/constants.ts new file mode 100644 index 00000000000..575b64230b5 --- /dev/null +++ b/app-shell-odd/src/system-update/constants.ts @@ -0,0 +1,11 @@ +const OPENTRONS_PROJECT: string = _OPENTRONS_PROJECT_ + +export const FLEX_MANIFEST_URL = + OPENTRONS_PROJECT && OPENTRONS_PROJECT.includes('robot-stack') + ? 'https://builds.opentrons.com/ot3-oe/releases.json' + : 'https://ot3-development.builds.opentrons.com/ot3-oe/releases.json' + +export const SYSTEM_UPDATE_DIRECTORY = '__ot_system_update__' +export const VERSION_FILENAME = 'VERSION.json' +export const REASONABLE_VERSION_FILE_SIZE_B = 4096 +export const SYSTEM_FILENAME = 'system-update.zip' diff --git a/app-shell-odd/src/system-update/directories.ts b/app-shell-odd/src/system-update/directories.ts index c2723153505..757f47bc44a 100644 --- a/app-shell-odd/src/system-update/directories.ts +++ b/app-shell-odd/src/system-update/directories.ts @@ -1,15 +1,6 @@ import { app } from 'electron' import path from 'path' +import { SYSTEM_UPDATE_DIRECTORY } from './constants' -const SYSTEM_UPDATE_DIRECTORY = path.join( - app.getPath('sessionData'), - '__ot_system_update__' -) - -export const getSystemUpdateDir = (): string => SYSTEM_UPDATE_DIRECTORY - -export const getFileDownloadDir = (version: string): string => - path.join(SYSTEM_UPDATE_DIRECTORY, version) - -export const getManifestCacheDir = (): string => - path.join(SYSTEM_UPDATE_DIRECTORY, 'releases.json') +export const getSystemUpdateDir = (): string => + path.join(app.getPath('userData'), SYSTEM_UPDATE_DIRECTORY) diff --git a/app-shell-odd/src/system-update/from-usb/__tests__/provider.test.ts b/app-shell-odd/src/system-update/from-usb/__tests__/provider.test.ts new file mode 100644 index 00000000000..cbdf79435dc --- /dev/null +++ b/app-shell-odd/src/system-update/from-usb/__tests__/provider.test.ts @@ -0,0 +1,205 @@ +import { it, describe, vi, afterEach, expect } from 'vitest' +import { when } from 'vitest-when' +import { getProvider } from '../provider' +import { getLatestMassStorageUpdateFile as _getLatestMassStorageUpdateFile } from '../scan-device' + +vi.mock('../scan-device') +vi.mock('../../../log') + +const getLatestMassStorageUpdateFile = vi.mocked( + _getLatestMassStorageUpdateFile +) + +describe('system-update/from-usb/provider', () => { + afterEach(() => { + vi.resetAllMocks() + }) + it('signals available updates when given available updates', () => { + when(getLatestMassStorageUpdateFile) + .calledWith(['/storage/valid-release.zip']) + .thenResolve({ path: '/storage/valid-release.zip', version: '1.2.3' }) + const progress = vi.fn() + const provider = getProvider({ + currentVersion: '1.0.0', + massStorageDeviceRoot: '/storage', + massStorageDeviceFiles: ['/storage/valid-release.zip'], + }) + const expectedUpdate = { + version: '1.2.3', + files: { + system: '/storage/valid-release.zip', + releaseNotes: expect.any(String), + }, + releaseNotes: expect.any(String), + downloadProgress: 100, + } + return expect(provider.refreshUpdateCache(progress)) + .resolves.toEqual(expectedUpdate) + .then(() => { + expect(progress).toHaveBeenLastCalledWith(expectedUpdate) + }) + }) + it('signals no available update when given no available updates', () => { + when(getLatestMassStorageUpdateFile) + .calledWith(['/storage/blahblah']) + .thenResolve(null) + const progress = vi.fn() + const provider = getProvider({ + currentVersion: '1.0.0', + massStorageDeviceRoot: '/storage', + massStorageDeviceFiles: ['/storage/blahblah'], + }) + const expectedUpdate = { + version: null, + files: null, + releaseNotes: null, + downloadProgress: 0, + } + return expect(provider.refreshUpdateCache(progress)) + .resolves.toEqual(expectedUpdate) + .then(() => { + expect(progress).toHaveBeenLastCalledWith(expectedUpdate) + }) + }) + it('signals no available update when the scan throws', () => { + when(getLatestMassStorageUpdateFile) + .calledWith(['/storage/blahblah']) + .thenReject(new Error('oh no')) + const progress = vi.fn() + const provider = getProvider({ + currentVersion: '1.0.0', + massStorageDeviceRoot: '/storage', + massStorageDeviceFiles: ['/storage/blahblah'], + }) + const expectedUpdate = { + version: null, + files: null, + releaseNotes: null, + downloadProgress: 0, + } + return expect(provider.refreshUpdateCache(progress)) + .resolves.toEqual(expectedUpdate) + .then(() => { + expect(progress).toHaveBeenLastCalledWith(expectedUpdate) + }) + }) + it('signals no available update when the highest version update is the same version as current', () => { + when(getLatestMassStorageUpdateFile) + .calledWith(['/storage/valid-release.zip']) + .thenResolve({ path: '/storage/valid-release.zip', version: '1.0.0' }) + const progress = vi.fn() + const provider = getProvider({ + currentVersion: '1.0.0', + massStorageDeviceRoot: '/storage', + massStorageDeviceFiles: ['/storage/valid-release.zip'], + }) + const expectedUpdate = { + version: null, + files: null, + releaseNotes: null, + downloadProgress: 0, + } + return expect(provider.refreshUpdateCache(progress)) + .resolves.toEqual(expectedUpdate) + .then(() => { + expect(progress).toHaveBeenLastCalledWith(expectedUpdate) + }) + }) + it('throws when torn down before scanning', () => { + const provider = getProvider({ + currentVersion: '1.0.0', + massStorageDeviceRoot: '/', + massStorageDeviceFiles: [], + }) + const progress = vi.fn() + return provider + .teardown() + .then(() => + expect(provider.refreshUpdateCache(progress)).rejects.toThrow() + ) + .then(() => + expect(progress).toHaveBeenLastCalledWith({ + version: null, + files: null, + releaseNotes: null, + downloadProgress: 0, + }) + ) + }) + it('throws when torn down right after scanning', () => { + const provider = getProvider({ + currentVersion: '1.0.0', + massStorageDeviceRoot: '/', + massStorageDeviceFiles: [], + }) + const progress = vi.fn() + when(getLatestMassStorageUpdateFile) + .calledWith(['/storage/valid-release.zip']) + .thenDo(() => + provider.teardown().then(() => ({ + path: '/storage/valid-release.zip', + version: '1.0.0', + })) + ) + return provider + .teardown() + .then(() => + expect(provider.refreshUpdateCache(progress)).rejects.toThrow() + ) + .then(() => + expect(progress).toHaveBeenLastCalledWith({ + version: null, + files: null, + releaseNotes: null, + downloadProgress: 0, + }) + ) + }) + it('will not run two checks at once', () => { + when(getLatestMassStorageUpdateFile) + .calledWith(['/storage/valid-release.zip']) + .thenResolve({ path: '/storage/valid-release.zip', version: '1.0.0' }) + const progress = vi.fn() + const provider = getProvider({ + currentVersion: '1.0.0', + massStorageDeviceRoot: '/storage', + massStorageDeviceFiles: ['/storage/valid-release.zip'], + }) + const expectedUpdate = { + version: null, + files: null, + releaseNotes: null, + downloadProgress: 0, + } + const first = provider.refreshUpdateCache(progress) + const second = provider.refreshUpdateCache(progress) + return Promise.all([ + expect(first).resolves.toEqual(expectedUpdate), + expect(second).rejects.toThrow(), + ]).then(() => expect(getLatestMassStorageUpdateFile).toHaveBeenCalledOnce()) + }) + it('will run a second check after the first ends', () => { + when(getLatestMassStorageUpdateFile) + .calledWith(['/storage/valid-release.zip']) + .thenResolve({ path: '/storage/valid-release.zip', version: '1.0.0' }) + const progress = vi.fn() + const provider = getProvider({ + currentVersion: '1.0.0', + massStorageDeviceRoot: '/storage', + massStorageDeviceFiles: ['/storage/valid-release.zip'], + }) + const expectedUpdate = { + version: null, + files: null, + releaseNotes: null, + downloadProgress: 0, + } + return expect(provider.refreshUpdateCache(progress)) + .resolves.toEqual(expectedUpdate) + .then(() => + expect(provider.refreshUpdateCache(progress)).resolves.toEqual( + expectedUpdate + ) + ) + }) +}) diff --git a/app-shell-odd/src/system-update/from-usb/__tests__/scan-device.test.ts b/app-shell-odd/src/system-update/from-usb/__tests__/scan-device.test.ts new file mode 100644 index 00000000000..ff51e89abf3 --- /dev/null +++ b/app-shell-odd/src/system-update/from-usb/__tests__/scan-device.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { when } from 'vitest-when' + +import { getVersionFromZipIfValid as _getVersionFromZipIfValid } from '../scan-zip' +import { getLatestMassStorageUpdateFile } from '../scan-device' +vi.mock('../../../log') +vi.mock('../scan-zip') +const getVersionFromZipIfValid = vi.mocked(_getVersionFromZipIfValid) + +describe('system-update/from-usb/scan-device', () => { + afterEach(() => { + vi.resetAllMocks() + }) + it('returns the single file passed in', () => { + when(getVersionFromZipIfValid) + .calledWith('/some/random/zip/file.zip') + .thenResolve({ path: '/some/random/zip/file.zip', version: '0.0.1' }) + return expect( + getLatestMassStorageUpdateFile(['/some/random/zip/file.zip']) + ).resolves.toEqual({ path: '/some/random/zip/file.zip', version: '0.0.1' }) + }) + it('returns null if no files are passed in', () => + expect(getLatestMassStorageUpdateFile([])).resolves.toBeNull()) + it('returns null if no suitable zips are found', () => { + when(getVersionFromZipIfValid) + .calledWith('/some/random/zip/file.zip') + .thenReject(new Error('no version found')) + return expect( + getLatestMassStorageUpdateFile(['/some/random/zip/file.zip']) + ).resolves.toBeNull() + }) + it('checks only the zip file', () => { + when(getVersionFromZipIfValid) + .calledWith('/some/random/zip/file.zip') + .thenResolve({ path: '/some/random/zip/file.zip', version: '0.0.1' }) + return expect( + getLatestMassStorageUpdateFile([ + '/some/random/zip/file.zip', + '/some/other/random/file', + ]) + ) + .resolves.toEqual({ path: '/some/random/zip/file.zip', version: '0.0.1' }) + .then(() => expect(getVersionFromZipIfValid).toHaveBeenCalledOnce()) + }) + it('returns the highest version', () => { + when(getVersionFromZipIfValid) + .calledWith('higher-version.zip') + .thenResolve({ path: 'higher-version.zip', version: '1.0.0' }) + when(getVersionFromZipIfValid) + .calledWith('lower-version.zip') + .thenResolve({ path: 'higher-version.zip', version: '1.0.0-alpha.0' }) + return expect( + getLatestMassStorageUpdateFile([ + 'higher-version.zip', + 'lower-version.zip', + ]) + ).resolves.toEqual({ path: 'higher-version.zip', version: '1.0.0' }) + }) +}) diff --git a/app-shell-odd/src/system-update/from-usb/__tests__/scan-zip.test.ts b/app-shell-odd/src/system-update/from-usb/__tests__/scan-zip.test.ts new file mode 100644 index 00000000000..226267a5a11 --- /dev/null +++ b/app-shell-odd/src/system-update/from-usb/__tests__/scan-zip.test.ts @@ -0,0 +1,151 @@ +import { it, describe, expect, vi } from 'vitest' +import path from 'path' +import { exec as _exec } from 'child_process' +import { promisify } from 'util' +import { writeFile, mkdir } from 'fs/promises' +import { REASONABLE_VERSION_FILE_SIZE_B } from '../../constants' +import { directoryWithCleanup } from '../../utils' +import { getVersionFromZipIfValid } from '../scan-zip' + +vi.mock('../../../log') +const exec = promisify(_exec) + +const zipCommand = ( + tempDir: string, + zipName?: string, + zipContentSubDirectory?: string +): string => + `zip -j ${path.join(tempDir, zipName ?? 'test.zip')} ${path.join( + tempDir, + zipContentSubDirectory ?? 'test', + '*' + )}` + +describe('system-update/from-usb/scan-zip', () => { + it('should read version data from a valid zip file', () => + directoryWithCleanup(directory => + mkdir(path.join(directory, 'test')) + .then(() => + writeFile( + path.join(directory, 'test', 'VERSION.json'), + JSON.stringify({ + robot_type: 'OT-3 Standard', + opentrons_api_version: '1.2.3', + }) + ) + ) + .then(() => exec(zipCommand(directory))) + .then(() => + expect( + getVersionFromZipIfValid(path.join(directory, 'test.zip')) + ).resolves.toEqual({ + path: path.join(directory, 'test.zip'), + version: '1.2.3', + }) + ) + )) + + it('should throw if there is no version file', () => + directoryWithCleanup(directory => + mkdir(path.join(directory, 'test')) + .then(() => writeFile(path.join(directory, 'test', 'dummy'), 'lalala')) + .then(() => exec(zipCommand(directory))) + .then(() => + expect( + getVersionFromZipIfValid(path.join(directory, 'test.zip')) + ).rejects.toThrow() + ) + )) + it('should throw if the version file is too big', () => + directoryWithCleanup(directory => + mkdir(path.join(directory, 'test')) + .then(() => + writeFile( + path.join(directory, 'test', 'VERSION.json'), + `{data: "${'a'.repeat(REASONABLE_VERSION_FILE_SIZE_B + 1)}"}` + ) + ) + .then(() => + exec( + `head -c ${ + REASONABLE_VERSION_FILE_SIZE_B + 1 + } /dev/zero > ${path.join(directory, 'test', 'VERSION.json')} ` + ) + ) + .then(() => exec(zipCommand(directory))) + .then(() => + expect( + getVersionFromZipIfValid(path.join(directory, 'test.zip')) + ).rejects.toThrow() + ) + )) + it('should throw if the version file is not valid json', () => + directoryWithCleanup(directory => + mkdir(path.join(directory, 'test')) + .then(() => + writeFile(path.join(directory, 'test', 'VERSION.json'), 'asdaasdas') + ) + .then(() => exec(zipCommand(directory))) + .then(() => + expect( + getVersionFromZipIfValid(path.join(directory, 'test.zip')) + ).rejects.toThrow() + ) + )) + it('should throw if the version file is for OT-2', () => + directoryWithCleanup(directory => + mkdir(path.join(directory, 'test')) + .then(() => + writeFile( + path.join(directory, 'test', 'VERSION.json'), + JSON.stringify({ + robot_type: 'OT-2 Standard', + opentrons_api_version: '1.2.3', + }) + ) + ) + .then(() => exec(zipCommand(directory))) + .then(() => + expect( + getVersionFromZipIfValid(path.join(directory, 'test.zip')) + ).rejects.toThrow() + ) + )) + it('should throw if not given a zip file', () => + directoryWithCleanup(directory => + mkdir(path.join(directory, 'test')) + .then(() => writeFile(path.join(directory, 'test.zip'), 'aosidasdasd')) + .then(() => + expect( + getVersionFromZipIfValid(path.join(directory, 'test.zip')) + ).rejects.toThrow() + ) + )) + it('should throw if given a zip file with internal directories', () => + directoryWithCleanup(directory => + mkdir(path.join(directory, 'test')) + .then(() => + writeFile( + path.join(directory, 'test', 'VERSION.json'), + JSON.stringify({ + robot_type: 'OT-3 Standard', + opentrons_api_version: '1.2.3', + }) + ) + ) + .then(() => + exec( + `zip ${path.join(directory, 'test.zip')} ${path.join( + directory, + 'test', + '*' + )}` + ) + ) + .then(() => + expect( + getVersionFromZipIfValid(path.join(directory, 'test.zip')) + ).rejects.toThrow() + ) + )) +}) diff --git a/app-shell-odd/src/system-update/from-usb/index.ts b/app-shell-odd/src/system-update/from-usb/index.ts new file mode 100644 index 00000000000..9ae1d7e4751 --- /dev/null +++ b/app-shell-odd/src/system-update/from-usb/index.ts @@ -0,0 +1,2 @@ +export { getProvider } from './provider' +export type { USBUpdateSource } from './provider' diff --git a/app-shell-odd/src/system-update/from-usb/provider.ts b/app-shell-odd/src/system-update/from-usb/provider.ts new file mode 100644 index 00000000000..53913fab790 --- /dev/null +++ b/app-shell-odd/src/system-update/from-usb/provider.ts @@ -0,0 +1,111 @@ +import tempy from 'tempy' +import path from 'path' +import { rm, writeFile } from 'fs/promises' +import type { UpdateProvider, ResolvedUpdate, ProgressCallback } from '../types' +import { getLatestMassStorageUpdateFile } from './scan-device' +import { createLogger } from '../../log' + +export interface USBUpdateSource { + currentVersion: string + massStorageDeviceRoot: string + massStorageDeviceFiles: string[] +} + +const fakeReleaseNotesForMassStorage = (version: string): string => ` +# Opentrons Robot Software Version ${version} + +This update is from a USB mass storage device connected to your Flex, and release notes cannot be shown. + +Don't remove the USB mass storage device while the update is in progress. +` +const log = createLogger('system-updates/from-usb') + +export function getProvider( + from: USBUpdateSource +): UpdateProvider { + const noUpdate = { + version: null, + files: null, + releaseNotes: null, + downloadProgress: 0, + } as const + let currentUpdate: ResolvedUpdate = noUpdate + let canceller = new AbortController() + let currentCheck: Promise | null = null + const tempdir = tempy.directory() + let tornDown = false + + const checkUpdates = async ( + progress: ProgressCallback + ): Promise => { + const myCanceller = canceller + if (myCanceller.signal.aborted || tornDown) { + progress(noUpdate) + throw new Error('cache torn down') + } + const updateFile = await getLatestMassStorageUpdateFile( + from.massStorageDeviceFiles + ).catch(() => null) + if (myCanceller.signal.aborted) { + progress(noUpdate) + throw new Error('cache torn down') + } + if (updateFile == null) { + log.info(`No update file in presented files`) + progress(noUpdate) + currentUpdate = noUpdate + return noUpdate + } + log.info(`Update file found for version ${updateFile.version}`) + if (updateFile.version === from.currentVersion) { + progress(noUpdate) + currentUpdate = noUpdate + return noUpdate + } + await writeFile( + path.join(tempdir, 'dummy-release-notes.md'), + fakeReleaseNotesForMassStorage(updateFile.version) + ) + if (myCanceller.signal.aborted) { + progress(noUpdate) + throw new Error('cache torn down') + } + const update = { + version: updateFile.version, + files: { + system: updateFile.path, + releaseNotes: path.join(tempdir, 'dummy-release-notes.md'), + }, + releaseNotes: fakeReleaseNotesForMassStorage(updateFile.version), + downloadProgress: 100, + } as const + currentUpdate = update + progress(update) + return update + } + return { + refreshUpdateCache: progressCallback => { + if (currentCheck != null) { + return new Promise((resolve, reject) => { + reject(new Error('Check already ongoing')) + }) + } + const updatePromise = checkUpdates(progressCallback) + currentCheck = updatePromise + return updatePromise.finally(() => { + currentCheck = null + }) + }, + getUpdateDetails: () => currentUpdate, + lockUpdateCache: () => {}, + unlockUpdateCache: () => {}, + teardown: () => { + canceller.abort() + tornDown = true + canceller = new AbortController() + return rm(tempdir, { recursive: true, force: true }) + }, + name: () => `USBUpdateProvider from ${from.massStorageDeviceRoot}`, + source: () => from, + } +} diff --git a/app-shell-odd/src/system-update/from-usb/scan-device.ts b/app-shell-odd/src/system-update/from-usb/scan-device.ts new file mode 100644 index 00000000000..0c0e7f3e40c --- /dev/null +++ b/app-shell-odd/src/system-update/from-usb/scan-device.ts @@ -0,0 +1,37 @@ +import Semver from 'semver' +import { getVersionFromZipIfValid } from './scan-zip' +import type { FileDetails } from './scan-zip' + +import { createLogger } from '../../log' +const log = createLogger('system-udpate/from-usb/scan-device') + +const higherVersion = (a: FileDetails | null, b: FileDetails): FileDetails => + a == null ? b : Semver.gt(a.version, b.version) ? a : b + +const mostRecentUpdateOf = (candidates: FileDetails[]): FileDetails | null => + candidates.reduce( + (prev, current) => higherVersion(prev, current), + null + ) + +const getMassStorageUpdateFiles = ( + filePaths: string[] +): Promise => + Promise.all( + filePaths.map(path => + path.endsWith('.zip') + ? getVersionFromZipIfValid(path).catch(() => null) + : new Promise(resolve => { + resolve(null) + }) + ) + ).then(values => { + const filtered = values.filter(entry => entry != null) as FileDetails[] + log.debug(`scan device found ${filtered}`) + return filtered + }) + +export const getLatestMassStorageUpdateFile = ( + filePaths: string[] +): Promise => + getMassStorageUpdateFiles(filePaths).then(mostRecentUpdateOf) diff --git a/app-shell-odd/src/system-update/from-usb/scan-zip.ts b/app-shell-odd/src/system-update/from-usb/scan-zip.ts new file mode 100644 index 00000000000..b6bce376096 --- /dev/null +++ b/app-shell-odd/src/system-update/from-usb/scan-zip.ts @@ -0,0 +1,88 @@ +import StreamZip from 'node-stream-zip' +import Semver from 'semver' +import { createLogger } from '../../log' +import { REASONABLE_VERSION_FILE_SIZE_B, VERSION_FILENAME } from '../constants' + +const log = createLogger('system-update/from-usb/scan-zip') + +export interface FileDetails { + path: string + version: string +} + +export const getVersionFromZipIfValid = (path: string): Promise => + new Promise((resolve, reject) => { + const zip = new StreamZip({ file: path, storeEntries: true }) + zip.on('ready', () => { + log.info(`Reading zip from ${path}`) + getVersionFromOpenedZipIfValid(zip) + .then(version => { + log.info(`Zip at ${path} has version ${version}`) + zip.close() + resolve({ version, path }) + }) + .catch(err => { + log.info( + `Zip at ${path} was read but could not be parsed: ${err.name}: ${err.message}` + ) + zip.close() + reject(err) + }) + }) + zip.on('error', err => { + log.info(`Zip at ${path} could not be read: ${err.name}: ${err.message}`) + zip.close() + reject(err) + }) + }) + +export const getVersionFromOpenedZipIfValid = ( + zip: StreamZip +): Promise => + new Promise((resolve, reject) => { + const found = Object.values(zip.entries()).reduce((prev, entry) => { + log.debug( + `Checking if ${entry.name} is ${VERSION_FILENAME}, is a file (${entry.isFile}), and ${entry.size}<${REASONABLE_VERSION_FILE_SIZE_B}` + ) + if ( + entry.isFile && + entry.name === VERSION_FILENAME && + entry.size < REASONABLE_VERSION_FILE_SIZE_B + ) { + log.debug(`${entry.name} is a version file candidate`) + const contents = zip.entryDataSync(entry.name).toString('ascii') + log.debug(`version contents: ${contents}`) + try { + const parsedContents = JSON.parse(contents) + if (parsedContents?.robot_type !== 'OT-3 Standard') { + reject(new Error('not a Flex release file')) + } + const fileVersion = parsedContents?.opentrons_api_version + const version = Semver.valid(fileVersion as string) + if (version === null) { + reject(new Error(`${fileVersion} is not a valid version`)) + return prev + } else { + log.info(`Found version file version ${version}`) + resolve(version) + return true + } + } catch (err: any) { + if (err instanceof Error) { + log.error( + `Failed to read ${entry.name}: ${err.name}: ${err.message}` + ) + } else { + log.error(`Failed to ready ${entry.name}: ${err}`) + } + reject(err) + return prev + } + } else { + return prev + } + }, false) + if (!found) { + reject(new Error('No version file found in zip')) + } + }) diff --git a/app-shell-odd/src/system-update/from-web/__tests__/latest-update.test.ts b/app-shell-odd/src/system-update/from-web/__tests__/latest-update.test.ts new file mode 100644 index 00000000000..b07d6947861 --- /dev/null +++ b/app-shell-odd/src/system-update/from-web/__tests__/latest-update.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest' +import { latestVersionForChannel } from '../latest-update' + +describe('latest-update', () => { + it.each([ + ['8.0.0', '7.0.0', '8.0.0', ''], + ['7.0.0', '8.0.0', '8.0.0', ''], + ['8.10.0', '8.9.0', '8.10.0', ''], + ['8.9.0', '8.10.0', '8.10.0', ''], + ['8.0.0-alpha.0', '8.0.0-alpha.1', '8.0.0-alpha.1', 'alpha'], + ['8.0.0-alpha.1', '8.0.0-alpha.0', '8.0.0-alpha.1', 'alpha'], + ['8.1.0-alpha.0', '8.0.0-alpha.1', '8.1.0-alpha.0', 'alpha'], + ['8.0.0-alpha.1', '8.1.0-alpha.0', '8.1.0-alpha.0', 'alpha'], + ])( + 'choosing between %s and %s should result in %s', + (first, second, higher, channel) => { + expect(latestVersionForChannel([first, second], channel)).toEqual(higher) + } + ) + it('ignores updates from different channels', () => { + expect( + latestVersionForChannel( + ['8.0.0', '9.0.0-alpha.0', '10.0.0-beta.1', '2.0.0'], + 'production' + ) + ).toEqual('8.0.0') + expect( + latestVersionForChannel( + ['8.0.0', '9.0.0-alpha.0', '10.0.0-beta.1', '2.0.0'], + 'alpha' + ) + ).toEqual('9.0.0-alpha.0') + expect( + latestVersionForChannel( + ['8.0.0', '9.0.0-alpha.0', '10.0.0-beta.1', '2.0.0'], + 'beta' + ) + ).toEqual('10.0.0-beta.1') + }) +}) diff --git a/app-shell-odd/src/system-update/from-web/__tests__/provider.test.ts b/app-shell-odd/src/system-update/from-web/__tests__/provider.test.ts new file mode 100644 index 00000000000..3ffe2e4ec08 --- /dev/null +++ b/app-shell-odd/src/system-update/from-web/__tests__/provider.test.ts @@ -0,0 +1,774 @@ +import { vi, describe, it, expect, afterEach } from 'vitest' +import { when } from 'vitest-when' + +import { LocalAbortError } from '../../../http' +import { getProvider } from '../provider' +import { getOrDownloadManifest as _getOrDownloadManifest } from '../release-manifest' +import { cleanUpAndGetOrDownloadReleaseFiles as _cleanUpAndGetOrDownloadReleaseFiles } from '../release-files' + +vi.mock('../../../log') +vi.mock('../release-manifest', async importOriginal => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const original = await importOriginal() + return { + ...original, + getOrDownloadManifest: vi.fn(), + } +}) +vi.mock('../release-files') + +const getOrDownloadManifest = vi.mocked(_getOrDownloadManifest) +const cleanUpAndGetOrDownloadReleaseFiles = vi.mocked( + _cleanUpAndGetOrDownloadReleaseFiles +) + +describe('provider.refreshUpdateCache happy paths', () => { + afterEach(() => { + vi.resetAllMocks() + }) + it('says there is no update if the latest version is the current version', () => { + when(getOrDownloadManifest) + .calledWith( + 'http://opentrons.com/releases.json', + '/some/random/directory', + expect.any(AbortController) + ) + .thenResolve({ + production: { + '1.2.3': { + system: 'http://opentrons.com/system.zip', + fullImage: 'http://opentrons.com/fullImage.zip', + version: 'http://opentrons.com/version.json', + releaseNotes: 'http://opentrons.com/releaseNotes.md', + }, + }, + }) + const progressCallback = vi.fn() + const provider = getProvider({ + manifestUrl: 'http://opentrons.com/releases.json', + channel: 'release', + updateCacheDirectory: '/some/random/directory', + currentVersion: '1.2.3', + }) + expect(provider.getUpdateDetails()).toEqual({ + version: null, + files: null, + releaseNotes: null, + downloadProgress: 0, + }) + return expect(provider.refreshUpdateCache(progressCallback)) + .resolves.toEqual({ + version: null, + files: null, + releaseNotes: null, + downloadProgress: 0, + }) + .then(() => { + expect(progressCallback).toHaveBeenCalledWith({ + version: null, + files: null, + releaseNotes: null, + downloadProgress: 0, + }) + expect(provider.getUpdateDetails()).toEqual({ + version: null, + files: null, + releaseNotes: null, + downloadProgress: 0, + }) + expect(cleanUpAndGetOrDownloadReleaseFiles).not.toHaveBeenCalled() + }) + }) + it('says there is an update if a cached update is needed', () => { + const releaseUrls = { + system: 'http://opentrons.com/system.zip', + fullImage: 'http://opentrons.com/fullImage.zip', + version: 'http://opentrons.com/version.json', + releaseNotes: 'http://opentrons.com/releaseNotes.md', + } + const releaseFiles = { + system: '/some/random/directory/cached-release-1.2.3/ot3-system.zip', + releaseNotes: + '/some/random/directory/cached-release-1.2.3/releaseNotes.md', + } + const releaseData = { + ...releaseFiles, + releaseNotesContent: 'oh look some release notes cool', + } + when(getOrDownloadManifest) + .calledWith( + 'http://opentrons.com/releases.json', + '/some/random/directory', + expect.any(AbortController) + ) + .thenResolve({ + production: { + '1.2.3': releaseUrls, + }, + }) + + when(cleanUpAndGetOrDownloadReleaseFiles) + .calledWith( + releaseUrls, + '/some/random/directory/versions', + '1.2.3', + expect.any(Function), + expect.any(Object) + ) + .thenResolve(releaseData) + + const progressCallback = vi.fn() + const provider = getProvider({ + manifestUrl: 'http://opentrons.com/releases.json', + channel: 'release', + updateCacheDirectory: '/some/random/directory', + currentVersion: '1.0.0', + }) + expect(provider.getUpdateDetails()).toEqual({ + version: null, + files: null, + releaseNotes: null, + downloadProgress: 0, + }) + return expect(provider.refreshUpdateCache(progressCallback)) + .resolves.toEqual({ + version: '1.2.3', + files: releaseFiles, + releaseNotes: 'oh look some release notes cool', + downloadProgress: 100, + }) + .then(() => + expect(progressCallback).toHaveBeenCalledWith({ + version: '1.2.3', + files: releaseFiles, + releaseNotes: 'oh look some release notes cool', + downloadProgress: 100, + }) + ) + }) + it('says there is an update and forwards progress if an update download is needed', () => { + const releaseUrls = { + system: 'http://opentrons.com/system.zip', + fullImage: 'http://opentrons.com/fullImage.zip', + version: 'http://opentrons.com/version.json', + releaseNotes: 'http://opentrons.com/releaseNotes.md', + } + const releaseFiles = { + system: '/some/random/directory/cached-release-1.2.3/ot3-system.zip', + releaseNotes: + '/some/random/directory/cached-release-1.2.3/releaseNotes.md', + } + const releaseData = { + ...releaseFiles, + releaseNotesContent: 'oh look some release notes sweet', + } + when(getOrDownloadManifest) + .calledWith( + 'http://opentrons.com/releases.json', + '/some/random/directory', + expect.any(AbortController) + ) + .thenResolve({ + production: { + '1.2.3': releaseUrls, + }, + }) + + when(cleanUpAndGetOrDownloadReleaseFiles) + .calledWith( + releaseUrls, + '/some/random/directory/versions', + '1.2.3', + expect.any(Function), + expect.any(Object) + ) + .thenDo( + ( + _releaseUrls, + _cacheDir, + _version, + progressCallback, + _abortController + ) => + new Promise(resolve => { + progressCallback({ size: 100, downloaded: 0 }) + resolve() + }) + .then( + () => + new Promise(resolve => { + progressCallback({ size: 100, downloaded: 50 }) + resolve() + }) + ) + .then( + () => + new Promise(resolve => { + progressCallback({ size: 100, downloaded: 100 }) + resolve(releaseData) + }) + ) + ) + + const progressCallback = vi.fn() + const provider = getProvider({ + manifestUrl: 'http://opentrons.com/releases.json', + channel: 'release', + updateCacheDirectory: '/some/random/directory', + currentVersion: '1.0.0', + }) + expect(provider.getUpdateDetails()).toEqual({ + version: null, + files: null, + releaseNotes: null, + downloadProgress: 0, + }) + return expect(provider.refreshUpdateCache(progressCallback)) + .resolves.toEqual({ + version: '1.2.3', + files: releaseFiles, + releaseNotes: 'oh look some release notes sweet', + downloadProgress: 100, + }) + .then(() => { + expect(progressCallback).toHaveBeenCalledWith({ + version: '1.2.3', + files: null, + releaseNotes: null, + downloadProgress: 0, + }) + expect(progressCallback).toHaveBeenCalledWith({ + version: '1.2.3', + files: null, + releaseNotes: null, + downloadProgress: 50, + }) + expect(progressCallback).toHaveBeenCalledWith({ + version: '1.2.3', + files: null, + releaseNotes: null, + downloadProgress: 100, + }) + expect(progressCallback).toHaveBeenCalledWith({ + version: '1.2.3', + files: releaseFiles, + releaseNotes: 'oh look some release notes sweet', + downloadProgress: 100, + }) + expect(provider.getUpdateDetails()).toEqual({ + version: '1.2.3', + files: releaseFiles, + releaseNotes: 'oh look some release notes sweet', + downloadProgress: 100, + }) + }) + }) +}) + +describe('provider.refreshUpdateCache locking', () => { + afterEach(() => { + vi.resetAllMocks() + }) + it('will not start a refresh when locked', () => { + const provider = getProvider({ + manifestUrl: 'http://opentrons.com/releases.json', + channel: 'release', + updateCacheDirectory: '/some/random/directory', + currentVersion: '1.0.0', + }) + provider.lockUpdateCache() + return expect(provider.refreshUpdateCache(vi.fn())).rejects.toThrow() + }) + it('will start a refresh when locked then unlocked', () => { + const provider = getProvider({ + manifestUrl: 'http://opentrons.com/releases.json', + channel: 'release', + updateCacheDirectory: '/some/random/directory', + currentVersion: '1.2.3', + }) + when(getOrDownloadManifest) + .calledWith( + 'http://opentrons.com/releases.json', + '/some/random/directory', + expect.any(AbortController) + ) + .thenResolve({ + production: { + '1.2.3': { + system: 'http://opentrons.com/system.zip', + fullImage: 'http://opentrons.com/fullImage.zip', + version: 'http://opentrons.com/version.json', + releaseNotes: 'http://opentrons.com/releaseNotes.md', + }, + }, + }) + provider.lockUpdateCache() + provider.unlockUpdateCache() + return expect(provider.refreshUpdateCache(vi.fn())).resolves.toEqual({ + version: null, + files: null, + releaseNotes: null, + downloadProgress: 0, + }) + }) + it('will abort when locked in the manifest phase and return the previous update', () => { + const provider = getProvider({ + manifestUrl: 'http://opentrons.com/releases.json', + channel: 'release', + updateCacheDirectory: '/some/random/directory', + currentVersion: '1.0.0', + }) + const releaseUrls = { + system: 'http://opentrons.com/system.zip', + fullImage: 'http://opentrons.com/fullImage.zip', + version: 'http://opentrons.com/version.json', + releaseNotes: 'http://opentrons.com/releaseNotes.md', + } + when(getOrDownloadManifest) + .calledWith( + 'http://opentrons.com/releases.json', + '/some/random/directory', + expect.any(AbortController) + ) + .thenResolve({ + production: { + '1.2.3': releaseUrls, + }, + }) + const releaseFiles = { + system: '/some/random/directory/cached-release-1.2.3/ot3-system.zip', + releaseNotes: + '/some/random/directory/cached-release-1.2.3/releaseNotes.md', + } + const releaseData = { ...releaseFiles, releaseNotesContent: 'oh hello' } + when(cleanUpAndGetOrDownloadReleaseFiles) + .calledWith( + releaseUrls, + '/some/random/directory/versions', + '1.2.3', + expect.any(Function), + expect.any(Object) + ) + .thenResolve(releaseData) + + return expect(provider.refreshUpdateCache(vi.fn())) + .resolves.toEqual({ + version: '1.2.3', + files: releaseFiles, + releaseNotes: 'oh hello', + downloadProgress: 100, + }) + .then(() => { + when(getOrDownloadManifest) + .calledWith( + 'http://opentrons.com/releases.json', + '/some/random/directory', + expect.any(AbortController) + ) + .thenDo( + (_manifestUrl, _cacheDirectory, abortController) => + new Promise((resolve, reject) => { + abortController.signal.addEventListener( + 'abort', + () => { + reject(new LocalAbortError(abortController.signal.reason)) + }, + { once: true } + ) + provider.lockUpdateCache() + }) + ) + const progress = vi.fn() + return expect(provider.refreshUpdateCache(progress)) + .rejects.toThrow() + .then(() => + expect(progress).toHaveBeenCalledWith({ + version: '1.2.3', + files: releaseFiles, + releaseNotes: 'oh hello', + downloadProgress: 100, + }) + ) + }) + .then(() => + expect(provider.getUpdateDetails()).toEqual({ + version: '1.2.3', + files: releaseFiles, + releaseNotes: 'oh hello', + downloadProgress: 100, + }) + ) + }) + it('will abort when locked between manifest and download phases and return the previous update', () => { + const provider = getProvider({ + manifestUrl: 'http://opentrons.com/releases.json', + channel: 'release', + updateCacheDirectory: '/some/random/directory', + currentVersion: '1.0.0', + }) + const releaseUrls = { + system: 'http://opentrons.com/system.zip', + fullImage: 'http://opentrons.com/fullImage.zip', + version: 'http://opentrons.com/version.json', + releaseNotes: 'http://opentrons.com/releaseNotes.md', + } + when(getOrDownloadManifest) + .calledWith( + 'http://opentrons.com/releases.json', + '/some/random/directory', + expect.any(AbortController) + ) + .thenResolve({ + production: { + '1.2.3': releaseUrls, + }, + }) + const releaseFiles = { + system: '/some/random/directory/cached-release-1.2.3/ot3-system.zip', + releaseNotes: + '/some/random/directory/cached-release-1.2.3/releaseNotes.md', + } + const releaseData = { ...releaseFiles, releaseNotesContent: 'hi' } + when(cleanUpAndGetOrDownloadReleaseFiles) + .calledWith( + releaseUrls, + '/some/random/directory/versions', + '1.2.3', + expect.any(Function), + expect.any(Object) + ) + .thenResolve(releaseData) + + return expect(provider.refreshUpdateCache(vi.fn())) + .resolves.toEqual({ + version: '1.2.3', + files: releaseFiles, + releaseNotes: 'hi', + downloadProgress: 100, + }) + .then(() => { + when(getOrDownloadManifest) + .calledWith( + expect.any(String), + expect.any(String), + expect.any(AbortController) + ) + .thenDo( + () => + new Promise(resolve => { + provider.lockUpdateCache() + resolve({ production: { '1.2.3': releaseUrls } }) + }) + ) + const progress = vi.fn() + return expect(provider.refreshUpdateCache(progress)) + .rejects.toThrow() + .then(() => + expect(progress).toHaveBeenCalledWith({ + version: '1.2.3', + files: releaseFiles, + releaseNotes: 'hi', + downloadProgress: 100, + }) + ) + }) + .then(() => + expect(provider.getUpdateDetails()).toEqual({ + version: '1.2.3', + files: releaseFiles, + releaseNotes: 'hi', + downloadProgress: 100, + }) + ) + }) + it('will abort when locked in the file download phase and return the previous update', () => { + const provider = getProvider({ + manifestUrl: 'http://opentrons.com/releases.json', + channel: 'release', + updateCacheDirectory: '/some/random/directory', + currentVersion: '1.0.0', + }) + const releaseUrls = { + system: 'http://opentrons.com/system.zip', + fullImage: 'http://opentrons.com/fullImage.zip', + version: 'http://opentrons.com/version.json', + releaseNotes: 'http://opentrons.com/releaseNotes.md', + } + when(getOrDownloadManifest) + .calledWith( + 'http://opentrons.com/releases.json', + '/some/random/directory', + expect.any(AbortController) + ) + .thenResolve({ + production: { + '1.2.3': releaseUrls, + }, + }) + const releaseFiles = { + system: '/some/random/directory/cached-release-1.2.3/ot3-system.zip', + releaseNotes: + '/some/random/directory/cached-release-1.2.3/releaseNotes.md', + } + const releaseData = { + ...releaseFiles, + releaseNotesContent: 'content', + } + when(cleanUpAndGetOrDownloadReleaseFiles) + .calledWith( + releaseUrls, + '/some/random/directory/versions', + '1.2.3', + expect.any(Function), + expect.any(Object) + ) + .thenResolve(releaseData) + + return expect(provider.refreshUpdateCache(vi.fn())) + .resolves.toEqual({ + version: '1.2.3', + files: releaseFiles, + releaseNotes: 'content', + downloadProgress: 100, + }) + .then(() => { + when(getOrDownloadManifest) + .calledWith( + 'http://opentrons.com/releases.json', + '/some/random/directory', + expect.any(AbortController) + ) + .thenResolve({ + production: { + '1.2.3': releaseUrls, + }, + }) + when(cleanUpAndGetOrDownloadReleaseFiles) + .calledWith( + expect.any(Object), + expect.any(String), + expect.any(String), + expect.any(Function), + expect.any(AbortController) + ) + .thenDo( + ( + _releaseUrls, + _cacheDirectory, + _version, + _progress, + abortController + ) => + new Promise((resolve, reject) => { + abortController.signal.addEventListener( + 'abort', + () => { + reject(new LocalAbortError(abortController.signal.reason)) + }, + { once: true } + ) + provider.lockUpdateCache() + }) + ) + const progress = vi.fn() + return expect(provider.refreshUpdateCache(progress)) + .rejects.toThrow() + .then(() => + expect(progress).toHaveBeenCalledWith({ + version: '1.2.3', + files: releaseFiles, + releaseNotes: 'content', + downloadProgress: 100, + }) + ) + }) + .then(() => { + expect(provider.getUpdateDetails()).toEqual({ + version: '1.2.3', + files: releaseFiles, + releaseNotes: 'content', + downloadProgress: 100, + }) + }) + }) + it('will abort when locked in the last-chance phase and return the previous update', () => { + const provider = getProvider({ + manifestUrl: 'http://opentrons.com/releases.json', + channel: 'release', + updateCacheDirectory: '/some/random/directory', + currentVersion: '1.0.0', + }) + const releaseUrls = { + system: 'http://opentrons.com/system.zip', + fullImage: 'http://opentrons.com/fullImage.zip', + version: 'http://opentrons.com/version.json', + releaseNotes: 'http://opentrons.com/releaseNotes.md', + } + when(getOrDownloadManifest) + .calledWith( + 'http://opentrons.com/releases.json', + '/some/random/directory', + expect.any(AbortController) + ) + .thenResolve({ + production: { + '1.2.3': releaseUrls, + }, + }) + const releaseFiles = { + system: '/some/random/directory/cached-release-1.2.3/ot3-system.zip', + releaseNotes: + '/some/random/directory/cached-release-1.2.3/releaseNotes.md', + } + const releaseData = { + ...releaseFiles, + releaseNotesContent: 'there is some', + } + when(cleanUpAndGetOrDownloadReleaseFiles) + .calledWith( + releaseUrls, + '/some/random/directory/versions', + '1.2.3', + expect.any(Function), + expect.any(Object) + ) + .thenResolve(releaseData) + + return expect(provider.refreshUpdateCache(vi.fn())) + .resolves.toEqual({ + version: '1.2.3', + files: releaseFiles, + releaseNotes: 'there is some', + downloadProgress: 100, + }) + .then(() => { + when(getOrDownloadManifest) + .calledWith( + 'http://opentrons.com/releases.json', + '/some/random/directory', + expect.any(AbortController) + ) + .thenResolve({ + production: { + '1.2.3': releaseUrls, + }, + }) + when(cleanUpAndGetOrDownloadReleaseFiles) + .calledWith( + expect.any(Object), + expect.any(String), + expect.any(String), + expect.any(Function), + expect.any(AbortController) + ) + .thenDo( + ( + _releaseUrls, + _cacheDirectory, + _version, + _progress, + _abortController + ) => + new Promise(resolve => { + provider.lockUpdateCache() + resolve(releaseData) + }) + ) + const progress = vi.fn() + return expect(provider.refreshUpdateCache(progress)) + .rejects.toThrow() + .then(() => + expect(progress).toHaveBeenCalledWith({ + version: '1.2.3', + files: releaseFiles, + releaseNotes: 'there is some', + downloadProgress: 100, + }) + ) + }) + .then(() => + expect(provider.getUpdateDetails()).toEqual({ + version: '1.2.3', + files: releaseFiles, + releaseNotes: 'there is some', + downloadProgress: 100, + }) + ) + }) + it('will not run two checks at once', () => { + when(getOrDownloadManifest) + .calledWith( + 'http://opentrons.com/releases.json', + '/some/random/directory', + expect.any(AbortController) + ) + .thenResolve({ + production: { + '1.2.3': { + system: 'http://opentrons.com/system.zip', + fullImage: 'http://opentrons.com/fullImage.zip', + version: 'http://opentrons.com/version.json', + releaseNotes: 'http://opentrons.com/releaseNotes.md', + }, + }, + }) + const progressCallback = vi.fn() + const provider = getProvider({ + manifestUrl: 'http://opentrons.com/releases.json', + channel: 'release', + updateCacheDirectory: '/some/random/directory', + currentVersion: '1.2.3', + }) + const first = provider.refreshUpdateCache(progressCallback) + const second = provider.refreshUpdateCache(progressCallback) + return Promise.all([ + expect(first).resolves.toEqual({ + version: null, + files: null, + releaseNotes: null, + downloadProgress: 0, + }), + expect(second).rejects.toThrow(), + ]).then(() => expect(getOrDownloadManifest).toHaveBeenCalledOnce()) + }) + it('will run a second check after the first completes', () => { + when(getOrDownloadManifest) + .calledWith( + 'http://opentrons.com/releases.json', + '/some/random/directory', + expect.any(AbortController) + ) + .thenResolve({ + production: { + '1.2.3': { + system: 'http://opentrons.com/system.zip', + fullImage: 'http://opentrons.com/fullImage.zip', + version: 'http://opentrons.com/version.json', + releaseNotes: 'http://opentrons.com/releaseNotes.md', + }, + }, + }) + const progressCallback = vi.fn() + const provider = getProvider({ + manifestUrl: 'http://opentrons.com/releases.json', + channel: 'release', + updateCacheDirectory: '/some/random/directory', + currentVersion: '1.2.3', + }) + return expect(provider.refreshUpdateCache(progressCallback)) + .resolves.toEqual({ + version: null, + files: null, + releaseNotes: null, + downloadProgress: 0, + }) + .then(() => + expect(provider.refreshUpdateCache(progressCallback)).resolves.toEqual({ + version: null, + files: null, + releaseNotes: null, + downloadProgress: 0, + }) + ) + }) +}) diff --git a/app-shell-odd/src/system-update/from-web/__tests__/release-files.test.ts b/app-shell-odd/src/system-update/from-web/__tests__/release-files.test.ts new file mode 100644 index 00000000000..34df59eaf49 --- /dev/null +++ b/app-shell-odd/src/system-update/from-web/__tests__/release-files.test.ts @@ -0,0 +1,514 @@ +// TODO(mc, 2020-06-11): test all release-files functions +import { vi, describe, it, expect, afterEach } from 'vitest' +import { when } from 'vitest-when' +import path from 'path' +import { promises as fs } from 'fs' + +import { fetchToFile as httpFetchToFile } from '../../../http' +import { + ensureCleanReleaseCacheForVersion, + getReleaseFiles, + downloadReleaseFiles, + getOrDownloadReleaseFiles, +} from '../release-files' + +import { directoryWithCleanup } from '../../utils' +import type { ReleaseSetUrls } from '../../types' + +vi.mock('../../../http') +vi.mock('../../../log') + +const fetchToFile = vi.mocked(httpFetchToFile) + +describe('ensureCleanReleaseCacheForVersion', () => { + it('should create the appropriate directory tree if it does not exist', () => + directoryWithCleanup(directory => + ensureCleanReleaseCacheForVersion( + path.join(directory, 'somerandomdirectory', 'someotherrandomdirectory'), + '1.2.3' + ) + .then(cacheDirectory => { + expect(cacheDirectory).toEqual( + path.join( + directory, + 'somerandomdirectory', + 'someotherrandomdirectory', + 'cached-release-1.2.3' + ) + ) + return fs.stat(cacheDirectory) + }) + .then(stats => expect(stats.isDirectory()).toBeTruthy()) + )) + it('should create the appropriate directory if the base directory entry is occupied by a file', () => + directoryWithCleanup(directory => + fs + .writeFile( + path.join(directory, 'somerandomdirectory'), + 'somerandomdata' + ) + .then(() => + ensureCleanReleaseCacheForVersion( + path.join(directory, 'somerandomdirectory'), + '1.2.3' + ) + ) + .then(cacheDirectory => { + expect(cacheDirectory).toEqual( + path.join(directory, 'somerandomdirectory', 'cached-release-1.2.3') + ) + return fs.stat(cacheDirectory) + }) + .then(stats => expect(stats.isDirectory()).toBeTruthy()) + )) + it('should create the appropriate directory if the version directory entry is occupied by a file', () => + directoryWithCleanup(directory => + fs + .mkdir(path.join(directory, 'somerandomdirectory')) + .then(() => + fs.writeFile( + path.join(directory, 'somerandomdirectory', 'cached-release-1.2.3'), + 'somerandomdata' + ) + ) + .then(() => + ensureCleanReleaseCacheForVersion( + path.join(directory, 'somerandomdirectory'), + '1.2.3' + ) + ) + .then(baseDirectory => { + expect(baseDirectory).toEqual( + path.join(directory, 'somerandomdirectory', 'cached-release-1.2.3') + ) + return fs.stat(baseDirectory) + }) + .then(stats => expect(stats.isDirectory()).toBeTruthy()) + )) + it('should remove caches for other versions from the cache directory', () => + directoryWithCleanup(directory => + fs + .mkdir(path.join(directory, 'cached-release-0.1.2')) + .then(() => fs.mkdir(path.join(directory, 'cached-release-4.5.6'))) + .then(() => + fs.writeFile( + path.join(directory, 'cached-release-4.5.6', 'test.zip'), + 'asfjohasda' + ) + ) + .then(() => ensureCleanReleaseCacheForVersion(directory, '1.2.3')) + .then(cacheDirectory => { + expect(cacheDirectory).toEqual( + path.join(directory, 'cached-release-1.2.3') + ) + return fs.readdir(directory) + }) + .then(contents => expect(contents).toEqual(['cached-release-1.2.3'])) + )) + it('should leave already-existing correct version cache directories untouched', () => + directoryWithCleanup(directory => + fs + .mkdir(path.join(directory, 'cached-release-1.2.3')) + .then(() => + fs.writeFile( + path.join(directory, 'cached-release-1.2.3', 'system.zip'), + '123123' + ) + ) + .then(() => ensureCleanReleaseCacheForVersion(directory, '1.2.3')) + .then(cacheDirectory => fs.readdir(cacheDirectory)) + .then(contents => { + expect(contents).toEqual(['system.zip']) + return fs.readFile( + path.join(directory, 'cached-release-1.2.3', 'system.zip'), + { encoding: 'utf-8' } + ) + }) + .then(contents => expect(contents).toEqual('123123')) + )) +}) + +describe('getReleaseFiles', () => { + it('should fail if no release files are cached', () => + directoryWithCleanup(directory => + expect( + getReleaseFiles( + { + fullImage: 'http://opentrons.com/fullImage.zip', + system: 'http://opentrons.com/ot3-system.zip', + version: 'http//opentrons.com/VERSION.json', + releaseNotes: 'http://opentrons.com/releaseNotes.md', + }, + directory + ) + ).rejects.toThrow() + )) + it('should fail if system is not present but all others are', () => + directoryWithCleanup(directory => + fs + .writeFile(path.join(directory, 'fullImage.zip'), 'aslkdjasd') + .then(() => fs.writeFile(path.join(directory, 'VERSION.json'), 'asdas')) + .then(() => + fs.writeFile(path.join(directory, 'releaseNotes.md'), 'asdalsda') + ) + .then(() => + expect( + getReleaseFiles( + { + fullImage: 'http://opentrons.com/fullImage.zip', + system: 'http://opentrons.com/ot3-system.zip', + version: 'http//opentrons.com/VERSION.json', + releaseNotes: 'http://opentrons.com/releaseNotes.md', + }, + directory + ) + ).rejects.toThrow() + ) + )) + it('should return available files if system.zip is one of them', () => + directoryWithCleanup(directory => + fs + .writeFile(path.join(directory, 'ot3-system.zip'), 'asdjlhasd') + .then(() => + expect( + getReleaseFiles( + { + fullImage: 'http://opentrons.com/fullImage.zip', + system: 'http://opentrons.com/ot3-system.zip', + version: 'http//opentrons.com/VERSION.json', + releaseNotes: 'http://opentrons.com/releaseNotes.md', + }, + directory + ) + ).resolves.toEqual({ + system: path.join(directory, 'ot3-system.zip'), + releaseNotes: null, + releaseNotesContent: null, + }) + ) + )) + it('should find release notes if available', () => + directoryWithCleanup(directory => + fs + .writeFile(path.join(directory, 'ot3-system.zip'), 'asdjlhasd') + .then(() => + fs.writeFile(path.join(directory, 'releaseNotes.md'), 'asdasda') + ) + .then(() => + expect( + getReleaseFiles( + { + fullImage: 'http://opentrons.com/fullImage.zip', + system: 'http://opentrons.com/ot3-system.zip', + version: 'http//opentrons.com/VERSION.json', + releaseNotes: 'http://opentrons.com/releaseNotes.md', + }, + directory + ) + ).resolves.toEqual({ + system: path.join(directory, 'ot3-system.zip'), + releaseNotes: path.join(directory, 'releaseNotes.md'), + releaseNotesContent: 'asdasda', + }) + ) + )) +}) + +describe('downloadReleaseFiles', () => { + afterEach(() => { + vi.resetAllMocks() + }) + it('should try and fetch both system zip and release notes', () => + directoryWithCleanup(directory => { + let tempSystemPath = '' + when(fetchToFile) + .calledWith( + 'http://opentrons.com/ot3-system.zip', + expect.any(String), + expect.any(Object) + ) + .thenDo((_url, dest, _opts) => { + tempSystemPath = dest + return fs + .writeFile(dest, 'this is the contents of the system.zip') + .then(() => dest) + }) + when(fetchToFile) + .calledWith( + 'http://opentrons.com/releaseNotes.md', + expect.any(String), + expect.any(Object) + ) + .thenDo((_url, dest) => { + return fs + .writeFile(dest, 'this is the contents of the release notes') + .then(() => dest) + }) + const progress = vi.fn() + return downloadReleaseFiles( + { + system: 'http://opentrons.com/ot3-system.zip', + releaseNotes: 'http://opentrons.com/releaseNotes.md', + } as ReleaseSetUrls, + directory, + progress, + new AbortController() + ).then(files => { + expect(files).toEqual({ + system: path.join(directory, 'ot3-system.zip'), + releaseNotes: path.join(directory, 'releaseNotes.md'), + releaseNotesContent: 'this is the contents of the release notes', + }) + return Promise.all([ + fs + .readFile(files.system, { encoding: 'utf-8' }) + .then(contents => + expect(contents).toEqual('this is the contents of the system.zip') + ), + fs + .readFile(files.releaseNotes as string, { encoding: 'utf-8' }) + .then(contents => + expect(contents).toEqual( + 'this is the contents of the release notes' + ) + ), + expect(fs.stat(path.dirname(tempSystemPath))).rejects.toThrow(), + ]) + }) + })) + it('should fetch only system zip if only system is available', () => + directoryWithCleanup(directory => { + when(fetchToFile) + .calledWith( + 'http://opentrons.com/ot3-system.zip', + expect.any(String), + expect.any(Object) + ) + .thenDo((_url, dest, _opts) => { + return fs + .writeFile(dest, 'this is the contents of the system.zip') + .then(() => dest) + }) + const progress = vi.fn() + return downloadReleaseFiles( + { + system: 'http://opentrons.com/ot3-system.zip', + } as ReleaseSetUrls, + directory, + progress, + new AbortController() + ).then(files => { + expect(files).toEqual({ + system: path.join(directory, 'ot3-system.zip'), + releaseNotes: null, + releaseNotesContent: null, + }) + return fs + .readFile(files.system, { encoding: 'utf-8' }) + .then(contents => + expect(contents).toEqual('this is the contents of the system.zip') + ) + }) + })) + it('should tolerate failing to fetch release notes', () => + directoryWithCleanup(directory => { + when(fetchToFile) + .calledWith( + 'http://opentrons.com/ot3-system.zip', + expect.any(String), + expect.any(Object) + ) + .thenDo((_url, dest, _opts) => { + return fs + .writeFile(dest, 'this is the contents of the system.zip') + .then(() => dest) + }) + when(fetchToFile) + .calledWith( + 'http://opentrons.com/releaseNotes.md', + expect.any(String), + expect.any(Object) + ) + .thenReject(new Error('oh no!')) + const progress = vi.fn() + return downloadReleaseFiles( + { + system: 'http://opentrons.com/ot3-system.zip', + releaseNotes: 'http://opentrons.com/releaseNotes.md', + } as ReleaseSetUrls, + directory, + progress, + new AbortController() + ).then(files => { + expect(files).toEqual({ + system: path.join(directory, 'ot3-system.zip'), + releaseNotes: null, + releaseNotesContent: null, + }) + return fs + .readFile(files.system, { encoding: 'utf-8' }) + .then(contents => + expect(contents).toEqual('this is the contents of the system.zip') + ) + }) + })) + it('should fail if it cannot fetch system zip', () => + directoryWithCleanup(directory => { + let tempSystemPath = '' + when(fetchToFile) + .calledWith( + 'http://opentrons.com/ot3-system.zip', + expect.any(String), + expect.any(Object) + ) + .thenReject(new Error('oh no')) + when(fetchToFile) + .calledWith( + 'http://opentrons.com/releaseNotes.md', + expect.any(String), + expect.any(Object) + ) + .thenDo((_url, dest) => { + tempSystemPath = dest + return fs + .writeFile(dest, 'this is the contents of the release notes') + .then(() => dest) + }) + const progress = vi.fn() + return expect( + downloadReleaseFiles( + { + system: 'http://opentrons.com/ot3-system.zip', + releaseNotes: 'http://opentrons.com/releaseNotes.md', + } as ReleaseSetUrls, + directory, + progress, + new AbortController() + ) + ) + .rejects.toThrow() + .then(() => + expect(fs.stat(path.dirname(tempSystemPath))).rejects.toThrow() + ) + })) + it('should allow the http requests to be aborted', () => + directoryWithCleanup(directory => { + const aborter = new AbortController() + const progressCallback = vi.fn() + when(fetchToFile) + .calledWith('http://opentrons.com/ot3-system.zip', expect.any(String), { + onProgress: progressCallback, + signal: aborter.signal, + }) + .thenDo( + (_url, dest, options) => + new Promise((resolve, reject) => { + const listener = () => { + reject(options.signal.reason) + } + options.signal.addEventListener('abort', listener, { once: true }) + aborter.abort('oh no!') + return fs + .writeFile(dest, 'this is the contents of the system.zip') + .then(() => dest) + }) + ) + return expect( + downloadReleaseFiles( + { + system: 'http://opentrons.com/ot3-system.zip', + } as ReleaseSetUrls, + directory, + progressCallback, + aborter + ) + ).rejects.toThrow() + })) +}) + +describe('getOrDownloadReleaseFiles', () => { + it('should not download release files if they are cached', () => + directoryWithCleanup(directory => + fs + .writeFile(path.join(directory, 'ot3-system.zip'), 'asdjlhasd') + .then(() => + expect( + getOrDownloadReleaseFiles( + { + system: 'http://opentrons.com/ot3-system.zip', + releaseNotes: 'http://opentrons.com/releaseNotes.md', + } as ReleaseSetUrls, + directory, + vi.fn(), + new AbortController() + ) + ) + .resolves.toEqual({ + system: path.join(directory, 'ot3-system.zip'), + releaseNotes: null, + releaseNotesContent: null, + }) + .then(() => expect(fetchToFile).not.toHaveBeenCalled()) + ) + )) + it('should download release files if they are not cached', () => + directoryWithCleanup(directory => { + when(fetchToFile) + .calledWith( + 'http://opentrons.com/ot3-system.zip', + expect.any(String), + expect.any(Object) + ) + .thenDo((_url, dest, _opts) => { + return fs + .writeFile(dest, 'this is the contents of the system.zip') + .then(() => dest) + }) + + return expect( + getOrDownloadReleaseFiles( + { + system: 'http://opentrons.com/ot3-system.zip', + } as ReleaseSetUrls, + directory, + vi.fn(), + new AbortController() + ) + ) + .resolves.toEqual({ + system: path.join(directory, 'ot3-system.zip'), + releaseNotes: null, + releaseNotesContent: null, + }) + .then(() => + fs + .readFile(path.join(directory, 'ot3-system.zip'), { + encoding: 'utf-8', + }) + .then(contents => + expect(contents).toEqual('this is the contents of the system.zip') + ) + ) + })) + it('should fail if the file is not cached and can not be downloaded', () => + directoryWithCleanup(directory => { + when(fetchToFile) + .calledWith( + 'http://opentrons.com/ot3-system.zip', + expect.any(String), + expect.any(Object) + ) + .thenReject(new Error('oh no')) + + return expect( + getOrDownloadReleaseFiles( + { + system: 'http://opentrons.com/ot3-system.zip', + } as ReleaseSetUrls, + directory, + vi.fn(), + new AbortController() + ) + ).rejects.toThrow() + })) +}) diff --git a/app-shell-odd/src/system-update/from-web/__tests__/release-manifest.test.ts b/app-shell-odd/src/system-update/from-web/__tests__/release-manifest.test.ts new file mode 100644 index 00000000000..8062cd6b28b --- /dev/null +++ b/app-shell-odd/src/system-update/from-web/__tests__/release-manifest.test.ts @@ -0,0 +1,185 @@ +import { describe, it, vi, expect } from 'vitest' +import { when } from 'vitest-when' +import path from 'path' +import { readdir, writeFile, mkdir, readFile } from 'fs/promises' +import { fetchJson as _fetchJson } from '../../../http' +import { ensureCacheDir, getOrDownloadManifest } from '../release-manifest' +import { directoryWithCleanup } from '../../utils' + +vi.mock('../../../http') +// note: this doesn't look like it's needed but it is because http uses log +vi.mock('../../../log') +const fetchJson = vi.mocked(_fetchJson) + +const MOCK_MANIFEST = { + production: { + '1.2.3': { + fullImage: 'https://opentrons.com/no', + system: 'https://opentrons.com/no2', + version: 'https://opentrons.com/no3', + releaseNotes: 'https://opentrons.com/no4', + }, + }, +} + +describe('ensureCacheDirectory', () => { + it('should create the cache directory if it or its parents do not exist', () => + directoryWithCleanup(directory => + ensureCacheDir( + path.join(directory as string, 'somerandomname', 'someotherrandomname') + ) + .then(ensuredDirectory => { + expect(ensuredDirectory).toEqual( + path.join(directory, 'somerandomname', 'someotherrandomname') + ) + return readdir(path.join(directory, 'somerandomname'), { + withFileTypes: true, + }) + }) + .then(contents => { + expect(contents).toHaveLength(1) + expect(contents[0].isDirectory()).toBeTruthy() + expect(contents[0].name).toEqual('someotherrandomname') + return readdir(path.join(contents[0].path, contents[0].name)) + }) + .then(contents => { + expect(contents).toHaveLength(0) + }) + )) + it('should delete and recreate the cache directory if it is a file', () => + directoryWithCleanup(directory => + writeFile(path.join(directory, 'somerandomname'), 'alsdasda') + .then(() => ensureCacheDir(path.join(directory, 'somerandomname'))) + .then(ensuredDirectory => { + expect(ensuredDirectory).toEqual( + path.join(directory, 'somerandomname') + ) + return readdir(directory, { withFileTypes: true }) + }) + .then(contents => { + expect(contents).toHaveLength(1) + expect(contents[0].isDirectory()).toBeTruthy() + expect(contents[0].name).toEqual('somerandomname') + return readdir(path.join(contents[0].path, contents[0].name)) + }) + .then(contents => { + expect(contents).toHaveLength(0) + }) + )) + + it('should remove a non-file with the same name as the manifest file', () => + directoryWithCleanup(directory => + mkdir(path.join(directory, 'somerandomname', 'manifest.json'), { + recursive: true, + }) + .then(() => + writeFile( + path.join(directory, 'somerandomname', 'testfile'), + 'testdata' + ) + ) + .then(() => ensureCacheDir(path.join(directory, 'somerandomname'))) + .then(ensuredDirectory => readdir(ensuredDirectory)) + .then(contents => { + expect(contents).not.toContain('manifest.json') + return readFile(path.join(directory, 'somerandomname', 'testfile'), { + encoding: 'utf-8', + }) + }) + .then(contents => expect(contents).toEqual('testdata')) + )) + + it('should preserve extra contents of the directory if the directory exists', () => + directoryWithCleanup(directory => + mkdir(path.join(directory, 'somerandomname'), { recursive: true }) + .then(() => + writeFile( + path.join(directory, 'somerandomname', 'somerandomfile'), + 'somerandomdata' + ) + ) + .then(() => ensureCacheDir(path.join(directory, 'somerandomname'))) + .then(ensuredDirectory => { + expect(ensuredDirectory).toEqual( + path.join(directory, 'somerandomname') + ) + return readFile( + path.join(directory, 'somerandomname', 'somerandomfile'), + { encoding: 'utf-8' } + ) + }) + .then(contents => { + expect(contents).toEqual('somerandomdata') + return readdir(directory) + }) + .then(contents => expect(contents).toEqual(['somerandomname'])) + )) +}) + +describe('getOrDownloadManifest', () => { + const localManifest = { + production: { + '4.5.6': { + fullImage: 'https://opentrons.com/no', + system: 'https://opentrons.com/no2', + version: 'https://opentrons.com/no3', + releaseNotes: 'https://opentrons.com/no4', + }, + }, + } + it('should download a new manifest if possible', () => + directoryWithCleanup(directory => + writeFile( + path.join(directory, 'manifest.json'), + JSON.stringify(localManifest) + ) + .then(() => { + when(fetchJson) + .calledWith( + 'http://opentrons.com/releases.json', + expect.any(Object) + ) + .thenResolve(MOCK_MANIFEST) + return getOrDownloadManifest( + 'http://opentrons.com/releases.json', + directory, + new AbortController() + ) + }) + .then(manifest => expect(manifest).toEqual(MOCK_MANIFEST)) + )) + it('should use a cached manifest if the download fails', () => + directoryWithCleanup(directory => + writeFile( + path.join(directory, 'manifest.json'), + JSON.stringify(localManifest) + ) + .then(() => { + when(fetchJson) + .calledWith( + 'http://opentrons.com/releases.json', + expect.any(Object) + ) + .thenReject(new Error('oh no!')) + return getOrDownloadManifest( + 'http://opentrons.com/releases.json', + directory, + new AbortController() + ) + }) + .then(manifest => expect(manifest).toEqual(localManifest)) + )) + it('should reject if no manifest is available', () => + directoryWithCleanup(directory => { + when(fetchJson) + .calledWith('http://opentrons.com/releases.json', expect.any(Object)) + .thenReject(new Error('oh no!')) + return expect( + getOrDownloadManifest( + 'http://opentrons.com/releases.json', + directory, + new AbortController() + ) + ).rejects.toThrow() + })) +}) diff --git a/app-shell-odd/src/system-update/from-web/index.ts b/app-shell-odd/src/system-update/from-web/index.ts new file mode 100644 index 00000000000..0a9c34e3370 --- /dev/null +++ b/app-shell-odd/src/system-update/from-web/index.ts @@ -0,0 +1,2 @@ +export { getProvider } from './provider' +export type { WebUpdateSource } from './provider' diff --git a/app-shell-odd/src/system-update/from-web/latest-update.ts b/app-shell-odd/src/system-update/from-web/latest-update.ts new file mode 100644 index 00000000000..1a270c85ddd --- /dev/null +++ b/app-shell-odd/src/system-update/from-web/latest-update.ts @@ -0,0 +1,28 @@ +import semver from 'semver' + +const channelFinder = (version: string, channel: string): boolean => { + // return the latest alpha/beta if a user subscribes to alpha/beta updates + if (['alpha', 'beta'].includes(channel)) { + return version.includes(channel) + } else { + // otherwise get the latest stable version + return !version.includes('alpha') && !version.includes('beta') + } +} + +export const latestVersionForChannel = ( + availableVersions: string[], + channel: string +): string | null => + availableVersions + .filter(version => channelFinder(version, channel)) + .sort((a, b) => (semver.gt(a, b) ? 1 : -1)) + .pop() ?? null + +export const shouldUpdate = ( + currentVersion: string, + availableVersion: string | null +): string | null => + availableVersion != null && currentVersion !== availableVersion + ? availableVersion + : null diff --git a/app-shell-odd/src/system-update/from-web/provider.ts b/app-shell-odd/src/system-update/from-web/provider.ts new file mode 100644 index 00000000000..ca5c8da9fc9 --- /dev/null +++ b/app-shell-odd/src/system-update/from-web/provider.ts @@ -0,0 +1,209 @@ +import path from 'path' +import { rm } from 'fs/promises' + +import { createLogger } from '../../log' +import { LocalAbortError } from '../../http' + +import type { + UpdateProvider, + ResolvedUpdate, + UnresolvedUpdate, + ProgressCallback, + NoUpdate, +} from '../types' + +import { getOrDownloadManifest, getReleaseSet } from './release-manifest' +import { cleanUpAndGetOrDownloadReleaseFiles } from './release-files' +import { latestVersionForChannel, shouldUpdate } from './latest-update' + +import type { DownloadProgress } from '../../http' + +const log = createLogger('systemUpdate/from-web/provider') + +export interface WebUpdateSource { + manifestUrl: string + channel: string + updateCacheDirectory: string + currentVersion: string +} + +export function getProvider( + from: WebUpdateSource +): UpdateProvider { + let locked = false + let canceller = new AbortController() + const lockCache = (): void => { + locked = true + canceller.abort('cache locked') + canceller = new AbortController() + } + const versionCacheDir = path.join(from.updateCacheDirectory, 'versions') + const noUpdate = { + version: null, + files: null, + releaseNotes: null, + downloadProgress: 0, + } as const + let currentUpdate: UnresolvedUpdate = noUpdate + let currentCheck: Promise | null = null + const updater = async ( + progress: ProgressCallback + ): Promise => { + const myCanceller = canceller + // this needs to be an `as`-assertion on the value because we can only guarantee that + // currentUpdate is resolved by the function of the program: we know that this function, + // which is the only thing that can alter currentUpdate, will always end with a resolved update, + // and we know that this function will not be running twice at the same time. + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const previousUpdate = { + version: currentUpdate.version, + files: currentUpdate.files == null ? null : { ...currentUpdate.files }, + releaseNotes: currentUpdate.releaseNotes, + downloadProgress: currentUpdate.downloadProgress, + } as ResolvedUpdate + if (locked) { + throw new Error('cache locked') + } + const returnNoUpdate = (): NoUpdate => { + currentUpdate = noUpdate + progress(noUpdate) + return noUpdate + } + const manifest = await getOrDownloadManifest( + from.manifestUrl, + from.updateCacheDirectory, + myCanceller + ).catch((error: Error) => { + if (myCanceller.signal.aborted) { + log.info('aborted cache update because cache was locked') + currentUpdate = previousUpdate + progress(previousUpdate) + throw error + } + log.info( + `Failed to get or download update manifest: ${error.name}: ${error.message}` + ) + return null + }) + if (manifest == null) { + log.info(`no manifest found, returning`) + return returnNoUpdate() + } + const latestVersion = latestVersionForChannel( + Object.keys(manifest.production), + from.channel + ) + + const versionToUpdate = shouldUpdate(from.currentVersion, latestVersion) + if (versionToUpdate == null) { + log.debug(`no update found, returning`) + return returnNoUpdate() + } + const releaseUrls = getReleaseSet(manifest, versionToUpdate) + if (releaseUrls == null) { + log.debug(`no release urls found, returning`) + return returnNoUpdate() + } + log.info(`Finding version ${latestVersion}`) + const downloadingUpdate = { + version: latestVersion, + files: null, + releaseNotes: null, + downloadProgress: 0, + } as const + progress(downloadingUpdate) + currentUpdate = downloadingUpdate + + if (myCanceller.signal.aborted) { + log.info('aborted cache update because cache was locked') + currentUpdate = previousUpdate + progress(previousUpdate) + throw new LocalAbortError('cache locked') + } + const localFiles = await cleanUpAndGetOrDownloadReleaseFiles( + releaseUrls, + versionCacheDir, + versionToUpdate, + (downloadProgress: DownloadProgress): void => { + const downloadProgressPercent = + downloadProgress.size == null || downloadProgress.size === 0.0 + ? 0 + : (downloadProgress.downloaded / downloadProgress.size) * 100 + log.debug( + `Downloading update ${versionToUpdate}: ${downloadProgress.downloaded}/${downloadProgress.size}B (${downloadProgressPercent}%)` + ) + const update = { + version: versionToUpdate, + files: null, + releaseNotes: null, + downloadProgress: downloadProgressPercent, + } + currentUpdate = update + progress(update) + }, + myCanceller + ).catch((err: Error) => { + if (myCanceller.signal.aborted) { + currentUpdate = previousUpdate + progress(previousUpdate) + throw err + } else { + log.warn(`Failed to fetch update data: ${err.name}: ${err.message}`) + } + return null + }) + + if (localFiles == null) { + log.info( + `Download of ${versionToUpdate} failed, no release data is available` + ) + return returnNoUpdate() + } + if (myCanceller.signal.aborted) { + currentUpdate = previousUpdate + progress(previousUpdate) + throw new LocalAbortError('cache locked') + } + + const updateDetails = { + version: versionToUpdate, + files: { + system: localFiles.system, + releaseNotes: localFiles.releaseNotes, + }, + releaseNotes: localFiles.releaseNotesContent, + downloadProgress: 100, + } as const + currentUpdate = updateDetails + progress(updateDetails) + return updateDetails + } + return { + getUpdateDetails: () => currentUpdate, + refreshUpdateCache: (progress: ProgressCallback) => { + if (currentCheck != null) { + return new Promise((resolve, reject) => { + reject(new Error('Check already ongoing')) + }) + } else { + const updaterPromise = updater(progress) + currentCheck = updaterPromise + return updaterPromise.finally(() => { + currentCheck = null + }) + } + }, + + teardown: () => { + lockCache() + return rm(from.updateCacheDirectory, { recursive: true, force: true }) + }, + lockUpdateCache: lockCache, + unlockUpdateCache: () => { + locked = false + }, + name: () => + `WebUpdateProvider from ${from.manifestUrl} channel ${from.channel}`, + source: () => from, + } +} diff --git a/app-shell-odd/src/system-update/from-web/release-files.ts b/app-shell-odd/src/system-update/from-web/release-files.ts new file mode 100644 index 00000000000..a3c45cf5d42 --- /dev/null +++ b/app-shell-odd/src/system-update/from-web/release-files.ts @@ -0,0 +1,243 @@ +// functions for downloading and storing release files + +import path from 'path' +import tempy from 'tempy' +import { move, readdir, rm, mkdirp, readFile } from 'fs-extra' +import { fetchToFile } from '../../http' +import { createLogger } from '../../log' + +import type { DownloadProgress } from '../../http' +import type { ReleaseSetUrls, ReleaseSetFilepaths } from '../types' +import type { Dirent } from 'fs' + +const log = createLogger('systemUpdate/from-web/release-files') +const outPath = (dir: string, url: string): string => { + return path.join(dir, path.basename(url)) +} + +const RELEASE_DIRECTORY_PREFIX = 'cached-release-' + +export const directoryNameForRelease = (version: string): string => + `${RELEASE_DIRECTORY_PREFIX}${version}` + +export const directoryForRelease = ( + baseDirectory: string, + version: string +): string => path.join(baseDirectory, directoryNameForRelease(version)) + +async function ensureReleaseCache(baseDirectory: string): Promise { + try { + return await readdir(baseDirectory, { withFileTypes: true }) + } catch (error: any) { + console.log( + `Could not read download cache base directory: ${error.name}: ${error.message}: remaking` + ) + await rm(baseDirectory, { force: true, recursive: true }) + await mkdirp(baseDirectory) + return [] + } +} + +export const ensureCleanReleaseCacheForVersion = ( + baseDirectory: string, + version: string +): Promise => + ensureReleaseCache(baseDirectory) + .then(contents => + Promise.all( + contents.map(contained => + !contained.isDirectory() || + contained.name !== directoryNameForRelease(version) + ? rm(path.join(baseDirectory, contained.name), { + force: true, + recursive: true, + }) + : new Promise(resolve => { + resolve() + }) + ) + ) + ) + .then(() => mkdirp(directoryForRelease(baseDirectory, version))) + .then(() => directoryForRelease(baseDirectory, version)) + +export interface ReleaseSetData extends ReleaseSetFilepaths { + releaseNotesContent: string | null +} + +export const augmentWithReleaseNotesContent = ( + releaseFiles: ReleaseSetFilepaths +): Promise => + releaseFiles.releaseNotes == null + ? new Promise(resolve => { + resolve({ ...releaseFiles, releaseNotesContent: null }) + }) + : readReleaseNotes(releaseFiles.releaseNotes) + .then(releaseNotesContent => ({ ...releaseFiles, releaseNotesContent })) + .catch(err => { + log.error( + `Release notes should be present but cannot be read: ${err.name}: ${err.message}` + ) + return { ...releaseFiles, releaseNotesContent: null } + }) + +// checks `directory` for system update files matching the given `urls`, and +// downloads them if they can't be found +export function getReleaseFiles( + urls: ReleaseSetUrls, + directory: string +): Promise { + return readdir(directory).then((files: string[]) => { + log.info(`Files in system update download directory ${directory}: ${files}`) + const expected = { + system: path.basename(urls.system), + releaseNotes: + urls?.releaseNotes == null ? null : path.basename(urls.releaseNotes), + } + const foundFiles = files.reduce>( + ( + releaseSetFilePaths: Partial, + thisFile: string + ): Partial => { + if (thisFile === expected.system) { + return { ...releaseSetFilePaths, system: thisFile } + } + if ( + expected.releaseNotes != null && + thisFile === expected.releaseNotes + ) { + return { ...releaseSetFilePaths, releaseNotes: thisFile } + } + return releaseSetFilePaths + }, + {} + ) + if (foundFiles?.system != null) { + const files = { + system: outPath(directory, foundFiles.system), + releaseNotes: + foundFiles?.releaseNotes != null + ? outPath(directory, foundFiles.releaseNotes) + : null, + } + log.info( + `Found system file ${foundFiles.system} in cache directory ${directory}` + ) + return augmentWithReleaseNotesContent(files) + } + + throw new Error( + `no release files cached: could not find system file ${outPath( + directory, + urls.system + )} in ${files}` + ) + }) +} + +// downloads the entire release set to a temporary directory, and once they're +// all successfully downloaded, renames the directory to `directory` +export function downloadReleaseFiles( + urls: ReleaseSetUrls, + directory: string, + // `onProgress` will be called with download progress as the files are read + onProgress: (progress: DownloadProgress) => void, + canceller: AbortController +): Promise { + const tempDir: string = tempy.directory() + const tempSystemPath = outPath(tempDir, urls.system) + const tempNotesPath = outPath(tempDir, urls.releaseNotes ?? '') + // downloads are streamed directly to the filesystem to avoid loading them + // all into memory simultaneously + const notesReq = + urls.releaseNotes != null + ? fetchToFile(urls.releaseNotes, tempNotesPath, { + signal: canceller.signal, + }).catch(err => { + log.warn( + `release notes not available from ${urls.releaseNotes}: ${err.name}: ${err.message}` + ) + return null + }) + : Promise.resolve(null) + if (urls.releaseNotes != null) { + log.info(`Downloading ${urls.releaseNotes} to ${tempNotesPath}`) + } else { + log.info('No release notes available, not downloading') + } + log.info(`Downloading ${urls.system} to ${tempSystemPath}`) + const systemReq = fetchToFile(urls.system, tempSystemPath, { + onProgress, + signal: canceller.signal, + }) + return Promise.all([systemReq, notesReq]) + .then(results => { + const [systemTemp, releaseNotesTemp] = results + const systemPath = outPath(directory, systemTemp) + const notesPath = releaseNotesTemp + ? outPath(directory, releaseNotesTemp) + : null + + log.info(`Download complete, ${tempDir}=>${directory}`) + + return move(tempDir, directory, { overwrite: true }).then(() => { + log.info(`Move complete`) + return augmentWithReleaseNotesContent({ + system: systemPath, + releaseNotes: notesPath, + }) + }) + }) + .catch(error => { + log.error( + `Failed to download release files: ${error.name}: ${error.message}` + ) + return rm(tempDir, { force: true, recursive: true }).then(() => { + throw error + }) + }) +} + +export async function getOrDownloadReleaseFiles( + urls: ReleaseSetUrls, + releaseCacheDirectory: string, + onProgress: (progress: DownloadProgress) => void, + canceller: AbortController +): Promise { + try { + return await getReleaseFiles(urls, releaseCacheDirectory) + } catch (error: any) { + log.info( + `Could not find cached release files for ${releaseCacheDirectory}: ${error.name}: ${error.message}, attempting to download` + ) + return await downloadReleaseFiles( + urls, + releaseCacheDirectory, + onProgress, + canceller + ) + } +} + +export const cleanUpAndGetOrDownloadReleaseFiles = ( + urls: ReleaseSetUrls, + baseDirectory: string, + version: string, + onProgress: (progress: DownloadProgress) => void, + canceller: AbortController +): Promise => + ensureCleanReleaseCacheForVersion(baseDirectory, version).then(versionCache => + getOrDownloadReleaseFiles(urls, versionCache, onProgress, canceller) + ) + +const readReleaseNotes = (path: string | null): Promise => + path == null + ? new Promise(resolve => { + resolve(null) + }) + : readFile(path, { encoding: 'utf-8' }).catch(err => { + log.warn( + `Could not read release notes from ${path}: ${err.name}: ${err.message}` + ) + return null + }) diff --git a/app-shell-odd/src/system-update/from-web/release-manifest.ts b/app-shell-odd/src/system-update/from-web/release-manifest.ts new file mode 100644 index 00000000000..9433067cb17 --- /dev/null +++ b/app-shell-odd/src/system-update/from-web/release-manifest.ts @@ -0,0 +1,101 @@ +import * as FS from 'fs/promises' +import path from 'path' +import { readJson, outputJson } from 'fs-extra' + +import type { Stats } from 'fs' +import { fetchJson, LocalAbortError } from '../../http' +import type { ReleaseManifest, ReleaseSetUrls } from '../types' +import { createLogger } from '../../log' + +const log = createLogger('systemUpdate/from-web/provider') + +export function getReleaseSet( + manifest: ReleaseManifest, + version: string +): ReleaseSetUrls | null { + return manifest.production[version] ?? null +} + +export const getCachedReleaseManifest = ( + cacheDir: string +): Promise => readJson(`${cacheDir}/manifest.json`) + +const removeAndRemake = (directory: string): Promise => + FS.rm(directory, { recursive: true, force: true }) + .then(() => FS.mkdir(directory, { recursive: true })) + .then(() => FS.stat(directory)) + +export const ensureCacheDir = (directory: string): Promise => + FS.stat(directory) + .catch(() => removeAndRemake(directory)) + .then(stats => + stats.isDirectory() + ? new Promise(resolve => { + resolve(stats) + }) + : removeAndRemake(directory) + ) + .then(() => FS.readdir(directory, { withFileTypes: true })) + .then(contents => { + const manifestCandidate = contents.find( + entry => entry.name === 'manifest.json' + ) + if (manifestCandidate == null || manifestCandidate.isFile()) { + return new Promise(resolve => { + resolve(directory) + }) + } + return FS.rm(path.join(directory, 'manifest.json'), { + force: true, + recursive: true, + }).then(() => directory) + }) + +export const downloadManifest = ( + manifestUrl: string, + cacheDir: string, + cancel: AbortController +): Promise => { + log.info(`Attempting to fetch release manifest from ${manifestUrl}`) + return fetchJson(manifestUrl, { + signal: cancel.signal, + }).then(manifest => { + log.info('Fetched release manifest OK') + return outputJson(path.join(cacheDir, 'manifest.json'), manifest).then( + () => manifest + ) + }) +} + +export const ensureCacheDirAndDownloadManifest = ( + manifestUrl: string, + cacheDir: string, + cancel: AbortController +): Promise => + ensureCacheDir(cacheDir).then(ensuredCacheDir => + downloadManifest(manifestUrl, ensuredCacheDir, cancel) + ) + +export async function getOrDownloadManifest( + manifestUrl: string, + cacheDir: string, + cancel: AbortController +): Promise { + try { + return await ensureCacheDirAndDownloadManifest( + manifestUrl, + cacheDir, + cancel + ) + } catch (error: any) { + if (error instanceof LocalAbortError) { + log.info('Aborted during manifest fetch') + throw error + } else { + log.info( + `Could not fetch manifest: ${error.name}: ${error.message}, falling back to cached` + ) + return await getCachedReleaseManifest(cacheDir) + } + } +} diff --git a/app-shell-odd/src/system-update/handler.ts b/app-shell-odd/src/system-update/handler.ts new file mode 100644 index 00000000000..8344578e9fa --- /dev/null +++ b/app-shell-odd/src/system-update/handler.ts @@ -0,0 +1,380 @@ +// system update handler + +import Semver from 'semver' + +import { CONFIG_INITIALIZED, VALUE_UPDATED } from '../constants' +import { createLogger } from '../log' +import { postFile } from '../http' +import { getConfig } from '../config' +import { getSystemUpdateDir } from './directories' +import { SYSTEM_FILENAME, FLEX_MANIFEST_URL } from './constants' +import { getProvider as getWebUpdateProvider } from './from-web' +import { getProvider as getUsbUpdateProvider } from './from-usb' + +import type { Action, Dispatch } from '../types' +import type { UpdateProvider, UnresolvedUpdate, ReadyUpdate } from './types' +import type { USBUpdateSource } from './from-usb' + +export const CURRENT_SYSTEM_VERSION = _PKG_VERSION_ + +const log = createLogger('system-update/handler') + +export interface UpdateDriver { + handleAction: (action: Action) => Promise + reload: () => Promise + shouldReload: () => boolean + teardown: () => Promise +} + +export function createUpdateDriver(dispatch: Dispatch): UpdateDriver { + log.info(`Running robot system updates storing to ${getSystemUpdateDir()}`) + + let webUpdate: UnresolvedUpdate = { + version: null, + files: null, + releaseNotes: null, + downloadProgress: 0, + } + let webProvider = getWebUpdateProvider({ + manifestUrl: FLEX_MANIFEST_URL, + channel: getConfig('update').channel, + updateCacheDirectory: getSystemUpdateDir(), + currentVersion: CURRENT_SYSTEM_VERSION, + }) + const usbProviders: Record> = {} + let currentBestUsbUpdate: + | (ReadyUpdate & { providerName: string }) + | null = null + + const updateBestUsbUpdate = (): void => { + currentBestUsbUpdate = null + Object.values(usbProviders).forEach(provider => { + const providerUpdate = provider.getUpdateDetails() + if (providerUpdate.files == null) { + // nothing to do, keep null + } else if (currentBestUsbUpdate == null) { + currentBestUsbUpdate = { + ...(providerUpdate as ReadyUpdate), + providerName: provider.name(), + } + } else if ( + Semver.gt(providerUpdate.version, currentBestUsbUpdate.version) + ) { + currentBestUsbUpdate = { + ...(providerUpdate as ReadyUpdate), + providerName: provider.name(), + } + } + }) + } + + const dispatchStaticUpdateData = (): void => { + if (currentBestUsbUpdate != null) { + dispatchUpdateInfo( + { + version: currentBestUsbUpdate.version, + releaseNotes: currentBestUsbUpdate.releaseNotes, + force: true, + }, + dispatch + ) + } else { + dispatchUpdateInfo( + { + version: webUpdate.version, + releaseNotes: webUpdate.releaseNotes, + force: false, + }, + dispatch + ) + } + } + + return { + handleAction: (action: Action): Promise => { + switch (action.type) { + case 'shell:CHECK_UPDATE': + return webProvider + .refreshUpdateCache(updateStatus => { + webUpdate = updateStatus + if (currentBestUsbUpdate == null) { + if ( + updateStatus.version != null && + updateStatus.files == null && + updateStatus.downloadProgress === 0 + ) { + dispatch({ + type: 'robotUpdate:UPDATE_VERSION', + payload: { + version: updateStatus.version, + force: false, + target: 'flex', + }, + }) + } else if ( + updateStatus.version != null && + updateStatus.files == null && + updateStatus.downloadProgress !== 0 + ) { + dispatch({ + // TODO: change this action type to 'systemUpdate:DOWNLOAD_PROGRESS' + type: 'robotUpdate:DOWNLOAD_PROGRESS', + payload: { + progress: updateStatus.downloadProgress, + target: 'flex', + }, + }) + } else if (updateStatus.files != null) { + dispatchStaticUpdateData() + } + } + }) + .catch(err => { + log.warn( + `Error finding updates with ${webProvider.name()}: ${ + err.name + }: ${err.message}` + ) + return { + version: null, + files: null, + downloadProgress: 0, + releaseNotes: null, + } as const + }) + .then(result => { + webUpdate = result + dispatchStaticUpdateData() + }) + case 'shell:ROBOT_MASS_STORAGE_DEVICE_ENUMERATED': + log.info( + `mass storage device enumerated at ${action.payload.rootPath}` + ) + if (usbProviders[action.payload.rootPath] != null) { + return new Promise(resolve => { + resolve() + }) + } + usbProviders[action.payload.rootPath] = getUsbUpdateProvider({ + currentVersion: CURRENT_SYSTEM_VERSION, + massStorageDeviceRoot: action.payload.rootPath, + massStorageDeviceFiles: action.payload.filePaths, + }) + return usbProviders[action.payload.rootPath] + .refreshUpdateCache(() => {}) + .then(() => { + updateBestUsbUpdate() + dispatchStaticUpdateData() + }) + .catch(err => { + log.error( + `Failed to get updates from ${action.payload.rootPath}: ${err.name}: ${err.message}` + ) + }) + + case 'shell:ROBOT_MASS_STORAGE_DEVICE_REMOVED': + log.info(`mass storage removed at ${action.payload.rootPath}`) + const provider = usbProviders[action.payload.rootPath] + if (provider != null) { + return provider + .teardown() + .then(() => { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete usbProviders[action.payload.rootPath] + updateBestUsbUpdate() + }) + .catch(err => { + log.error( + `Failed to tear down provider ${provider.name()}: ${ + err.name + }: ${err.message}` + ) + }) + .then(() => { + dispatchStaticUpdateData() + }) + } + return new Promise(resolve => { + resolve() + }) + case 'robotUpdate:UPLOAD_FILE': { + const { host, path, systemFile } = action.payload + // eslint-disable-next-line @typescript-eslint/no-floating-promises + return postFile( + `http://${host.ip}:${host.port}${path}`, + SYSTEM_FILENAME, + systemFile + ) + .then(() => ({ + type: 'robotUpdate:FILE_UPLOAD_DONE' as const, + payload: host.name, + })) + .catch((error: Error) => { + log.warn('Error uploading update to robot', { + path, + systemFile, + error, + }) + + return { + type: 'robotUpdate:UNEXPECTED_ERROR' as const, + payload: { + message: `Error uploading update to robot: ${error.message}`, + }, + } + }) + .then(dispatch) + } + case 'robotUpdate:READ_SYSTEM_FILE': { + const getDetails = (): { + systemFile: string + version: string + isManualFile: false + } | null => { + if (currentBestUsbUpdate) { + return { + systemFile: currentBestUsbUpdate.files.system, + version: currentBestUsbUpdate.version, + isManualFile: false, + } + } else if (webUpdate.files?.system != null) { + return { + systemFile: webUpdate.files.system, + version: webUpdate.version as string, // version is string if files is not null + isManualFile: false, + } + } else { + return null + } + } + return new Promise(resolve => { + const details = getDetails() + if (details == null) { + dispatch({ + type: 'robotUpdate:UNEXPECTED_ERROR', + payload: { message: 'System update file not downloaded' }, + }) + resolve() + return + } + + dispatch({ + type: 'robotUpdate:FILE_INFO' as const, + payload: details, + }) + resolve() + }) + } + case 'robotUpdate:READ_USER_FILE': { + return new Promise(resolve => { + dispatch({ + type: 'robotUpdate:UNEXPECTED_ERROR', + payload: { + message: 'Updates of this kind are not implemented for ODD', + }, + }) + resolve() + }) + } + } + return new Promise(resolve => { + resolve() + }) + }, + reload: () => { + webProvider.lockUpdateCache() + return webProvider + .teardown() + .catch(err => { + log.error( + `Failed to tear down web provider ${webProvider.name()}: ${ + err.name + }: ${err.message}` + ) + }) + .then(() => { + webProvider = getWebUpdateProvider({ + manifestUrl: FLEX_MANIFEST_URL, + channel: getConfig('update').channel, + updateCacheDirectory: getSystemUpdateDir(), + currentVersion: CURRENT_SYSTEM_VERSION, + }) + }) + .catch(err => { + const message = `System updates failed to handle config change: ${err.name}: ${err.message}` + log.error(message) + dispatch({ + type: 'robotUpdate:UNEXPECTED_ERROR', + payload: { message: message }, + }) + }) + }, + shouldReload: () => + getConfig('update').channel !== webProvider.source().channel, + teardown: () => { + return Promise.allSettled([ + webProvider.teardown(), + ...Object.values(usbProviders).map(provider => provider.teardown()), + ]) + .catch(errs => { + log.error(`Failed to tear down some providers: ${errs}`) + }) + .then(results => { + log.info('all providers torn down') + }) + }, + } +} + +export interface UpdatableDriver { + getUpdateDriver: () => UpdateDriver | null + handleAction: (action: Action) => Promise +} + +export function manageDriver(dispatch: Dispatch): UpdatableDriver { + let updateDriver: UpdateDriver | null = null + return { + handleAction: action => { + if (action.type === CONFIG_INITIALIZED) { + log.info('Initializing update driver') + return new Promise(resolve => { + updateDriver = createUpdateDriver(dispatch) + resolve() + }) + } else if (updateDriver != null) { + if (action.type === VALUE_UPDATED && updateDriver.shouldReload()) { + return updateDriver.reload() + } else { + return updateDriver.handleAction(action) + } + } else { + return new Promise(resolve => { + log.warn( + `update driver manager received action ${action.type} before initialization` + ) + resolve() + }) + } + }, + getUpdateDriver: () => updateDriver, + } +} + +export function registerRobotSystemUpdate(dispatch: Dispatch): Dispatch { + return manageDriver(dispatch).handleAction +} + +const dispatchUpdateInfo = ( + info: { version: string | null; releaseNotes: string | null; force: boolean }, + dispatch: Dispatch +): void => { + const { version, releaseNotes, force } = info + dispatch({ + type: 'robotUpdate:UPDATE_INFO', + payload: { releaseNotes, version, force, target: 'flex' }, + }) + dispatch({ + type: 'robotUpdate:UPDATE_VERSION', + payload: { version, force, target: 'flex' }, + }) +} diff --git a/app-shell-odd/src/system-update/index.ts b/app-shell-odd/src/system-update/index.ts index 7d8e62fb8ac..4ec36b05a57 100644 --- a/app-shell-odd/src/system-update/index.ts +++ b/app-shell-odd/src/system-update/index.ts @@ -1,394 +1,2 @@ // system update files -import path from 'path' -import { ensureDir } from 'fs-extra' -import { readFile } from 'fs/promises' -import StreamZip from 'node-stream-zip' -import Semver from 'semver' -import { UI_INITIALIZED } from '../constants' -import { createLogger } from '../log' -import { - getLatestSystemUpdateUrls, - getLatestVersion, - isUpdateAvailable, - updateLatestVersion, -} from '../update' -import { - getReleaseFiles, - readUserFileInfo, - cleanupReleaseFiles, -} from './release-files' -import { uploadSystemFile } from './update' -import { getSystemUpdateDir } from './directories' - -import type { DownloadProgress } from '../http' -import type { Action, Dispatch } from '../types' -import type { ReleaseSetFilepaths } from './types' - -const log = createLogger('systemUpdate/index') -const REASONABLE_VERSION_FILE_SIZE_B = 4096 - -let isGettingLatestSystemFiles = false -const isGettingMassStorageUpdatesFrom: Set = new Set() -let massStorageUpdateSet: ReleaseSetFilepaths | null = null -let systemUpdateSet: ReleaseSetFilepaths | null = null - -const readFileInfoAndDispatch = ( - dispatch: Dispatch, - fileName: string, - isManualFile: boolean = false -): Promise => - readUserFileInfo(fileName) - .then(fileInfo => ({ - type: 'robotUpdate:FILE_INFO' as const, - payload: { - systemFile: fileInfo.systemFile, - version: fileInfo.versionInfo.opentrons_api_version, - isManualFile, - }, - })) - .catch((error: Error) => ({ - type: 'robotUpdate:UNEXPECTED_ERROR' as const, - payload: { message: error.message }, - })) - .then(dispatch) - -export function registerRobotSystemUpdate(dispatch: Dispatch): Dispatch { - log.info(`Running robot system updates storing to ${getSystemUpdateDir()}`) - return function handleAction(action: Action) { - switch (action.type) { - case UI_INITIALIZED: - case 'shell:CHECK_UPDATE': - // short circuit early if we're already downloading the latest system files - if (isGettingLatestSystemFiles) { - log.info(`system update download already in progress`) - return - } - updateLatestVersion() - .then(() => { - if (isUpdateAvailable() && !isGettingLatestSystemFiles) { - isGettingLatestSystemFiles = true - return getLatestSystemUpdateFiles(dispatch) - } - }) - .then(() => { - isGettingLatestSystemFiles = false - }) - .catch((error: Error) => { - log.warn('Error checking for update', { - error, - }) - isGettingLatestSystemFiles = false - }) - - break - - case 'robotUpdate:UPLOAD_FILE': { - const { host, path, systemFile } = action.payload - // eslint-disable-next-line @typescript-eslint/no-floating-promises - uploadSystemFile(host, path, systemFile) - .then(() => ({ - type: 'robotUpdate:FILE_UPLOAD_DONE' as const, - payload: host.name, - })) - .catch((error: Error) => { - log.warn('Error uploading update to robot', { - path, - systemFile, - error, - }) - - return { - type: 'robotUpdate:UNEXPECTED_ERROR' as const, - payload: { - message: `Error uploading update to robot: ${error.message}`, - }, - } - }) - .then(dispatch) - - break - } - - case 'robotUpdate:READ_USER_FILE': { - const { systemFile } = action.payload as { systemFile: string } - // eslint-disable-next-line @typescript-eslint/no-floating-promises - readFileInfoAndDispatch(dispatch, systemFile, true) - break - } - case 'robotUpdate:READ_SYSTEM_FILE': { - const systemFile = - massStorageUpdateSet?.system ?? systemUpdateSet?.system - if (systemFile == null) { - dispatch({ - type: 'robotUpdate:UNEXPECTED_ERROR', - payload: { message: 'System update file not downloaded' }, - }) - return - } - // eslint-disable-next-line @typescript-eslint/no-floating-promises - readFileInfoAndDispatch(dispatch, systemFile) - break - } - case 'shell:ROBOT_MASS_STORAGE_DEVICE_ENUMERATED': - if (isGettingMassStorageUpdatesFrom.has(action.payload.rootPath)) { - return - } - isGettingMassStorageUpdatesFrom.add(action.payload.rootPath) - getLatestMassStorageUpdateFiles(action.payload.filePaths, dispatch) - .then(() => { - isGettingMassStorageUpdatesFrom.delete(action.payload.rootPath) - }) - .catch(() => { - isGettingMassStorageUpdatesFrom.delete(action.payload.rootPath) - }) - break - case 'shell:ROBOT_MASS_STORAGE_DEVICE_REMOVED': - if ( - massStorageUpdateSet !== null && - massStorageUpdateSet.system.startsWith(action.payload.rootPath) - ) { - console.log( - `Mass storage device ${action.payload.rootPath} removed, reverting to non-usb updates` - ) - massStorageUpdateSet = null - getCachedSystemUpdateFiles(dispatch) - } else { - console.log( - `Mass storage device ${action.payload.rootPath} removed but this was not an update source` - ) - } - break - } - } -} - -const getVersionFromOpenedZipIfValid = (zip: StreamZip): Promise => - new Promise((resolve, reject) => { - Object.values(zip.entries()).forEach(entry => { - if ( - entry.isFile && - entry.name === 'VERSION.json' && - entry.size < REASONABLE_VERSION_FILE_SIZE_B - ) { - const contents = zip.entryDataSync(entry.name).toString('ascii') - try { - const parsedContents = JSON.parse(contents) - if (parsedContents?.robot_type !== 'OT-3 Standard') { - reject(new Error('not a Flex release file')) - } - const fileVersion = parsedContents?.opentrons_api_version - const version = Semver.valid(fileVersion as string) - if (version === null) { - reject(new Error(`${fileVersion} is not a valid version`)) - } else { - resolve(version) - } - } catch (error) { - reject(error) - } - } - }) - }) - -interface FileDetails { - path: string - version: string -} - -const getVersionFromZipIfValid = (path: string): Promise => - new Promise((resolve, reject) => { - const zip = new StreamZip({ file: path, storeEntries: true }) - zip.on('ready', () => { - getVersionFromOpenedZipIfValid(zip) - .then(version => { - zip.close() - resolve({ version, path }) - }) - .catch(err => { - zip.close() - reject(err) - }) - }) - zip.on('error', err => { - zip.close() - reject(err) - }) - }) - -const fakeReleaseNotesForMassStorage = (version: string): string => ` -# Opentrons Robot Software Version ${version} - -This update is from a USB mass storage device connected to your Flex, and release notes cannot be shown. - -Don't remove the USB mass storage device while the update is in progress. -` - -export const getLatestMassStorageUpdateFiles = ( - filePaths: string[], - dispatch: Dispatch -): Promise => - Promise.all( - filePaths.map(path => - path.endsWith('.zip') - ? getVersionFromZipIfValid(path).catch(() => null) - : new Promise(resolve => { - resolve(null) - }) - ) - ).then(values => { - const update = values.reduce( - (prev, current) => - prev === null - ? current === null - ? prev - : current - : current === null - ? prev - : Semver.gt(current.version, prev.version) - ? current - : prev, - null - ) - if (update === null) { - console.log('no updates found in mass storage device') - } else { - console.log(`found update to version ${update.version} on mass storage`) - const releaseNotes = fakeReleaseNotesForMassStorage(update.version) - massStorageUpdateSet = { system: update.path, releaseNotes } - dispatchUpdateInfo( - { version: update.version, releaseNotes, force: true }, - dispatch - ) - } - }) - -const dispatchUpdateInfo = ( - info: { version: string | null; releaseNotes: string | null; force: boolean }, - dispatch: Dispatch -): void => { - const { version, releaseNotes, force } = info - dispatch({ - type: 'robotUpdate:UPDATE_INFO', - payload: { releaseNotes, version, force, target: 'flex' }, - }) - dispatch({ - type: 'robotUpdate:UPDATE_VERSION', - payload: { version, force, target: 'flex' }, - }) -} - -// Get latest system update version -// 1. Ensure the system update directory exists -// 2. Get the manifest file from the local cache -// 3. Get the release files according to the manifest -// a. If the files need downloading, dispatch progress updates to UI -// 4. Cache the filepaths of the update files in memory -// 5. Dispatch info or error to UI -export function getLatestSystemUpdateFiles( - dispatch: Dispatch -): Promise { - const fileDownloadDir = path.join( - getSystemUpdateDir(), - 'robot-system-updates' - ) - - return ensureDir(getSystemUpdateDir()) - .then(() => getLatestSystemUpdateUrls()) - .then(urls => { - if (urls === null) { - const latestVersion = getLatestVersion() - log.warn('No release files in manifest', { - version: latestVersion, - }) - return Promise.reject( - new Error(`No release files in manifest for version ${latestVersion}`) - ) - } - - let prevPercentDone = 0 - - const handleProgress = (progress: DownloadProgress): void => { - const { downloaded, size } = progress - if (size !== null) { - const percentDone = Math.round((downloaded / size) * 100) - if (Math.abs(percentDone - prevPercentDone) > 0) { - if (massStorageUpdateSet === null) { - dispatch({ - // TODO: change this action type to 'systemUpdate:DOWNLOAD_PROGRESS' - type: 'robotUpdate:DOWNLOAD_PROGRESS', - payload: { progress: percentDone, target: 'flex' }, - }) - } - prevPercentDone = percentDone - } - } - } - - return getReleaseFiles(urls, fileDownloadDir, handleProgress) - .then(filepaths => { - return cacheUpdateSet(filepaths) - }) - .then(updateInfo => { - massStorageUpdateSet === null && - dispatchUpdateInfo({ force: false, ...updateInfo }, dispatch) - }) - .catch((error: Error) => { - dispatch({ - type: 'robotUpdate:DOWNLOAD_ERROR', - payload: { error: error.message, target: 'flex' }, - }) - }) - .then(() => - cleanupReleaseFiles(getSystemUpdateDir(), 'robot-system-updates') - ) - .catch((error: Error) => { - log.warn('Unable to cleanup old release files', { error }) - }) - }) -} - -export function getCachedSystemUpdateFiles( - dispatch: Dispatch -): Promise { - if (systemUpdateSet) { - return getInfoFromUpdateSet(systemUpdateSet) - .then(updateInfo => { - dispatchUpdateInfo({ force: false, ...updateInfo }, dispatch) - }) - .catch(err => { - console.log(`Could not get info from update set: ${err}`) - }) - } else { - dispatchUpdateInfo( - { version: null, releaseNotes: null, force: false }, - dispatch - ) - return new Promise(resolve => { - resolve('no files') - }) - } -} - -function getInfoFromUpdateSet( - filepaths: ReleaseSetFilepaths -): Promise<{ version: string; releaseNotes: string | null }> { - const version = getLatestVersion() - const releaseNotesContentPromise = filepaths.releaseNotes - ? readFile(filepaths.releaseNotes, 'utf8') - : new Promise(resolve => { - resolve(null) - }) - return releaseNotesContentPromise - .then(releaseNotes => ({ - version: version, - releaseNotes, - })) - .catch(() => ({ version: version, releaseNotes: '' })) -} - -function cacheUpdateSet( - filepaths: ReleaseSetFilepaths -): Promise<{ version: string; releaseNotes: string | null }> { - systemUpdateSet = filepaths - return getInfoFromUpdateSet(systemUpdateSet) -} +export { registerRobotSystemUpdate } from './handler' diff --git a/app-shell-odd/src/system-update/release-files.ts b/app-shell-odd/src/system-update/release-files.ts deleted file mode 100644 index 6ea57648d05..00000000000 --- a/app-shell-odd/src/system-update/release-files.ts +++ /dev/null @@ -1,148 +0,0 @@ -// functions for downloading and storing release files -import assert from 'assert' -import path from 'path' -import { promisify } from 'util' -import tempy from 'tempy' -import { move, readdir, remove } from 'fs-extra' -import StreamZip from 'node-stream-zip' -import getStream from 'get-stream' - -import { createLogger } from '../log' -import { fetchToFile } from '../http' -import type { DownloadProgress } from '../http' -import type { ReleaseSetUrls, ReleaseSetFilepaths, UserFileInfo } from './types' - -const VERSION_FILENAME = 'VERSION.json' - -const log = createLogger('systemUpdate/release-files') -const outPath = (dir: string, url: string): string => { - return path.join(dir, path.basename(url)) -} - -// checks `directory` for system update files matching the given `urls`, and -// downloads them if they can't be found -export function getReleaseFiles( - urls: ReleaseSetUrls, - directory: string, - onProgress: (progress: DownloadProgress) => unknown -): Promise { - return readdir(directory) - .catch(error => { - log.warn('Error retrieving files from filesystem', { error }) - return [] - }) - .then((files: string[]) => { - log.debug('Files in system update download directory', { files }) - const system = outPath(directory, urls.system) - const releaseNotes = outPath(directory, urls.releaseNotes ?? '') - - // TODO: check for release notes when OT-3 manifest points to real release notes - if (files.some(f => f === path.basename(system))) { - return { system, releaseNotes } - } - - return downloadReleaseFiles(urls, directory, onProgress) - }) -} - -// downloads the entire release set to a temporary directory, and once they're -// all successfully downloaded, renames the directory to `directory` -// TODO(mc, 2019-07-09): DRY this up if/when more than 2 files are required -export function downloadReleaseFiles( - urls: ReleaseSetUrls, - directory: string, - // `onProgress` will be called with download progress as the files are read - onProgress: (progress: DownloadProgress) => unknown -): Promise { - const tempDir: string = tempy.directory() - const tempSystemPath = outPath(tempDir, urls.system) - const tempNotesPath = outPath(tempDir, urls.releaseNotes ?? '') - - log.debug('directory created for robot update downloads', { tempDir }) - - // downloads are streamed directly to the filesystem to avoid loading them - // all into memory simultaneously - const systemReq = fetchToFile(urls.system, tempSystemPath, { onProgress }) - const notesReq = urls.releaseNotes - ? fetchToFile(urls.releaseNotes, tempNotesPath) - : Promise.resolve(null) - - return Promise.all([systemReq, notesReq]).then(results => { - const [systemTemp, releaseNotesTemp] = results - const systemPath = outPath(directory, systemTemp) - const notesPath = releaseNotesTemp - ? outPath(directory, releaseNotesTemp) - : null - - log.debug('renaming directory', { from: tempDir, to: directory }) - - return move(tempDir, directory, { overwrite: true }).then(() => ({ - system: systemPath, - releaseNotes: notesPath, - })) - }) -} - -export function readUserFileInfo(systemFile: string): Promise { - const openZip = new Promise((resolve, reject) => { - const zip = new StreamZip({ file: systemFile, storeEntries: true }) - .once('ready', handleReady) - .once('error', handleError) - - function handleReady(): void { - cleanup() - resolve(zip) - } - - function handleError(error: Error): void { - cleanup() - zip.close() - reject(error) - } - - function cleanup(): void { - zip.removeListener('ready', handleReady) - zip.removeListener('error', handleError) - } - }) - - return openZip.then(zip => { - const entries = zip.entries() - const streamFromZip = promisify(zip.stream.bind(zip)) - - assert(VERSION_FILENAME in entries, `${VERSION_FILENAME} not in archive`) - - const result = streamFromZip(VERSION_FILENAME) - // @ts-expect-error(mc, 2021-02-17): stream may be undefined - .then(getStream) - .then(JSON.parse) - .then(versionInfo => ({ - systemFile, - versionInfo, - })) - - result.finally(() => { - zip.close() - }) - - return result - }) -} - -export function cleanupReleaseFiles( - downloadsDir: string, - currentRelease: string -): Promise { - log.debug('deleting release files not part of release ', currentRelease) - - return readdir(downloadsDir, { withFileTypes: true }) - .then(files => { - return ( - files - // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions - .filter(f => f.isDirectory() && f.name !== currentRelease) - .map(f => path.join(downloadsDir, f.name)) - ) - }) - .then(removals => Promise.all(removals.map(f => remove(f)))) -} diff --git a/app-shell-odd/src/system-update/release-manifest.ts b/app-shell-odd/src/system-update/release-manifest.ts deleted file mode 100644 index d27c8a04449..00000000000 --- a/app-shell-odd/src/system-update/release-manifest.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { readJson, outputJson } from 'fs-extra' -import { fetchJson } from '../http' -import { createLogger } from '../log' -import { getManifestCacheDir } from './directories' -import type { ReleaseManifest, ReleaseSetUrls } from './types' - -const log = createLogger('systemUpdate/release-manifest') - -export function getReleaseSet( - manifest: ReleaseManifest, - version: string -): ReleaseSetUrls | null { - return manifest.production[version] ?? null -} - -export const getCachedReleaseManifest = (): Promise => - readJson(getManifestCacheDir()) - -export const downloadAndCacheReleaseManifest = ( - manifestUrl: string -): Promise => - fetchJson(manifestUrl) - .then(manifest => { - return outputJson(getManifestCacheDir(), manifest).then(() => manifest) - }) - .catch((error: Error) => { - log.error('Error downloading the release manifest', { error }) - return readJson(getManifestCacheDir()) - }) diff --git a/app-shell-odd/src/system-update/types.ts b/app-shell-odd/src/system-update/types.ts index 8555d980791..12c2f5dc674 100644 --- a/app-shell-odd/src/system-update/types.ts +++ b/app-shell-odd/src/system-update/types.ts @@ -16,24 +16,47 @@ export interface ReleaseSetFilepaths { releaseNotes: string | null } -// shape of VERSION.json in update file -export interface VersionInfo { - buildroot_version: string - buildroot_sha: string - buildroot_branch: string - buildroot_buildid: string - build_type: string - opentrons_api_version: string - opentrons_api_sha: string - opentrons_api_branch: string - update_server_version: string - update_server_sha: string - update_server_branch: string +export interface NoUpdate { + version: null + files: null + releaseNotes: null + downloadProgress: 0 } -export interface UserFileInfo { - // filepath of update file - systemFile: string - // parsed contents of VERSION.json - versionInfo: VersionInfo +export interface FoundUpdate { + version: string + files: null + releaseNotes: null + downloadProgress: number +} + +export interface ReadyUpdate { + version: string + files: ReleaseSetFilepaths + releaseNotes: string | null + downloadProgress: 100 +} + +export type ResolvedUpdate = NoUpdate | ReadyUpdate +export type UnresolvedUpdate = ResolvedUpdate | FoundUpdate +export type ProgressCallback = (status: UnresolvedUpdate) => void + +// Interface provided by the web and usb sourced updaters. Type variable is +// specified by the updater implementation. +export interface UpdateProvider { + // Call before disposing to make sure any temporary storage is removed + teardown: () => Promise + // Scan an implementation-defined location for updates + refreshUpdateCache: (progress: ProgressCallback) => Promise + // Get the details of a found update, if any. + getUpdateDetails: () => UnresolvedUpdate + // Lock the update cache, which will prevent anything from accidentally overwriting stuff + // while it's being sent as an update + lockUpdateCache: () => void + // Reverse lockUpdateCache() + unlockUpdateCache: () => void + // get an identifier for logging + name: () => string + // get the current source + source: () => UpdateSourceDetails } diff --git a/app-shell-odd/src/system-update/update.ts b/app-shell-odd/src/system-update/update.ts deleted file mode 100644 index d1adb6e9c3d..00000000000 --- a/app-shell-odd/src/system-update/update.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { postFile } from '../http' -import type { - RobotModel, - ViewableRobot, -} from '@opentrons/app/src/redux/discovery/types' - -const OT2_FILENAME = 'ot2-system.zip' -const SYSTEM_FILENAME = 'system-update.zip' - -const getSystemFileName = (robotModel: RobotModel): string => { - if (robotModel === 'OT-2 Standard' || robotModel === null) { - return OT2_FILENAME - } - return SYSTEM_FILENAME -} - -export function uploadSystemFile( - robot: ViewableRobot, - urlPath: string, - file: string -): Promise { - const url = `http://${robot.ip}:${robot.port}${urlPath}` - - return postFile(url, getSystemFileName(robot.robotModel), file) -} diff --git a/app-shell-odd/src/system-update/utils.ts b/app-shell-odd/src/system-update/utils.ts new file mode 100644 index 00000000000..e0a334ba5d4 --- /dev/null +++ b/app-shell-odd/src/system-update/utils.ts @@ -0,0 +1,18 @@ +import { rm } from 'fs/promises' +import tempy from 'tempy' + +export const directoryWithCleanup = ( + task: (directory: string) => Promise +): Promise => { + const directory = tempy.directory() + return new Promise((resolve, reject) => + task(directory as string) + .then(result => { + resolve(result) + }) + .catch(err => { + reject(err) + }) + .finally(() => rm(directory as string, { recursive: true, force: true })) + ) +} diff --git a/app-shell-odd/src/system.ts b/app-shell-odd/src/system.ts new file mode 100644 index 00000000000..36c427a7e94 --- /dev/null +++ b/app-shell-odd/src/system.ts @@ -0,0 +1,22 @@ +import { UPDATE_BRIGHTNESS } from './constants' +import { createLogger } from './log' +import systemd from './systemd' + +import type { Action } from './types' + +const log = createLogger('system') + +export function registerUpdateBrightness(): (action: Action) => void { + return function handleAction(action: Action) { + switch (action.type) { + case UPDATE_BRIGHTNESS: + console.log('update the brightness') + systemd + .updateBrightness(action.payload.message) + .catch(err => + log.debug('Something wrong when updating the brightness', err) + ) + break + } + } +} diff --git a/app-shell-odd/src/types.ts b/app-shell-odd/src/types.ts index 2899171a08b..5d8f8a9502a 100644 --- a/app-shell-odd/src/types.ts +++ b/app-shell-odd/src/types.ts @@ -112,11 +112,13 @@ export type CLEAR_CACHE_TYPE = 'discovery:CLEAR_CACHE' export interface ConfigInitializedAction { type: CONFIG_INITIALIZED_TYPE payload: { config: Config } + meta: { shell: true } } export interface ConfigValueUpdatedAction { type: CONFIG_VALUE_UPDATED_TYPE payload: { path: string; value: any } + meta: { shell: true } } export interface StartDiscoveryAction { diff --git a/app-shell-odd/src/update.ts b/app-shell-odd/src/update.ts deleted file mode 100644 index d1ea2f154b3..00000000000 --- a/app-shell-odd/src/update.ts +++ /dev/null @@ -1,113 +0,0 @@ -import semver from 'semver' -import { UI_INITIALIZED, UPDATE_BRIGHTNESS } from './constants' -import { createLogger } from './log' -import { getConfig } from './config' -import { - downloadAndCacheReleaseManifest, - getCachedReleaseManifest, - getReleaseSet, -} from './system-update/release-manifest' -import systemd from './systemd' - -import type { Action, Dispatch } from './types' -import type { ReleaseSetUrls } from './system-update/types' - -const log = createLogger('update') - -const OPENTRONS_PROJECT: string = _OPENTRONS_PROJECT_ - -export const FLEX_MANIFEST_URL = - OPENTRONS_PROJECT && OPENTRONS_PROJECT.includes('robot-stack') - ? 'https://builds.opentrons.com/ot3-oe/releases.json' - : 'https://ot3-development.builds.opentrons.com/ot3-oe/releases.json' - -const PKG_VERSION = _PKG_VERSION_ -let LATEST_OT_SYSTEM_VERSION = PKG_VERSION - -const channelFinder = (version: string, channel: string): boolean => { - // return the latest alpha/beta if a user subscribes to alpha/beta updates - if (['alpha', 'beta'].includes(channel)) { - return version.includes(channel) - } else { - // otherwise get the latest stable version - return !version.includes('alpha') && !version.includes('beta') - } -} - -export const getLatestSystemUpdateUrls = (): Promise => { - return getCachedReleaseManifest() - .then(manifest => getReleaseSet(manifest, getLatestVersion())) - .catch((error: Error) => { - log.warn('Error retrieving release manifest', { - version: getLatestVersion(), - error, - }) - return Promise.reject(error) - }) -} - -export const updateLatestVersion = (): Promise => { - const channel = getConfig('update').channel - - return downloadAndCacheReleaseManifest(FLEX_MANIFEST_URL) - .then(response => { - const latestAvailableVersion = Object.keys(response.production) - .sort((a, b) => { - if (semver.lt(a, b)) { - return 1 - } - return -1 - }) - .find(verson => channelFinder(verson, channel)) - const changed = LATEST_OT_SYSTEM_VERSION !== latestAvailableVersion - LATEST_OT_SYSTEM_VERSION = latestAvailableVersion ?? PKG_VERSION - if (changed) { - log.info( - `Update: latest version available from ${FLEX_MANIFEST_URL} is ${latestAvailableVersion}` - ) - } - return LATEST_OT_SYSTEM_VERSION - }) - .catch((e: Error) => { - log.warn( - `Update: error fetching latest system version from ${FLEX_MANIFEST_URL}: ${e.message}, keeping latest version at ${LATEST_OT_SYSTEM_VERSION}` - ) - return LATEST_OT_SYSTEM_VERSION - }) -} - -export const getLatestVersion = (): string => { - return LATEST_OT_SYSTEM_VERSION -} - -export const getCurrentVersion = (): string => PKG_VERSION - -export const isUpdateAvailable = (): boolean => - getLatestVersion() !== getCurrentVersion() - -export function registerUpdate( - dispatch: Dispatch -): (action: Action) => unknown { - return function handleAction(action: Action) { - switch (action.type) { - case UI_INITIALIZED: - case 'shell:CHECK_UPDATE': - return updateLatestVersion() - } - } -} - -export function registerUpdateBrightness(): (action: Action) => unknown { - return function handleAction(action: Action) { - switch (action.type) { - case UPDATE_BRIGHTNESS: - console.log('update the brightness') - systemd - .updateBrightness(action.payload.message) - .catch(err => - log.debug('Something wrong when updating the brightness', err) - ) - break - } - } -} diff --git a/app-shell-odd/src/usb.ts b/app-shell-odd/src/usb.ts index 44252c6a339..1c5e6bd14a7 100644 --- a/app-shell-odd/src/usb.ts +++ b/app-shell-odd/src/usb.ts @@ -2,6 +2,7 @@ import * as fs from 'fs' import * as fsPromises from 'fs/promises' import { join } from 'path' import { flatten } from 'lodash' +import { createLogger } from './log' import { robotMassStorageDeviceAdded, robotMassStorageDeviceEnumerated, @@ -16,7 +17,12 @@ import type { Dispatch, Action } from './types' const FLEX_USB_MOUNT_DIR = '/media/' const FLEX_USB_DEVICE_DIR = '/dev/' -const FLEX_USB_MOUNT_FILTER = /sd[a-z]+[0-9]+$/ +// filter matches sda0, sdc9, sdb +const FLEX_USB_DEVICE_FILTER = /sd[a-z]+[0-9]*$/ +// filter matches sda0, sdc9, sdb, VOLUME-sdc10 +const FLEX_USB_MOUNT_FILTER = /([^/]+-)?(sd[a-z]+[0-9]*)$/ + +const log = createLogger('mass-storage') // These are for backoff algorithm // apply the delay from 1 sec 64 sec @@ -48,11 +54,15 @@ const isWeirdDirectoryAndShouldSkip = (dirName: string): boolean => .map(keyword => dirName.includes(keyword)) .reduce((prev, current) => prev || current, false) -const enumerateMassStorage = (path: string): Promise => { +const doEnumerateMassStorage = ( + path: string, + depth: number +): Promise => { + log.info(`Enumerating mass storage path ${path}`) return callWithRetry(() => fsPromises.readdir(path).then(entries => { - if (entries.length === 0) { - throw new Error('No entries found, retrying...') + if (entries.length === 0 && depth === 0) { + throw new Error('No entries found for top level, retrying...') } return entries }) @@ -62,29 +72,44 @@ const enumerateMassStorage = (path: string): Promise => { Promise.all( entries.map(entry => entry.isDirectory() && !isWeirdDirectoryAndShouldSkip(entry.name) - ? enumerateMassStorage(join(path, entry.name)) + ? doEnumerateMassStorage(join(path, entry.name), depth + 1) : new Promise(resolve => { resolve([join(path, entry.name)]) }) ) ) ) - .catch(error => { - console.error(`Error enumerating mass storage: ${error}`) + .catch((error: Error) => { + log.error( + `Error enumerating mass storage path ${path}: ${error.name}: ${error.message}` + ) return [] }) .then(flatten) - .then(result => { - return result - }) + .then(result => result) +} + +const enumerateMassStorage = (path: string): Promise => { + log.info(`Beginning scan of mass storage device at ${path}`) + return doEnumerateMassStorage(path, 0).then(results => { + log.info(`Found ${results.length} files in ${path}`) + return results + }) } + export function watchForMassStorage(dispatch: Dispatch): () => void { - console.log('watching for mass storage') + log.info('watching for mass storage') let prevDirs: string[] = [] const handleNewlyPresent = (path: string): Promise => { dispatch(robotMassStorageDeviceAdded(path)) return enumerateMassStorage(path) .then(contents => { + log.debug( + `mass storage device at ${path} enumerated: ${JSON.stringify( + contents + )}` + ) + log.info(`Enumerated ${path} with ${contents.length} results`) dispatch(robotMassStorageDeviceEnumerated(path, contents)) }) .then(() => path) @@ -101,6 +126,9 @@ export function watchForMassStorage(dispatch: Dispatch): () => void { const newlyAbsent = prevDirs.filter( entry => !sortedEntries.includes(entry) ) + log.info( + `rescan: newly present: ${newlyPresent} newly absent: ${newlyAbsent}` + ) return Promise.all([ ...newlyAbsent.map(entry => { if (entry.match(FLEX_USB_MOUNT_FILTER)) { @@ -119,6 +147,7 @@ export function watchForMassStorage(dispatch: Dispatch): () => void { ]) }) .then(present => { + log.info(`now present: ${present}`) prevDirs = present.filter((entry): entry is string => entry !== null) }) @@ -133,6 +162,9 @@ export function watchForMassStorage(dispatch: Dispatch): () => void { return } if (!fileName.match(FLEX_USB_MOUNT_FILTER)) { + log.debug( + `mediaWatcher: filename ${fileName} does not match ${FLEX_USB_MOUNT_FILTER}` + ) return } const fullPath = join(FLEX_USB_MOUNT_DIR, fileName) @@ -140,25 +172,36 @@ export function watchForMassStorage(dispatch: Dispatch): () => void { .stat(fullPath) .then(info => { if (!info.isDirectory) { + log.debug(`mediaWatcher: ${fullPath} is not a directory`) return } if (prevDirs.includes(fullPath)) { + log.debug(`mediaWatcher: ${fullPath} is known`) return } - console.log(`New mass storage device ${fileName} detected`) + log.info(`New mass storage device ${fileName} detected`) prevDirs.push(fullPath) return handleNewlyPresent(fullPath) }) - .catch(() => { + .catch(err => { if (prevDirs.includes(fullPath)) { - console.log(`Mass storage device at ${fileName} removed`) + log.info( + `Mass storage device at ${fileName} removed because its mount point disappeared`, + err + ) prevDirs = prevDirs.filter(entry => entry !== fullPath) dispatch(robotMassStorageDeviceRemoved(fullPath)) + } else { + log.debug( + `Mass storage device candidate mountpoint at ${fileName} disappeared`, + err + ) } }) } ) } catch { + log.error(`Failed to start watcher for ${FLEX_USB_MOUNT_DIR}`) return null } } @@ -170,21 +213,42 @@ export function watchForMassStorage(dispatch: Dispatch): () => void { { persistent: true }, (event, fileName) => { if (!!!fileName) return - if (!fileName.match(FLEX_USB_MOUNT_FILTER)) return - const fullPath = join(FLEX_USB_DEVICE_DIR, fileName) - const mountPath = join(FLEX_USB_MOUNT_DIR, fileName) - fsPromises.stat(fullPath).catch(() => { - if (prevDirs.includes(mountPath)) { - console.log(`Mass storage device at ${fileName} removed`) - prevDirs = prevDirs.filter(entry => entry !== mountPath) - dispatch( - robotMassStorageDeviceRemoved(join(FLEX_USB_MOUNT_DIR, fileName)) + if (!fileName.match(FLEX_USB_DEVICE_FILTER)) return + if (event !== 'rename') { + log.debug( + `devWatcher: ignoring ${event} event for ${fileName} (not rename)` + ) + return + } + log.debug(`devWatcher: ${event} event for ${fileName}`) + fsPromises + .readdir(FLEX_USB_DEVICE_DIR) + .then(contents => { + if (contents.includes(fileName)) { + log.debug( + `devWatcher: ${fileName} found in /dev, this is an attach` + ) + // this is an attach + return + } + const prevDir = prevDirs.filter(dir => dir.includes(fileName)).at(0) + log.debug( + `devWatcher: ${fileName} not in /dev, this is a remove, previously mounted at ${prevDir}` ) - // we don't care if this fails because it's racing the system removing - // the mount dir in the common case - fsPromises.unlink(mountPath).catch(() => {}) - } - }) + if (prevDir != null) { + log.info(`Mass storage device at ${fileName} removed`) + prevDirs = prevDirs.filter(entry => entry !== prevDir) + dispatch(robotMassStorageDeviceRemoved(prevDir)) + // we don't care if this fails because it's racing the system removing + // the mount dir in the common case + fsPromises.unlink(prevDir).catch(() => {}) + } + }) + .catch(err => { + log.info( + `Failed to handle mass storage device ${fileName}: ${err.name}: ${err.message}` + ) + }) } ) diff --git a/app-shell/src/config/actions.ts b/app-shell/src/config/actions.ts index eabc9b47a16..5d96e6c1171 100644 --- a/app-shell/src/config/actions.ts +++ b/app-shell/src/config/actions.ts @@ -111,6 +111,7 @@ import type { export const configInitialized = (config: Config): ConfigInitializedAction => ({ type: CONFIG_INITIALIZED, payload: { config }, + meta: { shell: true }, }) // config value has been updated @@ -120,6 +121,7 @@ export const configValueUpdated = ( ): ConfigValueUpdatedAction => ({ type: VALUE_UPDATED, payload: { path, value }, + meta: { shell: true }, }) export const customLabwareList = ( diff --git a/app-shell/src/main.ts b/app-shell/src/main.ts index ef422a455cc..0f4ab41733b 100644 --- a/app-shell/src/main.ts +++ b/app-shell/src/main.ts @@ -18,7 +18,6 @@ import { registerProtocolStorage } from './protocol-storage' import { getConfig, getStore, getOverrides, registerConfig } from './config' import { registerUsb } from './usb' import { registerNotify, closeAllNotifyConnections } from './notifications' - import type { BrowserWindow } from 'electron' import type { Action, Dispatch, Logger } from './types' import type { LogEntry } from 'winston' diff --git a/app-shell/src/types.ts b/app-shell/src/types.ts index 8a1bea51a20..f608b4512af 100644 --- a/app-shell/src/types.ts +++ b/app-shell/src/types.ts @@ -96,9 +96,11 @@ export type CLEAR_CACHE_TYPE = 'discovery:CLEAR_CACHE' export interface ConfigInitializedAction { type: CONFIG_INITIALIZED_TYPE payload: { config: Config } + meta: { shell: true } } export interface ConfigValueUpdatedAction { type: CONFIG_VALUE_UPDATED_TYPE payload: { path: string; value: any } + meta: { shell: true } } diff --git a/app/src/assets/localization/en/run_details.json b/app/src/assets/localization/en/run_details.json index e9f39f81d06..28df0734619 100644 --- a/app/src/assets/localization/en/run_details.json +++ b/app/src/assets/localization/en/run_details.json @@ -31,9 +31,11 @@ "custom_values": "Custom values", "data_out_of_date": "This data is likely out of date", "date": "Date", + "device_details": "Device details", "door_is_open": "Robot door is open", "door_open_pause": "Current Step - Paused - Door Open", "download": "Download", + "download_files": "Download files", "download_run_log": "Download run log", "downloading_run_log": "Downloading run log", "drop_tip": "Dropping tip in {{well_name}} of {{labware}} in {{labware_location}}", @@ -45,6 +47,7 @@ "error_info": "Error {{errorCode}}: {{errorType}}", "error_type": "Error: {{errorType}}", "failed_step": "Failed step", + "files_available_robot_details": "All files associated with the protocol run are available on the robot detail screen.", "final_step": "Final Step", "ignore_stored_data": "Ignore stored data", "labware": "labware", diff --git a/app/src/assets/videos/error-recovery/Gripper_Release.webm b/app/src/assets/videos/error-recovery/Gripper_Release.webm new file mode 100644 index 00000000000..a3ba721fd70 Binary files /dev/null and b/app/src/assets/videos/error-recovery/Gripper_Release.webm differ diff --git a/app/src/molecules/Command/hooks/index.ts b/app/src/local-resources/commands/hooks/index.ts similarity index 100% rename from app/src/molecules/Command/hooks/index.ts rename to app/src/local-resources/commands/hooks/index.ts diff --git a/app/src/molecules/Command/hooks/useCommandTextString/index.tsx b/app/src/local-resources/commands/hooks/useCommandTextString/index.tsx similarity index 99% rename from app/src/molecules/Command/hooks/useCommandTextString/index.tsx rename to app/src/local-resources/commands/hooks/useCommandTextString/index.tsx index d203595e112..3966a1bc7f4 100644 --- a/app/src/molecules/Command/hooks/useCommandTextString/index.tsx +++ b/app/src/local-resources/commands/hooks/useCommandTextString/index.tsx @@ -7,12 +7,12 @@ import type { RobotType, LabwareDefinition2, } from '@opentrons/shared-data' -import type { CommandTextData } from '../../types' import type { GetDirectTranslationCommandText } from './utils/getDirectTranslationCommandText' import type { TCProfileStepText, TCProfileCycleText, } from './utils/getTCRunExtendedProfileCommandText' +import type { CommandTextData } from '/app/local-resources/commands/types' export interface UseCommandTextStringParams { command: RunTimeCommand | null diff --git a/app/src/molecules/Command/utils/__tests__/getFinalLabwareLocation.test.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/__tests__/getFinalLabwareLocation.test.ts similarity index 100% rename from app/src/molecules/Command/utils/__tests__/getFinalLabwareLocation.test.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/__tests__/getFinalLabwareLocation.test.ts diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getAbsorbanceReaderCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getAbsorbanceReaderCommandText.ts similarity index 100% rename from app/src/molecules/Command/hooks/useCommandTextString/utils/getAbsorbanceReaderCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/getAbsorbanceReaderCommandText.ts diff --git a/app/src/molecules/Command/utils/getAddressableAreaDisplayName.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getAddressableAreaDisplayName.ts similarity index 95% rename from app/src/molecules/Command/utils/getAddressableAreaDisplayName.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/getAddressableAreaDisplayName.ts index 6bfdd2fc850..20d7c6cca07 100644 --- a/app/src/molecules/Command/utils/getAddressableAreaDisplayName.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getAddressableAreaDisplayName.ts @@ -3,7 +3,8 @@ import type { MoveToAddressableAreaParams, } from '@opentrons/shared-data' import type { TFunction } from 'i18next' -import type { CommandTextData } from '../types' + +import type { CommandTextData } from '/app/local-resources/commands' export function getAddressableAreaDisplayName( commandTextData: CommandTextData, diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getCommentCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getCommentCommandText.ts similarity index 100% rename from app/src/molecules/Command/hooks/useCommandTextString/utils/getCommentCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/getCommentCommandText.ts diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getConfigureForVolumeCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getConfigureForVolumeCommandText.ts similarity index 100% rename from app/src/molecules/Command/hooks/useCommandTextString/utils/getConfigureForVolumeCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/getConfigureForVolumeCommandText.ts diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getConfigureNozzleLayoutCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getConfigureNozzleLayoutCommandText.ts similarity index 100% rename from app/src/molecules/Command/hooks/useCommandTextString/utils/getConfigureNozzleLayoutCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/getConfigureNozzleLayoutCommandText.ts diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getCustomCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getCustomCommandText.ts similarity index 100% rename from app/src/molecules/Command/hooks/useCommandTextString/utils/getCustomCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/getCustomCommandText.ts diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getDelayCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getDelayCommandText.ts similarity index 100% rename from app/src/molecules/Command/hooks/useCommandTextString/utils/getDelayCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/getDelayCommandText.ts diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getDirectTranslationCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getDirectTranslationCommandText.ts similarity index 100% rename from app/src/molecules/Command/hooks/useCommandTextString/utils/getDirectTranslationCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/getDirectTranslationCommandText.ts diff --git a/app/src/molecules/Command/utils/getFinalLabwareLocation.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getFinalLabwareLocation.ts similarity index 100% rename from app/src/molecules/Command/utils/getFinalLabwareLocation.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/getFinalLabwareLocation.ts diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getHSShakeSpeedCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getHSShakeSpeedCommandText.ts similarity index 100% rename from app/src/molecules/Command/hooks/useCommandTextString/utils/getHSShakeSpeedCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/getHSShakeSpeedCommandText.ts diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getLiquidDisplayName.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getLiquidDisplayName.ts new file mode 100644 index 00000000000..2fcec940a55 --- /dev/null +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getLiquidDisplayName.ts @@ -0,0 +1,10 @@ +import type { Liquid } from '@opentrons/shared-data' + +export function getLiquidDisplayName( + liquids: Liquid[], + liquidId: string +): string { + const liquidDisplayName = liquids.find(liquid => liquid.id === liquidId) + ?.displayName + return liquidDisplayName ?? '' +} diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getLiquidProbeCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getLiquidProbeCommandText.ts similarity index 70% rename from app/src/molecules/Command/hooks/useCommandTextString/utils/getLiquidProbeCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/getLiquidProbeCommandText.ts index 7a08695b34f..171667012fe 100644 --- a/app/src/molecules/Command/hooks/useCommandTextString/utils/getLiquidProbeCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getLiquidProbeCommandText.ts @@ -1,8 +1,8 @@ import { - getFinalLabwareLocation, - getLabwareDisplayLocation, getLabwareName, -} from '../../../utils' + getLabwareDisplayLocation, +} from '/app/local-resources/labware' +import { getFinalLabwareLocation } from './getFinalLabwareLocation' import type { LiquidProbeRunTimeCommand, @@ -10,7 +10,6 @@ import type { TryLiquidProbeRunTimeCommand, } from '@opentrons/shared-data' import type { HandlesCommands } from './types' -import type { TFunction } from 'i18next' type LiquidProbeRunTimeCommands = | LiquidProbeRunTimeCommand @@ -38,20 +37,22 @@ export function getLiquidProbeCommandText({ ) : null - const displayLocation = - labwareLocation != null && commandTextData != null - ? getLabwareDisplayLocation( - commandTextData, - allRunDefs, - labwareLocation, - t as TFunction, - robotType - ) - : '' + const displayLocation = getLabwareDisplayLocation({ + loadedLabwares: commandTextData?.labware ?? [], + location: labwareLocation, + robotType, + allRunDefs, + loadedModules: commandTextData?.modules ?? [], + t, + }) const labware = commandTextData != null - ? getLabwareName(commandTextData, labwareId as string) + ? getLabwareName({ + loadedLabwares: commandTextData?.labware ?? [], + labwareId, + allRunDefs, + }) : null return t('detect_liquid_presence', { diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getLoadCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getLoadCommandText.ts similarity index 81% rename from app/src/molecules/Command/hooks/useCommandTextString/utils/getLoadCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/getLoadCommandText.ts index e28d52f3959..d8ab8736e08 100644 --- a/app/src/molecules/Command/hooks/useCommandTextString/utils/getLoadCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getLoadCommandText.ts @@ -5,13 +5,14 @@ import { getPipetteSpecsV2, } from '@opentrons/shared-data' +import { getPipetteNameOnMount } from './getPipetteNameOnMount' +import { getLiquidDisplayName } from './getLiquidDisplayName' + +import { getLabwareName } from '/app/local-resources/labware' import { - getLabwareName, - getPipetteNameOnMount, getModuleModel, getModuleDisplayLocation, - getLiquidDisplayName, -} from '../../../utils' +} from '/app/local-resources/modules' import type { LoadLabwareRunTimeCommand } from '@opentrons/shared-data' import type { GetCommandText } from '..' @@ -21,12 +22,16 @@ export const getLoadCommandText = ({ commandTextData, robotType, t, + allRunDefs, }: GetCommandText): string => { switch (command?.commandType) { case 'loadPipette': { const pipetteModel = commandTextData != null - ? getPipetteNameOnMount(commandTextData, command.params.mount) + ? getPipetteNameOnMount( + commandTextData.pipettes, + command.params.mount + ) : null return t('load_pipette_protocol_setup', { pipette_name: @@ -54,7 +59,10 @@ export const getLoadCommandText = ({ ) { const moduleModel = commandTextData != null - ? getModuleModel(commandTextData, command.params.location.moduleId) + ? getModuleModel( + commandTextData.modules ?? [], + command.params.location.moduleId + ) : null const moduleName = moduleModel != null ? getModuleDisplayName(moduleModel) : '' @@ -71,7 +79,7 @@ export const getLoadCommandText = ({ slot_name: commandTextData != null ? getModuleDisplayLocation( - commandTextData, + commandTextData.modules ?? [], command.params.location.moduleId ) : null, @@ -105,7 +113,10 @@ export const getLoadCommandText = ({ } else if (adapterLoc != null && 'moduleId' in adapterLoc) { const moduleModel = commandTextData != null - ? getModuleModel(commandTextData, adapterLoc?.moduleId ?? '') + ? getModuleModel( + commandTextData.modules ?? [], + adapterLoc?.moduleId ?? '' + ) : null const moduleName = moduleModel != null ? getModuleDisplayName(moduleModel) : '' @@ -116,7 +127,7 @@ export const getLoadCommandText = ({ slot_name: commandTextData != null ? getModuleDisplayLocation( - commandTextData, + commandTextData.modules ?? [], adapterLoc?.moduleId ?? '' ) : null, @@ -144,7 +155,11 @@ export const getLoadCommandText = ({ const { labwareId } = command.params const labware = commandTextData != null - ? getLabwareName(commandTextData, labwareId) + ? getLabwareName({ + loadedLabwares: commandTextData?.labware ?? [], + labwareId, + allRunDefs, + }) : null return t('reloading_labware', { labware }) } @@ -153,11 +168,15 @@ export const getLoadCommandText = ({ return t('load_liquids_info_protocol_setup', { liquid: commandTextData != null - ? getLiquidDisplayName(commandTextData, liquidId) + ? getLiquidDisplayName(commandTextData.liquids ?? [], liquidId) : null, labware: commandTextData != null - ? getLabwareName(commandTextData, labwareId) + ? getLabwareName({ + loadedLabwares: commandTextData?.labware ?? [], + labwareId, + allRunDefs, + }) : null, }) } diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getMoveLabwareCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveLabwareCommandText.ts similarity index 58% rename from app/src/molecules/Command/hooks/useCommandTextString/utils/getMoveLabwareCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveLabwareCommandText.ts index 84468cf2776..67fe3d52aaf 100644 --- a/app/src/molecules/Command/hooks/useCommandTextString/utils/getMoveLabwareCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveLabwareCommandText.ts @@ -1,10 +1,10 @@ import { GRIPPER_WASTE_CHUTE_ADDRESSABLE_AREA } from '@opentrons/shared-data' +import { getFinalLabwareLocation } from './getFinalLabwareLocation' import { getLabwareName, getLabwareDisplayLocation, - getFinalLabwareLocation, -} from '../../../utils' +} from '/app/local-resources/labware' import type { MoveLabwareRunTimeCommand } from '@opentrons/shared-data' import type { HandlesCommands } from './types' @@ -26,16 +26,23 @@ export function getMoveLabwareCommandText({ allPreviousCommands != null ? getFinalLabwareLocation(labwareId, allPreviousCommands) : null - const newDisplayLocation = - commandTextData != null - ? getLabwareDisplayLocation( - commandTextData, - allRunDefs, - newLocation, - t, - robotType - ) - : null + + const oldDisplayLocation = getLabwareDisplayLocation({ + location: oldLocation, + robotType, + allRunDefs, + loadedLabwares: commandTextData?.labware ?? [], + loadedModules: commandTextData?.modules ?? [], + t, + }) + const newDisplayLocation = getLabwareDisplayLocation({ + location: newLocation, + robotType, + allRunDefs, + loadedLabwares: commandTextData?.labware ?? [], + loadedModules: commandTextData?.modules ?? [], + t, + }) const location = newDisplayLocation?.includes( GRIPPER_WASTE_CHUTE_ADDRESSABLE_AREA @@ -47,35 +54,25 @@ export function getMoveLabwareCommandText({ ? t('move_labware_using_gripper', { labware: commandTextData != null - ? getLabwareName(commandTextData, labwareId) - : null, - old_location: - oldLocation != null && commandTextData != null - ? getLabwareDisplayLocation( - commandTextData, + ? getLabwareName({ allRunDefs, - oldLocation, - t, - robotType - ) - : '', + loadedLabwares: commandTextData.labware ?? [], + labwareId, + }) + : null, + old_location: oldDisplayLocation, new_location: location, }) : t('move_labware_manually', { labware: commandTextData != null - ? getLabwareName(commandTextData, labwareId) - : null, - old_location: - oldLocation != null && commandTextData != null - ? getLabwareDisplayLocation( - commandTextData, + ? getLabwareName({ allRunDefs, - oldLocation, - t, - robotType - ) - : '', + loadedLabwares: commandTextData.labware ?? [], + labwareId, + }) + : null, + old_location: oldDisplayLocation, new_location: location, }) } diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getMoveRelativeCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveRelativeCommandText.ts similarity index 100% rename from app/src/molecules/Command/hooks/useCommandTextString/utils/getMoveRelativeCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveRelativeCommandText.ts diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getMoveToAddressabelAreaForDropTipCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveToAddressabelAreaForDropTipCommandText.ts similarity index 87% rename from app/src/molecules/Command/hooks/useCommandTextString/utils/getMoveToAddressabelAreaForDropTipCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveToAddressabelAreaForDropTipCommandText.ts index 5788fbbdf62..f7cc0f42e1f 100644 --- a/app/src/molecules/Command/hooks/useCommandTextString/utils/getMoveToAddressabelAreaForDropTipCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveToAddressabelAreaForDropTipCommandText.ts @@ -1,4 +1,4 @@ -import { getAddressableAreaDisplayName } from '../../../utils' +import { getAddressableAreaDisplayName } from './getAddressableAreaDisplayName' import type { MoveToAddressableAreaForDropTipRunTimeCommand } from '@opentrons/shared-data/command' import type { HandlesCommands } from './types' diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getMoveToAddressableAreaCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveToAddressableAreaCommandText.ts similarity index 87% rename from app/src/molecules/Command/hooks/useCommandTextString/utils/getMoveToAddressableAreaCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveToAddressableAreaCommandText.ts index e8366120a23..749ef30f451 100644 --- a/app/src/molecules/Command/hooks/useCommandTextString/utils/getMoveToAddressableAreaCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveToAddressableAreaCommandText.ts @@ -1,4 +1,4 @@ -import { getAddressableAreaDisplayName } from '../../../utils' +import { getAddressableAreaDisplayName } from './getAddressableAreaDisplayName' import type { MoveToAddressableAreaRunTimeCommand } from '@opentrons/shared-data/command' import type { HandlesCommands } from './types' diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getMoveToCoordinatesCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveToCoordinatesCommandText.ts similarity index 100% rename from app/src/molecules/Command/hooks/useCommandTextString/utils/getMoveToCoordinatesCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveToCoordinatesCommandText.ts diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getMoveToSlotCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveToSlotCommandText.ts similarity index 100% rename from app/src/molecules/Command/hooks/useCommandTextString/utils/getMoveToSlotCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveToSlotCommandText.ts diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getMoveToWellCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveToWellCommandText.ts similarity index 63% rename from app/src/molecules/Command/hooks/useCommandTextString/utils/getMoveToWellCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveToWellCommandText.ts index c91d8431744..e3c8d6223be 100644 --- a/app/src/molecules/Command/hooks/useCommandTextString/utils/getMoveToWellCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getMoveToWellCommandText.ts @@ -1,10 +1,10 @@ import { - getFinalLabwareLocation, - getLabwareDisplayLocation, getLabwareName, -} from '../../../utils' + getLabwareDisplayLocation, +} from '/app/local-resources/labware' + +import { getFinalLabwareLocation } from './getFinalLabwareLocation' -import type { TFunction } from 'i18next' import type { MoveToWellRunTimeCommand } from '@opentrons/shared-data/command' import type { HandlesCommands } from './types' @@ -24,22 +24,25 @@ export function getMoveToWellCommandText({ allPreviousCommands != null ? getFinalLabwareLocation(labwareId, allPreviousCommands) : null - const displayLocation = - labwareLocation != null && commandTextData != null - ? getLabwareDisplayLocation( - commandTextData, - allRunDefs, - labwareLocation, - t as TFunction, - robotType - ) - : '' + + const displayLocation = getLabwareDisplayLocation({ + location: labwareLocation, + robotType, + allRunDefs, + loadedLabwares: commandTextData?.labware ?? [], + loadedModules: commandTextData?.modules ?? [], + t, + }) return t('move_to_well', { well_name: wellName, labware: commandTextData != null - ? getLabwareName(commandTextData, labwareId) + ? getLabwareName({ + loadedLabwares: commandTextData.labware ?? [], + labwareId, + allRunDefs, + }) : null, labware_location: displayLocation, }) diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/getPipetteNameOnMount.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getPipetteNameOnMount.ts new file mode 100644 index 00000000000..e4ae2519374 --- /dev/null +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getPipetteNameOnMount.ts @@ -0,0 +1,12 @@ +import { getLoadedPipette } from '/app/local-resources/instruments' + +import type { PipetteName } from '@opentrons/shared-data' +import type { LoadedPipettes } from '/app/local-resources/instruments/types' + +export function getPipetteNameOnMount( + loadedPipettes: LoadedPipettes, + mount: string +): PipetteName | null { + const loadedPipette = getLoadedPipette(loadedPipettes, mount) + return loadedPipette != null ? loadedPipette.pipetteName : null +} diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getPipettingCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getPipettingCommandText.ts similarity index 72% rename from app/src/molecules/Command/hooks/useCommandTextString/utils/getPipettingCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/getPipettingCommandText.ts index 27402ab148f..34ad5eae3a3 100644 --- a/app/src/molecules/Command/hooks/useCommandTextString/utils/getPipettingCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getPipettingCommandText.ts @@ -1,16 +1,16 @@ import { getLabwareDefURI } from '@opentrons/shared-data' -import { getLoadedLabware } from '../../../utils/accessors' +import { getFinalLabwareLocation } from './getFinalLabwareLocation' +import { getWellRange } from './getWellRange' + import { + getLabwareDefinitionsFromCommands, getLabwareName, + getLoadedLabware, getLabwareDisplayLocation, - getFinalLabwareLocation, - getWellRange, - getLabwareDefinitionsFromCommands, -} from '../../../utils' +} from '/app/local-resources/labware' import type { PipetteName, RunTimeCommand } from '@opentrons/shared-data' -import type { TFunction } from 'i18next' import type { GetCommandText } from '..' export const getPipettingCommandText = ({ @@ -40,16 +40,15 @@ export const getPipettingCommandText = ({ allPreviousCommands as RunTimeCommand[] ) : null - const displayLocation = - labwareLocation != null && commandTextData != null - ? getLabwareDisplayLocation( - commandTextData, - allRunDefs, - labwareLocation, - t as TFunction, - robotType - ) - : '' + + const displayLocation = getLabwareDisplayLocation({ + location: labwareLocation, + robotType, + allRunDefs, + loadedLabwares: commandTextData?.labware ?? [], + loadedModules: commandTextData?.modules ?? [], + t, + }) switch (command?.commandType) { case 'aspirate': { @@ -58,7 +57,11 @@ export const getPipettingCommandText = ({ well_name: wellName, labware: commandTextData != null - ? getLabwareName(commandTextData, labwareId) + ? getLabwareName({ + loadedLabwares: commandTextData.labware ?? [], + labwareId, + allRunDefs, + }) : null, labware_location: displayLocation, volume, @@ -72,7 +75,11 @@ export const getPipettingCommandText = ({ well_name: wellName, labware: commandTextData != null - ? getLabwareName(commandTextData, labwareId) + ? getLabwareName({ + loadedLabwares: commandTextData.labware ?? [], + labwareId, + allRunDefs, + }) : null, labware_location: displayLocation, volume, @@ -83,7 +90,11 @@ export const getPipettingCommandText = ({ well_name: wellName, labware: commandTextData != null - ? getLabwareName(commandTextData, labwareId) + ? getLabwareName({ + loadedLabwares: commandTextData.labware ?? [], + labwareId, + allRunDefs, + }) : null, labware_location: displayLocation, volume, @@ -96,7 +107,11 @@ export const getPipettingCommandText = ({ well_name: wellName, labware: commandTextData != null - ? getLabwareName(commandTextData, labwareId) + ? getLabwareName({ + loadedLabwares: commandTextData.labware ?? [], + labwareId, + allRunDefs, + }) : null, labware_location: displayLocation, flow_rate: flowRate, @@ -105,7 +120,7 @@ export const getPipettingCommandText = ({ case 'dropTip': { const loadedLabware = commandTextData != null - ? getLoadedLabware(commandTextData, labwareId) + ? getLoadedLabware(commandTextData.labware ?? [], labwareId) : null const labwareDefinitions = commandTextData != null @@ -121,7 +136,11 @@ export const getPipettingCommandText = ({ well_name: wellName, labware: commandTextData != null - ? getLabwareName(commandTextData, labwareId) + ? getLabwareName({ + loadedLabwares: commandTextData.labware ?? [], + labwareId, + allRunDefs, + }) : null, labware_location: displayLocation, }) @@ -129,7 +148,11 @@ export const getPipettingCommandText = ({ well_name: wellName, labware: commandTextData != null - ? getLabwareName(commandTextData, labwareId) + ? getLabwareName({ + loadedLabwares: commandTextData.labware ?? [], + labwareId, + allRunDefs, + }) : null, }) } @@ -153,7 +176,11 @@ export const getPipettingCommandText = ({ : null, labware: commandTextData != null - ? getLabwareName(commandTextData, labwareId) + ? getLabwareName({ + loadedLabwares: commandTextData.labware ?? [], + labwareId, + allRunDefs, + }) : null, labware_location: displayLocation, }) diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getPrepareToAspirateCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getPrepareToAspirateCommandText.ts similarity index 100% rename from app/src/molecules/Command/hooks/useCommandTextString/utils/getPrepareToAspirateCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/getPrepareToAspirateCommandText.ts diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getRailLightsCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getRailLightsCommandText.ts similarity index 100% rename from app/src/molecules/Command/hooks/useCommandTextString/utils/getRailLightsCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/getRailLightsCommandText.ts diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getTCRunExtendedProfileCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getTCRunExtendedProfileCommandText.ts similarity index 100% rename from app/src/molecules/Command/hooks/useCommandTextString/utils/getTCRunExtendedProfileCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/getTCRunExtendedProfileCommandText.ts diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getTCRunProfileCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getTCRunProfileCommandText.ts similarity index 100% rename from app/src/molecules/Command/hooks/useCommandTextString/utils/getTCRunProfileCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/getTCRunProfileCommandText.ts diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getTemperatureCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getTemperatureCommandText.ts similarity index 100% rename from app/src/molecules/Command/hooks/useCommandTextString/utils/getTemperatureCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/getTemperatureCommandText.ts diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getUnknownCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getUnknownCommandText.ts similarity index 100% rename from app/src/molecules/Command/hooks/useCommandTextString/utils/getUnknownCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/getUnknownCommandText.ts diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getWaitForDurationCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getWaitForDurationCommandText.ts similarity index 100% rename from app/src/molecules/Command/hooks/useCommandTextString/utils/getWaitForDurationCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/getWaitForDurationCommandText.ts diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getWaitForResumeCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getWaitForResumeCommandText.ts similarity index 100% rename from app/src/molecules/Command/hooks/useCommandTextString/utils/getWaitForResumeCommandText.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/getWaitForResumeCommandText.ts diff --git a/app/src/molecules/Command/utils/getWellRange.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/getWellRange.ts similarity index 100% rename from app/src/molecules/Command/utils/getWellRange.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/getWellRange.ts diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/index.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/index.ts similarity index 100% rename from app/src/molecules/Command/hooks/useCommandTextString/utils/index.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/index.ts diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/types.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/types.ts similarity index 100% rename from app/src/molecules/Command/hooks/useCommandTextString/utils/types.ts rename to app/src/local-resources/commands/hooks/useCommandTextString/utils/types.ts diff --git a/app/src/local-resources/commands/index.ts b/app/src/local-resources/commands/index.ts new file mode 100644 index 00000000000..02b71828cfa --- /dev/null +++ b/app/src/local-resources/commands/index.ts @@ -0,0 +1,4 @@ +export * from './hooks' +export * from './utils' + +export * from './types' diff --git a/app/src/molecules/Command/types.ts b/app/src/local-resources/commands/types.ts similarity index 100% rename from app/src/molecules/Command/types.ts rename to app/src/local-resources/commands/types.ts diff --git a/app/src/molecules/Command/utils/getCommandTextData.ts b/app/src/local-resources/commands/utils/getCommandTextData.ts similarity index 88% rename from app/src/molecules/Command/utils/getCommandTextData.ts rename to app/src/local-resources/commands/utils/getCommandTextData.ts index cfa8c6961ee..2750cd0d074 100644 --- a/app/src/molecules/Command/utils/getCommandTextData.ts +++ b/app/src/local-resources/commands/utils/getCommandTextData.ts @@ -4,7 +4,7 @@ import type { ProtocolAnalysisOutput, RunTimeCommand, } from '@opentrons/shared-data' -import type { CommandTextData } from '../types' +import type { CommandTextData } from '/app/local-resources/commands/types' export function getCommandTextData( protocolData: diff --git a/app/src/local-resources/commands/utils/index.ts b/app/src/local-resources/commands/utils/index.ts new file mode 100644 index 00000000000..7aa84d14de5 --- /dev/null +++ b/app/src/local-resources/commands/utils/index.ts @@ -0,0 +1 @@ +export * from './getCommandTextData' diff --git a/app/src/local-resources/instruments/hooks.ts b/app/src/local-resources/instruments/hooks.ts deleted file mode 100644 index 713dd6f1c83..00000000000 --- a/app/src/local-resources/instruments/hooks.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { - getGripperDisplayName, - getPipetteModelSpecs, - getPipetteNameSpecs, - getPipetteSpecsV2, - GRIPPER_MODELS, -} from '@opentrons/shared-data' -import { useIsOEMMode } from '/app/resources/robot-settings/hooks' - -import type { - GripperModel, - PipetteModel, - PipetteModelSpecs, - PipetteName, - PipetteNameSpecs, - PipetteV2Specs, -} from '@opentrons/shared-data' - -export function usePipetteNameSpecs( - name: PipetteName -): PipetteNameSpecs | null { - const isOEMMode = useIsOEMMode() - const pipetteNameSpecs = getPipetteNameSpecs(name) - - if (pipetteNameSpecs == null) return null - - const brandedDisplayName = pipetteNameSpecs.displayName - const anonymizedDisplayName = pipetteNameSpecs.displayName.replace( - 'Flex ', - '' - ) - - const displayName = isOEMMode ? anonymizedDisplayName : brandedDisplayName - - return { ...pipetteNameSpecs, displayName } -} - -export function usePipetteModelSpecs( - model: PipetteModel -): PipetteModelSpecs | null { - const modelSpecificFields = getPipetteModelSpecs(model) - const pipetteNameSpecs = usePipetteNameSpecs( - modelSpecificFields?.name as PipetteName - ) - - if (modelSpecificFields == null || pipetteNameSpecs == null) return null - - return { ...modelSpecificFields, displayName: pipetteNameSpecs.displayName } -} - -export function usePipetteSpecsV2( - name?: PipetteName | PipetteModel -): PipetteV2Specs | null { - const isOEMMode = useIsOEMMode() - const pipetteSpecs = getPipetteSpecsV2(name) - - if (pipetteSpecs == null) return null - - const brandedDisplayName = pipetteSpecs.displayName - const anonymizedDisplayName = pipetteSpecs.displayName.replace('Flex ', '') - - const displayName = isOEMMode ? anonymizedDisplayName : brandedDisplayName - - return { ...pipetteSpecs, displayName } -} - -export function useGripperDisplayName(gripperModel: GripperModel): string { - const isOEMMode = useIsOEMMode() - - let brandedDisplayName = '' - - // check to only call display name helper for a gripper model - if (GRIPPER_MODELS.includes(gripperModel)) { - brandedDisplayName = getGripperDisplayName(gripperModel) - } - - const anonymizedDisplayName = brandedDisplayName.replace('Flex ', '') - - return isOEMMode ? anonymizedDisplayName : brandedDisplayName -} diff --git a/app/src/local-resources/instruments/hooks/index.ts b/app/src/local-resources/instruments/hooks/index.ts new file mode 100644 index 00000000000..6cfd0af2293 --- /dev/null +++ b/app/src/local-resources/instruments/hooks/index.ts @@ -0,0 +1,5 @@ +export * from './useGripperDisplayName' +export * from './useHomePipettes' +export * from './usePipetteModelSpecs' +export * from './usePipetteNameSpecs' +export * from './usePipetteSpecsv2' diff --git a/app/src/local-resources/instruments/hooks/useGripperDisplayName.ts b/app/src/local-resources/instruments/hooks/useGripperDisplayName.ts new file mode 100644 index 00000000000..fd1b8262a79 --- /dev/null +++ b/app/src/local-resources/instruments/hooks/useGripperDisplayName.ts @@ -0,0 +1,19 @@ +import { getGripperDisplayName, GRIPPER_MODELS } from '@opentrons/shared-data' +import { useIsOEMMode } from '/app/resources/robot-settings' + +import type { GripperModel } from '@opentrons/shared-data' + +export function useGripperDisplayName(gripperModel: GripperModel): string { + const isOEMMode = useIsOEMMode() + + let brandedDisplayName = '' + + // check to only call display name helper for a gripper model + if (GRIPPER_MODELS.includes(gripperModel)) { + brandedDisplayName = getGripperDisplayName(gripperModel) + } + + const anonymizedDisplayName = brandedDisplayName.replace('Flex ', '') + + return isOEMMode ? anonymizedDisplayName : brandedDisplayName +} diff --git a/app/src/organisms/DropTipWizardFlows/hooks/useHomePipettes.ts b/app/src/local-resources/instruments/hooks/useHomePipettes.ts similarity index 90% rename from app/src/organisms/DropTipWizardFlows/hooks/useHomePipettes.ts rename to app/src/local-resources/instruments/hooks/useHomePipettes.ts index c0e58ef5bb5..da139c14651 100644 --- a/app/src/organisms/DropTipWizardFlows/hooks/useHomePipettes.ts +++ b/app/src/local-resources/instruments/hooks/useHomePipettes.ts @@ -1,12 +1,13 @@ import { useRobotControlCommands } from '/app/resources/maintenance_runs' import type { CreateCommand } from '@opentrons/shared-data' + import type { UseRobotControlCommandsProps, UseRobotControlCommandsResult, } from '/app/resources/maintenance_runs' -interface UseHomePipettesResult { +export interface UseHomePipettesResult { isHoming: UseRobotControlCommandsResult['isExecuting'] homePipettes: UseRobotControlCommandsResult['executeCommands'] } @@ -15,7 +16,7 @@ export type UseHomePipettesProps = Pick< UseRobotControlCommandsProps, 'pipetteInfo' | 'onSettled' > -// TODO(jh, 09-12-24): Find a better place for this hook to live. + // Home pipettes except for plungers. export function useHomePipettes( props: UseHomePipettesProps diff --git a/app/src/local-resources/instruments/hooks/usePipetteModelSpecs.ts b/app/src/local-resources/instruments/hooks/usePipetteModelSpecs.ts new file mode 100644 index 00000000000..afbc2f205fa --- /dev/null +++ b/app/src/local-resources/instruments/hooks/usePipetteModelSpecs.ts @@ -0,0 +1,24 @@ +import { getPipetteModelSpecs } from '@opentrons/shared-data' + +import { usePipetteNameSpecs } from './usePipetteNameSpecs' + +import type { + PipetteModel, + PipetteModelSpecs, + PipetteName, +} from '@opentrons/shared-data' + +export function usePipetteModelSpecs( + model: PipetteModel +): PipetteModelSpecs | null { + const modelSpecificFields = getPipetteModelSpecs(model) + const pipetteNameSpecs = usePipetteNameSpecs( + modelSpecificFields?.name as PipetteName + ) + + if (modelSpecificFields == null || pipetteNameSpecs == null) { + return null + } + + return { ...modelSpecificFields, displayName: pipetteNameSpecs.displayName } +} diff --git a/app/src/local-resources/instruments/hooks/usePipetteNameSpecs.ts b/app/src/local-resources/instruments/hooks/usePipetteNameSpecs.ts new file mode 100644 index 00000000000..85a29b2fef7 --- /dev/null +++ b/app/src/local-resources/instruments/hooks/usePipetteNameSpecs.ts @@ -0,0 +1,26 @@ +import { getPipetteNameSpecs } from '@opentrons/shared-data' + +import { useIsOEMMode } from '/app/resources/robot-settings' + +import type { PipetteName, PipetteNameSpecs } from '@opentrons/shared-data' + +export function usePipetteNameSpecs( + name: PipetteName +): PipetteNameSpecs | null { + const isOEMMode = useIsOEMMode() + const pipetteNameSpecs = getPipetteNameSpecs(name) + + if (pipetteNameSpecs == null) { + return null + } + + const brandedDisplayName = pipetteNameSpecs.displayName + const anonymizedDisplayName = pipetteNameSpecs.displayName.replace( + 'Flex ', + '' + ) + + const displayName = isOEMMode ? anonymizedDisplayName : brandedDisplayName + + return { ...pipetteNameSpecs, displayName } +} diff --git a/app/src/local-resources/instruments/hooks/usePipetteSpecsv2.ts b/app/src/local-resources/instruments/hooks/usePipetteSpecsv2.ts new file mode 100644 index 00000000000..951c1d857f1 --- /dev/null +++ b/app/src/local-resources/instruments/hooks/usePipetteSpecsv2.ts @@ -0,0 +1,27 @@ +import { getPipetteSpecsV2 } from '@opentrons/shared-data' + +import { useIsOEMMode } from '/app/resources/robot-settings' + +import type { + PipetteModel, + PipetteName, + PipetteV2Specs, +} from '@opentrons/shared-data' + +export function usePipetteSpecsV2( + name?: PipetteName | PipetteModel +): PipetteV2Specs | null { + const isOEMMode = useIsOEMMode() + const pipetteSpecs = getPipetteSpecsV2(name) + + if (pipetteSpecs == null) { + return null + } + + const brandedDisplayName = pipetteSpecs.displayName + const anonymizedDisplayName = pipetteSpecs.displayName.replace('Flex ', '') + + const displayName = isOEMMode ? anonymizedDisplayName : brandedDisplayName + + return { ...pipetteSpecs, displayName } +} diff --git a/app/src/local-resources/instruments/types.ts b/app/src/local-resources/instruments/types.ts new file mode 100644 index 00000000000..3c4a313f9e5 --- /dev/null +++ b/app/src/local-resources/instruments/types.ts @@ -0,0 +1,3 @@ +import type { LoadedPipette } from '@opentrons/shared-data' + +export type LoadedPipettes = LoadedPipette[] | Record diff --git a/app/src/local-resources/instruments/utils.ts b/app/src/local-resources/instruments/utils.ts index ef92e580725..c93ad39b078 100644 --- a/app/src/local-resources/instruments/utils.ts +++ b/app/src/local-resources/instruments/utils.ts @@ -1,3 +1,6 @@ +import type { LoadedPipette } from '@opentrons/shared-data' +import type { LoadedPipettes } from '/app/local-resources/instruments/types' + export interface IsPartialTipConfigParams { channel: 1 | 8 | 96 activeNozzleCount: number @@ -16,3 +19,13 @@ export function isPartialTipConfig({ return activeNozzleCount !== 96 } } + +export function getLoadedPipette( + loadedPipettes: LoadedPipettes, + mount: string +): LoadedPipette | undefined { + // NOTE: old analysis contains a object dictionary of pipette entities by id, this case is supported for backwards compatibility purposes + return Array.isArray(loadedPipettes) + ? loadedPipettes.find(l => l.mount === mount) + : loadedPipettes[mount] +} diff --git a/app/src/local-resources/labware/types.ts b/app/src/local-resources/labware/types.ts index 99ea299573d..da55c9d7004 100644 --- a/app/src/local-resources/labware/types.ts +++ b/app/src/local-resources/labware/types.ts @@ -3,6 +3,7 @@ import type { LabwareWellShapeProperties, LabwareWellGroupMetadata, LabwareBrand, + LoadedLabware, } from '@opentrons/shared-data' export interface LabwareDefAndDate { @@ -35,3 +36,5 @@ export interface LabwareWellGroupProperties { metadata: LabwareWellGroupMetadata brand: LabwareBrand | null } + +export type LoadedLabwares = LoadedLabware[] | Record diff --git a/app/src/local-resources/labware/utils/__tests__/getLabwareDisplayLocation.test.tsx b/app/src/local-resources/labware/utils/__tests__/getLabwareDisplayLocation.test.tsx new file mode 100644 index 00000000000..22e02478ded --- /dev/null +++ b/app/src/local-resources/labware/utils/__tests__/getLabwareDisplayLocation.test.tsx @@ -0,0 +1,173 @@ +import { describe, it, expect, vi } from 'vitest' +import { screen } from '@testing-library/react' +import { useTranslation } from 'react-i18next' + +import { + FLEX_ROBOT_TYPE, + getModuleDisplayName, + getModuleType, + getOccludedSlotCountForModule, + getLabwareDefURI, + getLabwareDisplayName, +} from '@opentrons/shared-data' + +import { renderWithProviders } from '/app/__testing-utils__' +import { i18n } from '/app/i18n' +import { getLabwareDisplayLocation } from '/app/local-resources/labware' +import { + getModuleModel, + getModuleDisplayLocation, +} from '/app/local-resources/modules' + +import type { ComponentProps } from 'react' +import type { LabwareLocation } from '@opentrons/shared-data' + +vi.mock('@opentrons/shared-data', async () => { + const actual = await vi.importActual('@opentrons/shared-data') + return { + ...actual, + getModuleDisplayName: vi.fn(), + getModuleType: vi.fn(), + getOccludedSlotCountForModule: vi.fn(), + getLabwareDefURI: vi.fn(), + getLabwareDisplayName: vi.fn(), + } +}) + +vi.mock('/app/local-resources/modules', () => ({ + getModuleModel: vi.fn(), + getModuleDisplayLocation: vi.fn(), +})) + +const TestWrapper = ({ + location, + params, +}: { + location: LabwareLocation | null + params: any +}) => { + const { t } = useTranslation('protocol_command_text') + const displayLocation = getLabwareDisplayLocation({ ...params, location, t }) + return

{displayLocation}
+} + +const render = (props: ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +describe('getLabwareDisplayLocation with translations', () => { + const defaultParams = { + loadedLabwares: [], + loadedModules: [], + robotType: FLEX_ROBOT_TYPE, + allRunDefs: [], + } + + it('should return an empty string for null location', () => { + render({ location: null, params: defaultParams }) + expect(screen.queryByText(/.+/)).toBeNull() + }) + + it('should return "off deck" for offDeck location', () => { + render({ location: 'offDeck', params: defaultParams }) + + screen.getByText('off deck') + }) + + it('should return a slot name for slot location', () => { + render({ location: { slotName: 'A1' }, params: defaultParams }) + + screen.getByText('Slot A1') + }) + + it('should return an addressable area name for an addressable area location', () => { + render({ location: { addressableAreaName: 'B2' }, params: defaultParams }) + + screen.getByText('Slot B2') + }) + + it('should return a module location for a module location', () => { + const mockModuleModel = 'temperatureModuleV2' + vi.mocked(getModuleModel).mockReturnValue(mockModuleModel) + vi.mocked(getModuleDisplayLocation).mockReturnValue('3') + vi.mocked(getModuleDisplayName).mockReturnValue('Temperature Module') + vi.mocked(getModuleType).mockReturnValue('temperatureModuleType') + vi.mocked(getOccludedSlotCountForModule).mockReturnValue(1) + + render({ location: { moduleId: 'temp123' }, params: defaultParams }) + + screen.getByText('Temperature Module in Slot 3') + }) + + it('should return an adapter location for an adapter location', () => { + const mockLoadedLabwares = [ + { + id: 'adapter123', + definitionUri: 'adapter-uri', + location: { slotName: 'D1' }, + }, + ] + const mockAllRunDefs = [ + { uri: 'adapter-uri', metadata: { displayName: 'Mock Adapter' } }, + ] + vi.mocked(getLabwareDefURI).mockReturnValue('adapter-uri') + vi.mocked(getLabwareDisplayName).mockReturnValue('Mock Adapter') + + render({ + location: { labwareId: 'adapter123' }, + params: { + ...defaultParams, + loadedLabwares: mockLoadedLabwares, + allRunDefs: mockAllRunDefs, + detailLevel: 'full', + }, + }) + + screen.getByText('Mock Adapter in D1') + }) + + it('should return a slot-only location when detailLevel is "slot-only"', () => { + render({ + location: { slotName: 'C1' }, + params: { ...defaultParams, detailLevel: 'slot-only' }, + }) + + screen.getByText('Slot C1') + }) + + it('should handle an adapter on module location when the detail level is full', () => { + const mockLoadedLabwares = [ + { + id: 'adapter123', + definitionUri: 'adapter-uri', + location: { moduleId: 'temp123' }, + }, + ] + const mockLoadedModules = [{ id: 'temp123', model: 'temperatureModuleV2' }] + const mockAllRunDefs = [ + { uri: 'adapter-uri', metadata: { displayName: 'Mock Adapter' } }, + ] + + vi.mocked(getLabwareDefURI).mockReturnValue('adapter-uri') + vi.mocked(getLabwareDisplayName).mockReturnValue('Mock Adapter') + vi.mocked(getModuleDisplayLocation).mockReturnValue('2') + vi.mocked(getModuleDisplayName).mockReturnValue('Temperature Module') + vi.mocked(getModuleType).mockReturnValue('temperatureModuleType') + vi.mocked(getOccludedSlotCountForModule).mockReturnValue(1) + + render({ + location: { labwareId: 'adapter123' }, + params: { + ...defaultParams, + loadedLabwares: mockLoadedLabwares, + loadedModules: mockLoadedModules, + allRunDefs: mockAllRunDefs, + detailLevel: 'full', + }, + }) + + screen.getByText('Mock Adapter on Temperature Module in 2') + }) +}) diff --git a/app/src/local-resources/labware/utils/getAllDefinitions.ts b/app/src/local-resources/labware/utils/getAllDefinitions.ts index db25fde06a1..24a28ef44e1 100644 --- a/app/src/local-resources/labware/utils/getAllDefinitions.ts +++ b/app/src/local-resources/labware/utils/getAllDefinitions.ts @@ -1,9 +1,12 @@ import groupBy from 'lodash/groupBy' + import { LABWAREV2_DO_NOT_LIST } from '@opentrons/shared-data' -import type { LabwareDefinition2 } from '@opentrons/shared-data' + import { getAllDefs } from './getAllDefs' -export const getOnlyLatestDefs = ( +import type { LabwareDefinition2 } from '@opentrons/shared-data' + +const getOnlyLatestDefs = ( labwareList: LabwareDefinition2[] ): LabwareDefinition2[] => { // group by namespace + loadName diff --git a/app/src/local-resources/labware/utils/getAllDefs.ts b/app/src/local-resources/labware/utils/getAllDefs.ts index 58ccbae8b74..307cb18b014 100644 --- a/app/src/local-resources/labware/utils/getAllDefs.ts +++ b/app/src/local-resources/labware/utils/getAllDefs.ts @@ -1,4 +1,5 @@ import { getAllDefinitions } from '@opentrons/shared-data' + import type { LabwareDefinition2 } from '@opentrons/shared-data' export function getAllDefs(): LabwareDefinition2[] { diff --git a/app/src/molecules/Command/utils/getLabwareDefinitionsFromCommands.ts b/app/src/local-resources/labware/utils/getLabwareDefinitionsFromCommands.ts similarity index 94% rename from app/src/molecules/Command/utils/getLabwareDefinitionsFromCommands.ts rename to app/src/local-resources/labware/utils/getLabwareDefinitionsFromCommands.ts index 238302e78e5..6016b0c5dd8 100644 --- a/app/src/molecules/Command/utils/getLabwareDefinitionsFromCommands.ts +++ b/app/src/local-resources/labware/utils/getLabwareDefinitionsFromCommands.ts @@ -1,6 +1,8 @@ -import type { LabwareDefinition2, RunTimeCommand } from '@opentrons/shared-data' import { getLabwareDefURI } from '@opentrons/shared-data' +import type { LabwareDefinition2, RunTimeCommand } from '@opentrons/shared-data' + +// Note: This is an O(n) operation. export function getLabwareDefinitionsFromCommands( commands: RunTimeCommand[] ): LabwareDefinition2[] { diff --git a/app/src/local-resources/labware/utils/getLabwareDisplayLocation.ts b/app/src/local-resources/labware/utils/getLabwareDisplayLocation.ts new file mode 100644 index 00000000000..d70e6d19d42 --- /dev/null +++ b/app/src/local-resources/labware/utils/getLabwareDisplayLocation.ts @@ -0,0 +1,180 @@ +import { + getLabwareDefURI, + getLabwareDisplayName, + getModuleDisplayName, + getModuleType, + getOccludedSlotCountForModule, +} from '@opentrons/shared-data' + +import { + getModuleModel, + getModuleDisplayLocation, +} from '/app/local-resources/modules' + +import type { TFunction } from 'i18next' +import type { + LabwareDefinition2, + LabwareLocation, + RobotType, +} from '@opentrons/shared-data' +import type { LoadedLabwares } from '/app/local-resources/labware' +import type { LoadedModules } from '/app/local-resources/modules' + +interface LabwareDisplayLocationBaseParams { + location: LabwareLocation | null + loadedModules: LoadedModules + loadedLabwares: LoadedLabwares + robotType: RobotType + t: TFunction + isOnDevice?: boolean +} + +export interface LabwareDisplayLocationSlotOnly + extends LabwareDisplayLocationBaseParams { + detailLevel: 'slot-only' +} + +export interface LabwareDisplayLocationFull + extends LabwareDisplayLocationBaseParams { + detailLevel?: 'full' + allRunDefs: LabwareDefinition2[] +} + +export type LabwareDisplayLocationParams = + | LabwareDisplayLocationSlotOnly + | LabwareDisplayLocationFull + +// detailLevel applies to nested labware. If 'full', return copy that includes the actual peripheral that nests the +// labware, ex, "in module XYZ in slot C1". +// If 'slot-only', return only the slot name, ex "in slot C1". +export function getLabwareDisplayLocation( + params: LabwareDisplayLocationParams +): string { + const { + loadedLabwares, + loadedModules, + location, + robotType, + t, + isOnDevice = false, + detailLevel = 'full', + } = params + + if (location == null) { + console.error('Cannot get labware display location. No location provided.') + return '' + } else if (location === 'offDeck') { + return t('off_deck') + } else if ('slotName' in location) { + return isOnDevice + ? location.slotName + : t('slot', { slot_name: location.slotName }) + } else if ('addressableAreaName' in location) { + return isOnDevice + ? location.addressableAreaName + : t('slot', { slot_name: location.addressableAreaName }) + } else if ('moduleId' in location) { + const moduleModel = getModuleModel(loadedModules, location.moduleId) + if (moduleModel == null) { + console.error('labware is located on an unknown module model') + return '' + } + const slotName = getModuleDisplayLocation(loadedModules, location.moduleId) + + if (detailLevel === 'slot-only') { + return t('slot', { slot_name: slotName }) + } + + return isOnDevice + ? `${getModuleDisplayName(moduleModel)}, ${slotName}` + : t('module_in_slot', { + count: getOccludedSlotCountForModule( + getModuleType(moduleModel), + robotType + ), + module: getModuleDisplayName(moduleModel), + slot_name: slotName, + }) + } else if ('labwareId' in location) { + if (!Array.isArray(loadedLabwares)) { + console.error('Cannot get display location from loaded labwares object') + return '' + } + const adapter = loadedLabwares.find(lw => lw.id === location.labwareId) + + if (adapter == null) { + console.error('labware is located on an unknown adapter') + return '' + } else if (detailLevel === 'slot-only') { + return getLabwareDisplayLocation({ + ...params, + location: adapter.location, + }) + } else if (detailLevel === 'full') { + const { allRunDefs } = params as LabwareDisplayLocationFull + const adapterDef = allRunDefs.find( + def => getLabwareDefURI(def) === adapter?.definitionUri + ) + const adapterDisplayName = + adapterDef != null ? getLabwareDisplayName(adapterDef) : '' + + if (adapter.location === 'offDeck') { + return t('off_deck') + } else if ( + 'slotName' in adapter.location || + 'addressableAreaName' in adapter.location + ) { + const slotName = + 'slotName' in adapter.location + ? adapter.location.slotName + : adapter.location.addressableAreaName + return t('adapter_in_slot', { + adapter: adapterDisplayName, + slot: slotName, + }) + } else if ('moduleId' in adapter.location) { + const moduleIdUnderAdapter = adapter.location.moduleId + + if (!Array.isArray(loadedModules)) { + console.error( + 'Cannot get display location from loaded modules object' + ) + return '' + } + + const moduleModel = loadedModules.find( + module => module.id === moduleIdUnderAdapter + )?.model + if (moduleModel == null) { + console.error('labware is located on an adapter on an unknown module') + return '' + } + const slotName = getModuleDisplayLocation( + loadedModules, + adapter.location.moduleId + ) + + return t('adapter_in_mod_in_slot', { + count: getOccludedSlotCountForModule( + getModuleType(moduleModel), + robotType + ), + module: getModuleDisplayName(moduleModel), + adapter: adapterDisplayName, + slot: slotName, + }) + } else { + console.error( + 'Unhandled adapter location for determining display location.' + ) + return '' + } + } else { + console.error('Unhandled detail level for determining display location.') + return '' + } + } else { + console.error('display location could not be established: ', location) + return '' + } +} diff --git a/app/src/molecules/Command/utils/getLabwareName.ts b/app/src/local-resources/labware/utils/getLabwareName.ts similarity index 50% rename from app/src/molecules/Command/utils/getLabwareName.ts rename to app/src/local-resources/labware/utils/getLabwareName.ts index 03c6feb1367..af51fbc5fbc 100644 --- a/app/src/molecules/Command/utils/getLabwareName.ts +++ b/app/src/local-resources/labware/utils/getLabwareName.ts @@ -1,19 +1,28 @@ -import { getLoadedLabware } from './accessors' - import { getLabwareDefURI, getLabwareDisplayName } from '@opentrons/shared-data' -import { getLabwareDefinitionsFromCommands } from './getLabwareDefinitionsFromCommands' -import type { CommandTextData } from '../types' + +import { getLoadedLabware } from './getLoadedLabware' + +import type { LabwareDefinition2 } from '@opentrons/shared-data' +import type { LoadedLabwares } from '/app/local-resources/labware' const FIXED_TRASH_DEF_URIS = [ 'opentrons/opentrons_1_trash_850ml_fixed/1', 'opentrons/opentrons_1_trash_1100ml_fixed/1', 'opentrons/opentrons_1_trash_3200ml_fixed/1', ] -export function getLabwareName( - commandTextData: CommandTextData, + +export interface GetLabwareNameParams { + allRunDefs: LabwareDefinition2[] + loadedLabwares: LoadedLabwares labwareId: string -): string { - const loadedLabware = getLoadedLabware(commandTextData, labwareId) +} + +export function getLabwareName({ + allRunDefs, + loadedLabwares, + labwareId, +}: GetLabwareNameParams): string { + const loadedLabware = getLoadedLabware(loadedLabwares, labwareId) if (loadedLabware == null) { return '' } else if (FIXED_TRASH_DEF_URIS.includes(loadedLabware.definitionUri)) { @@ -21,9 +30,9 @@ export function getLabwareName( } else if (loadedLabware.displayName != null) { return loadedLabware.displayName } else { - const labwareDef = getLabwareDefinitionsFromCommands( - commandTextData.commands - ).find(def => getLabwareDefURI(def) === loadedLabware.definitionUri) + const labwareDef = allRunDefs.find( + def => getLabwareDefURI(def) === loadedLabware.definitionUri + ) return labwareDef != null ? getLabwareDisplayName(labwareDef) : '' } } diff --git a/app/src/local-resources/labware/utils/getLoadedLabware.ts b/app/src/local-resources/labware/utils/getLoadedLabware.ts new file mode 100644 index 00000000000..efd6981837a --- /dev/null +++ b/app/src/local-resources/labware/utils/getLoadedLabware.ts @@ -0,0 +1,12 @@ +import type { LoadedLabware } from '@opentrons/shared-data' +import type { LoadedLabwares } from '/app/local-resources/labware' + +export function getLoadedLabware( + loadedLabware: LoadedLabwares, + labwareId: string +): LoadedLabware | undefined { + // NOTE: old analysis contains a object dictionary of labware entities by id, this case is supported for backwards compatibility purposes + return Array.isArray(loadedLabware) + ? loadedLabware.find(l => l.id === labwareId) + : loadedLabware[labwareId] +} diff --git a/app/src/local-resources/labware/utils/index.ts b/app/src/local-resources/labware/utils/index.ts index 310ed3f065a..73879e0956b 100644 --- a/app/src/local-resources/labware/utils/index.ts +++ b/app/src/local-resources/labware/utils/index.ts @@ -1,2 +1,7 @@ export * from './getAllDefinitions' export * from './labwareImages' +export * from './getAllDefs' +export * from './getLabwareDefinitionsFromCommands' +export * from './getLabwareName' +export * from './getLoadedLabware' +export * from './getLabwareDisplayLocation' diff --git a/app/src/local-resources/modules/index.ts b/app/src/local-resources/modules/index.ts index e508be48e92..85dcaa20ea5 100644 --- a/app/src/local-resources/modules/index.ts +++ b/app/src/local-resources/modules/index.ts @@ -1,2 +1,3 @@ -export * from './getModulePrepCommands' -export * from './getModuleImage' +export * from './utils' + +export * from './types' diff --git a/app/src/local-resources/modules/types.ts b/app/src/local-resources/modules/types.ts new file mode 100644 index 00000000000..8317beac7e8 --- /dev/null +++ b/app/src/local-resources/modules/types.ts @@ -0,0 +1,3 @@ +import type { LoadedModule } from '@opentrons/shared-data' + +export type LoadedModules = LoadedModule[] | Record diff --git a/app/src/local-resources/modules/__tests__/getModuleImage.test.ts b/app/src/local-resources/modules/utils/__tests__/getModuleImage.test.ts similarity index 100% rename from app/src/local-resources/modules/__tests__/getModuleImage.test.ts rename to app/src/local-resources/modules/utils/__tests__/getModuleImage.test.ts diff --git a/app/src/local-resources/modules/utils/getLoadedModule.ts b/app/src/local-resources/modules/utils/getLoadedModule.ts new file mode 100644 index 00000000000..70047e095e6 --- /dev/null +++ b/app/src/local-resources/modules/utils/getLoadedModule.ts @@ -0,0 +1,12 @@ +import type { LoadedModule } from '@opentrons/shared-data' +import type { LoadedModules } from '/app/local-resources/modules/types' + +export function getLoadedModule( + loadedModules: LoadedModules, + moduleId: string +): LoadedModule | undefined { + // NOTE: old analysis contains a object dictionary of module entities by id, this case is supported for backwards compatibility purposes + return Array.isArray(loadedModules) + ? loadedModules.find(l => l.id === moduleId) + : loadedModules[moduleId] +} diff --git a/app/src/local-resources/modules/utils/getModuleDisplayLocation.ts b/app/src/local-resources/modules/utils/getModuleDisplayLocation.ts new file mode 100644 index 00000000000..665e31d8975 --- /dev/null +++ b/app/src/local-resources/modules/utils/getModuleDisplayLocation.ts @@ -0,0 +1,11 @@ +import { getLoadedModule } from './getLoadedModule' + +import type { LoadedModules } from '../types' + +export function getModuleDisplayLocation( + loadedModules: LoadedModules, + moduleId: string +): string { + const loadedModule = getLoadedModule(loadedModules, moduleId) + return loadedModule != null ? loadedModule.location.slotName : '' +} diff --git a/app/src/local-resources/modules/getModuleImage.ts b/app/src/local-resources/modules/utils/getModuleImage.ts similarity index 100% rename from app/src/local-resources/modules/getModuleImage.ts rename to app/src/local-resources/modules/utils/getModuleImage.ts diff --git a/app/src/local-resources/modules/utils/getModuleModel.ts b/app/src/local-resources/modules/utils/getModuleModel.ts new file mode 100644 index 00000000000..18302253499 --- /dev/null +++ b/app/src/local-resources/modules/utils/getModuleModel.ts @@ -0,0 +1,12 @@ +import { getLoadedModule } from './getLoadedModule' + +import type { ModuleModel } from '@opentrons/shared-data' +import type { LoadedModules } from '/app/local-resources/modules/types' + +export function getModuleModel( + loadedModules: LoadedModules, + moduleId: string +): ModuleModel | null { + const loadedModule = getLoadedModule(loadedModules, moduleId) + return loadedModule != null ? loadedModule.model : null +} diff --git a/app/src/local-resources/modules/getModulePrepCommands.ts b/app/src/local-resources/modules/utils/getModulePrepCommands.ts similarity index 100% rename from app/src/local-resources/modules/getModulePrepCommands.ts rename to app/src/local-resources/modules/utils/getModulePrepCommands.ts diff --git a/app/src/local-resources/modules/utils/index.ts b/app/src/local-resources/modules/utils/index.ts new file mode 100644 index 00000000000..7f3f558738d --- /dev/null +++ b/app/src/local-resources/modules/utils/index.ts @@ -0,0 +1,5 @@ +export * from './getLoadedModule' +export * from './getModuleDisplayLocation' +export * from './getModuleImage' +export * from './getModuleModel' +export * from './getModulePrepCommands' diff --git a/app/src/molecules/Command/Command.tsx b/app/src/molecules/Command/Command.tsx index 3b09498ca00..e71757313a4 100644 --- a/app/src/molecules/Command/Command.tsx +++ b/app/src/molecules/Command/Command.tsx @@ -1,3 +1,5 @@ +import { omit } from 'lodash' + import { Flex, JUSTIFY_CENTER, @@ -8,17 +10,18 @@ import { SPACING, RESPONSIVENESS, } from '@opentrons/components' + +import { CommandText } from './CommandText' +import { CommandIcon } from './CommandIcon' +import { Skeleton } from '/app/atoms/Skeleton' + import type { LabwareDefinition2, RobotType, RunTimeCommand, } from '@opentrons/shared-data' -import { CommandText } from './CommandText' -import { CommandIcon } from './CommandIcon' -import type { CommandTextData } from './types' -import { Skeleton } from '/app/atoms/Skeleton' +import type { CommandTextData } from '/app/local-resources/commands' import type { StyleProps } from '@opentrons/components' -import { omit } from 'lodash' export type CommandState = NonSkeletonCommandState | 'loading' export type NonSkeletonCommandState = 'current' | 'failed' | 'future' diff --git a/app/src/molecules/Command/CommandText.stories.tsx b/app/src/molecules/Command/CommandText.stories.tsx index f6b40e0a4e4..a76acd5fa92 100644 --- a/app/src/molecules/Command/CommandText.stories.tsx +++ b/app/src/molecules/Command/CommandText.stories.tsx @@ -2,6 +2,7 @@ import { Box } from '@opentrons/components' import { CommandText as CommandTextComponent } from '.' import type { RobotType } from '@opentrons/shared-data' import * as Fixtures from './__fixtures__' +import { getLabwareDefinitionsFromCommands } from '../../local-resources/labware' import type { Meta, StoryObj } from '@storybook/react' @@ -12,6 +13,10 @@ interface StorybookArgs { } function Wrapper(props: StorybookArgs): JSX.Element { + const allRunDefs = getLabwareDefinitionsFromCommands( + Fixtures.mockDoItAllTextData.commands + ) + return ( ) diff --git a/app/src/molecules/Command/CommandText.tsx b/app/src/molecules/Command/CommandText.tsx index 3e8b27d2522..e690115a88b 100644 --- a/app/src/molecules/Command/CommandText.tsx +++ b/app/src/molecules/Command/CommandText.tsx @@ -11,7 +11,7 @@ import { RESPONSIVENESS, } from '@opentrons/components' -import { useCommandTextString } from './hooks' +import { useCommandTextString } from '/app/local-resources/commands' import type { LabwareDefinition2, @@ -19,11 +19,11 @@ import type { RunTimeCommand, } from '@opentrons/shared-data' import type { StyleProps } from '@opentrons/components' -import type { CommandTextData } from './types' import type { GetTCRunExtendedProfileCommandTextResult, GetTCRunProfileCommandTextResult, -} from './hooks' + CommandTextData, +} from '/app/local-resources/commands' interface LegacySTProps { as?: React.ComponentProps['as'] diff --git a/app/src/molecules/Command/__fixtures__/index.ts b/app/src/molecules/Command/__fixtures__/index.ts index ba988a5197a..894208e8e68 100644 --- a/app/src/molecules/Command/__fixtures__/index.ts +++ b/app/src/molecules/Command/__fixtures__/index.ts @@ -2,7 +2,7 @@ import robotSideAnalysis from './mockRobotSideAnalysis.json' import doItAllAnalysis from './doItAllV10.json' import qiaseqAnalysis from './analysis_QIAseqFX24xv4_8.json' import type { CompletedProtocolAnalysis } from '@opentrons/shared-data' -import type { CommandTextData } from '../types' +import type { CommandTextData } from '/app/local-resources/commands' export const mockRobotSideAnalysis: CompletedProtocolAnalysis = robotSideAnalysis as CompletedProtocolAnalysis export const mockDoItAllAnalysis: CompletedProtocolAnalysis = doItAllAnalysis as CompletedProtocolAnalysis diff --git a/app/src/molecules/Command/__tests__/CommandText.test.tsx b/app/src/molecules/Command/__tests__/CommandText.test.tsx index 621208af0a9..6999063be38 100644 --- a/app/src/molecules/Command/__tests__/CommandText.test.tsx +++ b/app/src/molecules/Command/__tests__/CommandText.test.tsx @@ -10,7 +10,7 @@ import { renderWithProviders } from '/app/__testing-utils__' import { i18n } from '/app/i18n' import { CommandText } from '../CommandText' import { mockCommandTextData } from '../__fixtures__' -import { getCommandTextData } from '../utils/getCommandTextData' +import { getCommandTextData } from '/app/local-resources/commands/utils' import type { AspirateInPlaceRunTimeCommand, diff --git a/app/src/molecules/Command/index.ts b/app/src/molecules/Command/index.ts index b4223d82beb..9fc833953c8 100644 --- a/app/src/molecules/Command/index.ts +++ b/app/src/molecules/Command/index.ts @@ -2,6 +2,3 @@ export * from './CommandText' export * from './Command' export * from './CommandIcon' export * from './CommandIndex' -export * from './utils' -export * from './types' -export * from './hooks' diff --git a/app/src/molecules/Command/utils/accessors.ts b/app/src/molecules/Command/utils/accessors.ts deleted file mode 100644 index 651fb15769e..00000000000 --- a/app/src/molecules/Command/utils/accessors.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { RunData } from '@opentrons/api-client' -import type { - CompletedProtocolAnalysis, - LoadedLabware, - LoadedModule, - LoadedPipette, -} from '@opentrons/shared-data' -import type { CommandTextData } from '../types' - -export function getLoadedLabware( - commandTextData: CompletedProtocolAnalysis | RunData | CommandTextData, - labwareId: string -): LoadedLabware | undefined { - // NOTE: old analysis contains a object dictionary of labware entities by id, this case is supported for backwards compatibility purposes - return Array.isArray(commandTextData.labware) - ? commandTextData.labware.find(l => l.id === labwareId) - : commandTextData.labware[labwareId] -} -export function getLoadedPipette( - commandTextData: CommandTextData, - mount: string -): LoadedPipette | undefined { - // NOTE: old analysis contains a object dictionary of pipette entities by id, this case is supported for backwards compatibility purposes - return Array.isArray(commandTextData.pipettes) - ? commandTextData.pipettes.find(l => l.mount === mount) - : commandTextData.pipettes[mount] -} -export function getLoadedModule( - commandTextData: - | CompletedProtocolAnalysis - | RunData - | Omit, - moduleId: string -): LoadedModule | undefined { - // NOTE: old analysis contains a object dictionary of module entities by id, this case is supported for backwards compatibility purposes - return Array.isArray(commandTextData.modules) - ? commandTextData.modules.find(l => l.id === moduleId) - : commandTextData.modules[moduleId] -} diff --git a/app/src/molecules/Command/utils/getLabwareDisplayLocation.ts b/app/src/molecules/Command/utils/getLabwareDisplayLocation.ts deleted file mode 100644 index 60b03609c79..00000000000 --- a/app/src/molecules/Command/utils/getLabwareDisplayLocation.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { - getLabwareDefURI, - getLabwareDisplayName, - getModuleDisplayName, - getModuleType, - getOccludedSlotCountForModule, -} from '@opentrons/shared-data' - -import { getModuleDisplayLocation } from './getModuleDisplayLocation' -import { getModuleModel } from './getModuleModel' - -import type { TFunction } from 'i18next' -import type { - RobotType, - LabwareLocation, - LabwareDefinition2, -} from '@opentrons/shared-data' -import type { CommandTextData } from '../types' - -// TODO(jh, 10-14-24): Refactor this util and related copy utils out of Command. -export function getLabwareDisplayLocation( - commandTextData: Omit, - allRunDefs: LabwareDefinition2[], - location: LabwareLocation, - t: TFunction, - robotType: RobotType, - isOnDevice?: boolean -): string { - if (location === 'offDeck') { - return t('off_deck') - } else if ('slotName' in location) { - return isOnDevice - ? location.slotName - : t('slot', { slot_name: location.slotName }) - } else if ('addressableAreaName' in location) { - return isOnDevice - ? location.addressableAreaName - : t('slot', { slot_name: location.addressableAreaName }) - } else if ('moduleId' in location) { - const moduleModel = getModuleModel(commandTextData, location.moduleId) - if (moduleModel == null) { - console.warn('labware is located on an unknown module model') - return '' - } else { - const slotName = getModuleDisplayLocation( - commandTextData, - location.moduleId - ) - return isOnDevice - ? `${getModuleDisplayName(moduleModel)}, ${slotName}` - : t('module_in_slot', { - count: getOccludedSlotCountForModule( - getModuleType(moduleModel), - robotType - ), - module: getModuleDisplayName(moduleModel), - slot_name: slotName, - }) - } - } else if ('labwareId' in location) { - const adapter = commandTextData.labware.find( - lw => lw.id === location.labwareId - ) - const adapterDef = allRunDefs.find( - def => getLabwareDefURI(def) === adapter?.definitionUri - ) - const adapterDisplayName = - adapterDef != null ? getLabwareDisplayName(adapterDef) : '' - - if (adapter == null) { - console.warn('labware is located on an unknown adapter') - return '' - } else if (adapter.location === 'offDeck') { - return t('off_deck') - } else if ('slotName' in adapter.location) { - return t('adapter_in_slot', { - adapter: adapterDisplayName, - slot: adapter.location.slotName, - }) - } else if ('addressableAreaName' in adapter.location) { - return t('adapter_in_slot', { - adapter: adapterDisplayName, - slot: adapter.location.addressableAreaName, - }) - } else if ('moduleId' in adapter.location) { - const moduleIdUnderAdapter = adapter.location.moduleId - const moduleModel = commandTextData.modules.find( - module => module.id === moduleIdUnderAdapter - )?.model - if (moduleModel == null) { - console.warn('labware is located on an adapter on an unknown module') - return '' - } - const slotName = getModuleDisplayLocation( - commandTextData, - adapter.location.moduleId - ) - return t('adapter_in_mod_in_slot', { - count: getOccludedSlotCountForModule( - getModuleType(moduleModel), - robotType - ), - module: getModuleDisplayName(moduleModel), - adapter: adapterDisplayName, - slot: slotName, - }) - } else { - console.warn( - 'display location on adapter could not be established: ', - location - ) - return '' - } - } else { - console.warn('display location could not be established: ', location) - return '' - } -} diff --git a/app/src/molecules/Command/utils/getLiquidDisplayName.ts b/app/src/molecules/Command/utils/getLiquidDisplayName.ts deleted file mode 100644 index 1b4b15a854b..00000000000 --- a/app/src/molecules/Command/utils/getLiquidDisplayName.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { CommandTextData } from '../types' - -export function getLiquidDisplayName( - commandTextData: CommandTextData, - liquidId: string -): CommandTextData['liquids'][number]['displayName'] { - const liquidDisplayName = (commandTextData?.liquids ?? []).find( - liquid => liquid.id === liquidId - )?.displayName - return liquidDisplayName ?? '' -} diff --git a/app/src/molecules/Command/utils/getModuleDisplayLocation.ts b/app/src/molecules/Command/utils/getModuleDisplayLocation.ts deleted file mode 100644 index fa5e527d218..00000000000 --- a/app/src/molecules/Command/utils/getModuleDisplayLocation.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { getLoadedModule } from './accessors' - -import type { CommandTextData } from '../types' - -export function getModuleDisplayLocation( - commandTextData: Omit, - moduleId: string -): string { - const loadedModule = getLoadedModule(commandTextData, moduleId) - return loadedModule != null ? loadedModule.location.slotName : '' -} diff --git a/app/src/molecules/Command/utils/getModuleModel.ts b/app/src/molecules/Command/utils/getModuleModel.ts deleted file mode 100644 index fdac4850331..00000000000 --- a/app/src/molecules/Command/utils/getModuleModel.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { getLoadedModule } from './accessors' - -import type { ModuleModel } from '@opentrons/shared-data' -import type { CommandTextData } from '../types' - -export function getModuleModel( - commandTextData: Omit, - moduleId: string -): ModuleModel | null { - const loadedModule = getLoadedModule(commandTextData, moduleId) - return loadedModule != null ? loadedModule.model : null -} diff --git a/app/src/molecules/Command/utils/getPipetteNameOnMount.ts b/app/src/molecules/Command/utils/getPipetteNameOnMount.ts deleted file mode 100644 index f1c09d73caf..00000000000 --- a/app/src/molecules/Command/utils/getPipetteNameOnMount.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { getLoadedPipette } from './accessors' - -import type { PipetteName } from '@opentrons/shared-data' -import type { CommandTextData } from '../types' - -export function getPipetteNameOnMount( - commandTextData: CommandTextData, - mount: string -): PipetteName | null { - const loadedPipette = getLoadedPipette(commandTextData, mount) - return loadedPipette != null ? loadedPipette.pipetteName : null -} diff --git a/app/src/molecules/Command/utils/index.ts b/app/src/molecules/Command/utils/index.ts deleted file mode 100644 index 8e8bbfd9119..00000000000 --- a/app/src/molecules/Command/utils/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -export * from './getAddressableAreaDisplayName' -export * from './getLabwareName' -export * from './getPipetteNameOnMount' -export * from './getModuleModel' -export * from './getModuleDisplayLocation' -export * from './getLiquidDisplayName' -export * from './getLabwareDisplayLocation' -export * from './getFinalLabwareLocation' -export * from './getWellRange' -export * from './getLabwareDefinitionsFromCommands' diff --git a/app/src/molecules/InterventionModal/CategorizedStepContent.tsx b/app/src/molecules/InterventionModal/CategorizedStepContent.tsx index f1c0835d396..3f7352a27fe 100644 --- a/app/src/molecules/InterventionModal/CategorizedStepContent.tsx +++ b/app/src/molecules/InterventionModal/CategorizedStepContent.tsx @@ -11,7 +11,8 @@ import { import { Command, CommandIndex } from '../Command' -import type { NonSkeletonCommandState, CommandTextData } from '../Command' +import type { CommandTextData } from '/app/local-resources/commands' +import type { NonSkeletonCommandState } from '../Command' import type { LabwareDefinition2, RobotType, diff --git a/app/src/organisms/ApplyHistoricOffsets/__tests__/ApplyHistoricOffsets.test.tsx b/app/src/organisms/ApplyHistoricOffsets/__tests__/ApplyHistoricOffsets.test.tsx index 30508bc0565..74f77291834 100644 --- a/app/src/organisms/ApplyHistoricOffsets/__tests__/ApplyHistoricOffsets.test.tsx +++ b/app/src/organisms/ApplyHistoricOffsets/__tests__/ApplyHistoricOffsets.test.tsx @@ -7,7 +7,7 @@ import { opentrons96PcrAdapterV1, fixture96Plate } from '@opentrons/shared-data' import { i18n } from '/app/i18n' import { renderWithProviders } from '/app/__testing-utils__' import { getIsLabwareOffsetCodeSnippetsOn } from '/app/redux/config' -import { getLabwareDefinitionsFromCommands } from '/app/molecules/Command/utils/getLabwareDefinitionsFromCommands' +import { getLabwareDefinitionsFromCommands } from '/app/local-resources/labware' import { ApplyHistoricOffsets } from '..' import type { LabwareDefinition2 } from '@opentrons/shared-data' @@ -15,7 +15,7 @@ import type { OffsetCandidate } from '../hooks/useOffsetCandidatesForAnalysis' vi.mock('/app/redux/config') vi.mock('/app/organisms/LabwarePositionCheck/utils/labware') -vi.mock('/app/molecules/Command/utils/getLabwareDefinitionsFromCommands') +vi.mock('/app/local-resources/labware') const mockLabwareDef = fixture96Plate as LabwareDefinition2 const mockAdapterDef = opentrons96PcrAdapterV1 as LabwareDefinition2 diff --git a/app/src/organisms/ApplyHistoricOffsets/index.tsx b/app/src/organisms/ApplyHistoricOffsets/index.tsx index 240afa960b2..6925145c012 100644 --- a/app/src/organisms/ApplyHistoricOffsets/index.tsx +++ b/app/src/organisms/ApplyHistoricOffsets/index.tsx @@ -22,7 +22,7 @@ import { getTopPortalEl } from '/app/App/portal' import { ExternalLink } from '/app/atoms/Link/ExternalLink' import { PythonLabwareOffsetSnippet } from '/app/molecules/PythonLabwareOffsetSnippet' import { LabwareOffsetTabs } from '/app/organisms/LabwareOffsetTabs' -import { getLabwareDefinitionsFromCommands } from '/app/molecules/Command/utils/getLabwareDefinitionsFromCommands' +import { getLabwareDefinitionsFromCommands } from '/app/local-resources/labware' import { LabwareOffsetTable } from './LabwareOffsetTable' import { getIsLabwareOffsetCodeSnippetsOn } from '/app/redux/config' import type { LabwareOffset } from '@opentrons/api-client' diff --git a/app/src/organisms/Desktop/Devices/ChangePipette/InstructionStep.tsx b/app/src/organisms/Desktop/Devices/ChangePipette/InstructionStep.tsx index 05d43fdd11c..5b6338be6a5 100644 --- a/app/src/organisms/Desktop/Devices/ChangePipette/InstructionStep.tsx +++ b/app/src/organisms/Desktop/Devices/ChangePipette/InstructionStep.tsx @@ -30,13 +30,13 @@ export function InstructionStep(props: Props): JSX.Element { const display = displayCategory === 'GEN2' ? new URL( - `/app/assets/images/change-pip/${direction}-${String( + `../../../../assets/images/change-pip/${direction}-${String( mount )}-${channelsKey}-GEN2-${diagram}@3x.png`, import.meta.url ).href : new URL( - `/app/assets/images/change-pip/${direction}-${String( + `../../../../assets/images/change-pip/${direction}-${String( mount )}-${channelsKey}-${diagram}@3x.png`, import.meta.url diff --git a/app/src/organisms/Desktop/Devices/ChangePipette/LevelPipette.tsx b/app/src/organisms/Desktop/Devices/ChangePipette/LevelPipette.tsx index db49a4d6861..fb1120daec7 100644 --- a/app/src/organisms/Desktop/Devices/ChangePipette/LevelPipette.tsx +++ b/app/src/organisms/Desktop/Devices/ChangePipette/LevelPipette.tsx @@ -26,6 +26,11 @@ export function LevelingVideo(props: { mount: Mount }): JSX.Element { const { pipetteName, mount } = props + const video = new URL( + `../../../../assets/videos/pip-leveling/${pipetteName}-${mount}.webm`, + import.meta.url + ).href + return ( ) } diff --git a/app/src/organisms/Desktop/Devices/HistoricalProtocolRunDrawer.tsx b/app/src/organisms/Desktop/Devices/HistoricalProtocolRunDrawer.tsx index 6533895bb1e..1570d560aac 100644 --- a/app/src/organisms/Desktop/Devices/HistoricalProtocolRunDrawer.tsx +++ b/app/src/organisms/Desktop/Devices/HistoricalProtocolRunDrawer.tsx @@ -56,6 +56,10 @@ export function HistoricalProtocolRunDrawer( return acc }, []) : [] + if ('outputFileIds' in run && run.outputFileIds.length > 0) { + runDataFileIds.push(...run.outputFileIds) + } + const uniqueLabwareOffsets = allLabwareOffsets?.filter( (offset, index, array) => { return ( diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderBannerContainer/index.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderBannerContainer/index.tsx index e05a11eb391..5c7c6e01621 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderBannerContainer/index.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderBannerContainer/index.tsx @@ -1,6 +1,19 @@ import { useTranslation } from 'react-i18next' +import { useNavigate } from 'react-router-dom' -import { Box, SPACING, Banner } from '@opentrons/components' +import { + Box, + StyledText, + Link, + SPACING, + Banner, + Flex, + DIRECTION_COLUMN, + JUSTIFY_SPACE_BETWEEN, + DIRECTION_ROW, + ALIGN_CENTER, + TEXT_DECORATION_UNDERLINE, +} from '@opentrons/components' import { ProtocolAnalysisErrorBanner } from './ProtocolAnalysisErrorBanner' import { @@ -21,17 +34,25 @@ export type RunHeaderBannerContainerProps = ProtocolRunHeaderProps & { isResetRunLoading: boolean runErrors: UseRunErrorsResult runHeaderModalContainerUtils: UseRunHeaderModalContainerResult + hasDownloadableFiles: boolean } // Holds all the various banners that render in ProtocolRunHeader. export function RunHeaderBannerContainer( props: RunHeaderBannerContainerProps ): JSX.Element | null { - const { runStatus, enteredER, runHeaderModalContainerUtils } = props + const navigate = useNavigate() + const { + runStatus, + enteredER, + runHeaderModalContainerUtils, + hasDownloadableFiles, + robotName, + } = props const { analysisErrorModalUtils } = runHeaderModalContainerUtils const { t } = useTranslation(['run_details', 'shared']) - const isDoorOpen = useIsDoorOpen(props.robotName) + const isDoorOpen = useIsDoorOpen(robotName) const { showRunCanceledBanner, @@ -73,6 +94,36 @@ export function RunHeaderBannerContainer( {...props} /> ) : null} + {hasDownloadableFiles ? ( + + + + + {t('download_files')} + + + {t('files_available_robot_details')} + + + { + navigate(`/devices/${robotName}`) + }} + > + {t('device_details')} + + + + ) : null} ) } diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderContent/ActionButton/hooks/useActionButtonProperties.ts b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderContent/ActionButton/hooks/useActionButtonProperties.ts index cd16d2467b6..4c5486eb6e7 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderContent/ActionButton/hooks/useActionButtonProperties.ts +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderContent/ActionButton/hooks/useActionButtonProperties.ts @@ -1,5 +1,6 @@ import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router-dom' +import { useSelector } from 'react-redux' import { RUN_STATUS_IDLE, @@ -14,6 +15,7 @@ import { useTrackEvent, } from '/app/redux/analytics' import { useTrackProtocolRunEvent } from '/app/redux-resources/analytics' +import { getMissingSetupSteps } from '/app/redux/protocol-runs' import { useIsHeaterShakerInProtocol } from '/app/organisms/ModuleCard/hooks' import { isAnyHeaterShakerShaking } from '../../../RunHeaderModalContainer/modals' import { @@ -24,6 +26,8 @@ import { import type { IconName } from '@opentrons/components' import type { BaseActionButtonProps } from '..' +import type { State } from '/app/redux/types' +import type { StepKey } from '/app/redux/protocol-runs' interface UseButtonPropertiesProps extends BaseActionButtonProps { isProtocolNotReady: boolean @@ -42,7 +46,6 @@ interface UseButtonPropertiesProps extends BaseActionButtonProps { export function useActionButtonProperties({ isProtocolNotReady, runStatus, - missingSetupSteps, robotName, runId, confirmAttachment, @@ -66,6 +69,9 @@ export function useActionButtonProperties({ const isHeaterShakerInProtocol = useIsHeaterShakerInProtocol() const isHeaterShakerShaking = isAnyHeaterShakerShaking(attachedModules) const trackEvent = useTrackEvent() + const missingSetupSteps = useSelector((state: State) => + getMissingSetupSteps(state, runId) + ) let buttonText = '' let handleButtonClick = (): void => {} diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/hooks/useMissingStepsModal.ts b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/hooks/useMissingStepsModal.ts index 4bf28bc049f..d0506c55153 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/hooks/useMissingStepsModal.ts +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/hooks/useMissingStepsModal.ts @@ -1,17 +1,21 @@ +import { useSelector } from 'react-redux' import { RUN_STATUS_IDLE, RUN_STATUS_STOPPED } from '@opentrons/api-client' import { useConditionalConfirm } from '@opentrons/components' import { useIsHeaterShakerInProtocol } from '/app/organisms/ModuleCard/hooks' import { isAnyHeaterShakerShaking } from '../modals' +import { getMissingSetupSteps } from '/app/redux/protocol-runs' import type { UseConditionalConfirmResult } from '@opentrons/components' import type { RunStatus, AttachedModule } from '@opentrons/api-client' import type { ConfirmMissingStepsModalProps } from '../modals' +import type { State } from '/app/redux/types' +import type { StepKey } from '/app/redux/protocol-runs' interface UseMissingStepsModalProps { runStatus: RunStatus | null attachedModules: AttachedModule[] - missingSetupSteps: string[] + runId: string handleProceedToRunClick: () => void } @@ -30,12 +34,14 @@ export type UseMissingStepsModalResult = export function useMissingStepsModal({ attachedModules, runStatus, - missingSetupSteps, + runId, handleProceedToRunClick, }: UseMissingStepsModalProps): UseMissingStepsModalResult { const isHeaterShakerInProtocol = useIsHeaterShakerInProtocol() const isHeaterShakerShaking = isAnyHeaterShakerShaking(attachedModules) - + const missingSetupSteps = useSelector((state: State) => + getMissingSetupSteps(state, runId) + ) const shouldShowHSConfirm = isHeaterShakerInProtocol && !isHeaterShakerShaking && diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/ConfirmMissingStepsModal.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/ConfirmMissingStepsModal.tsx index 978efdbab48..8203e126a2d 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/ConfirmMissingStepsModal.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/ConfirmMissingStepsModal.tsx @@ -12,11 +12,27 @@ import { TYPOGRAPHY, Modal, } from '@opentrons/components' +import { + LPC_STEP_KEY, + LABWARE_SETUP_STEP_KEY, + LIQUID_SETUP_STEP_KEY, + MODULE_SETUP_STEP_KEY, + ROBOT_CALIBRATION_STEP_KEY, +} from '/app/redux/protocol-runs' +import type { StepKey } from '/app/redux/protocol-runs' + +const STEP_KEY_TO_I18N_KEY = { + [LPC_STEP_KEY]: 'applied_labware_offsets', + [LABWARE_SETUP_STEP_KEY]: 'labware_placement', + [LIQUID_SETUP_STEP_KEY]: 'liquids', + [MODULE_SETUP_STEP_KEY]: 'module_setup', + [ROBOT_CALIBRATION_STEP_KEY]: 'robot_calibration', +} export interface ConfirmMissingStepsModalProps { onCloseClick: () => void onConfirmClick: () => void - missingSteps: string[] + missingSteps: StepKey[] } export const ConfirmMissingStepsModal = ( props: ConfirmMissingStepsModalProps @@ -41,7 +57,7 @@ export const ConfirmMissingStepsModal = ( missingSteps: new Intl.ListFormat('en', { style: 'short', type: 'conjunction', - }).format(missingSteps.map(step => t(step))), + }).format(missingSteps.map(step => t(STEP_KEY_TO_I18N_KEY[step]))), })} diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/ProtocolDropTipModal.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/ProtocolDropTipModal.tsx index 7d96803c4a6..e1f1be57d22 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/ProtocolDropTipModal.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/ProtocolDropTipModal.tsx @@ -16,14 +16,12 @@ import { } from '@opentrons/components' import { TextOnlyButton } from '/app/atoms/buttons' -import { useHomePipettes } from '/app/organisms/DropTipWizardFlows' +import { useHomePipettes } from '/app/local-resources/instruments' import type { PipetteData } from '@opentrons/api-client' import type { IconProps } from '@opentrons/components' -import type { - UseHomePipettesProps, - TipAttachmentStatusResult, -} from '/app/organisms/DropTipWizardFlows' +import type { UseHomePipettesProps } from '/app/local-resources/instruments' +import type { TipAttachmentStatusResult } from '/app/organisms/DropTipWizardFlows' type UseProtocolDropTipModalProps = Pick< UseHomePipettesProps, diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/__tests__/ProtocolDropTipModal.test.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/__tests__/ProtocolDropTipModal.test.tsx index 56a508b9666..0d95071a969 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/__tests__/ProtocolDropTipModal.test.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/__tests__/ProtocolDropTipModal.test.tsx @@ -4,7 +4,7 @@ import { renderHook, act, screen, fireEvent } from '@testing-library/react' import { renderWithProviders } from '/app/__testing-utils__' import { i18n } from '/app/i18n' -import { useHomePipettes } from '/app/organisms/DropTipWizardFlows' +import { useHomePipettes } from '/app/local-resources/instruments' import { useProtocolDropTipModal, ProtocolDropTipModal, @@ -12,7 +12,7 @@ import { import type { Mock } from 'vitest' -vi.mock('/app/organisms/DropTipWizardFlows') +vi.mock('/app/local-resources/instruments') describe('useProtocolDropTipModal', () => { let props: Parameters[0] diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/useRunHeaderModalContainer.ts b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/useRunHeaderModalContainer.ts index 17d81c1f18e..48eda0ebfa5 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/useRunHeaderModalContainer.ts +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/useRunHeaderModalContainer.ts @@ -62,7 +62,6 @@ export function useRunHeaderModalContainer({ runStatus, runRecord, attachedModules, - missingSetupSteps, protocolRunControls, runErrors, }: UseRunHeaderModalContainerProps): UseRunHeaderModalContainerResult { @@ -102,7 +101,7 @@ export function useRunHeaderModalContainer({ const missingStepsModalUtils = useMissingStepsModal({ attachedModules, runStatus, - missingSetupSteps, + runId, handleProceedToRunClick, }) diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/__tests__/ProtocolRunHeader.test.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/__tests__/ProtocolRunHeader.test.tsx index 9cc357d0565..e82d58cb75e 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/__tests__/ProtocolRunHeader.test.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/__tests__/ProtocolRunHeader.test.tsx @@ -30,6 +30,7 @@ vi.mock('react-router-dom') vi.mock('@opentrons/react-api-client') vi.mock('/app/redux-resources/robots') vi.mock('/app/resources/runs') +vi.mock('/app/redux/protocol-runs') vi.mock('../RunHeaderModalContainer') vi.mock('../RunHeaderBannerContainer') vi.mock('../RunHeaderContent') @@ -51,7 +52,6 @@ describe('ProtocolRunHeader', () => { robotName: MOCK_ROBOT, runId: MOCK_RUN_ID, makeHandleJumpToStep: vi.fn(), - missingSetupSteps: [], } vi.mocked(useNavigate).mockReturnValue(mockNavigate) diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/index.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/index.tsx index b9641fcc96b..40375135db9 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/index.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/index.tsx @@ -35,7 +35,6 @@ export interface ProtocolRunHeaderProps { robotName: string runId: string makeHandleJumpToStep: (index: number) => () => void - missingSetupSteps: string[] } export function ProtocolRunHeader( @@ -103,6 +102,11 @@ export function ProtocolRunHeader( isResetRunLoading={isResetRunLoadingRef.current} runErrors={runErrors} runHeaderModalContainerUtils={runHeaderModalContainerUtils} + hasDownloadableFiles={ + runRecord?.data != null && + 'outputFileIds' in runRecord.data && + runRecord.data.outputFileIds.length > 0 + } {...props} /> [ - 'applied_labware_offsets', - 'labware_placement', - 'liquids', -] +import type { Dispatch, State } from '/app/redux/types' +import type { StepKey } from '/app/redux/protocol-runs' interface ProtocolRunSetupProps { protocolRunHeaderRef: React.RefObject | null robotName: string runId: string - setMissingSteps: (missingSteps: MissingSteps) => void - missingSteps: MissingSteps } export function ProtocolRunSetup({ protocolRunHeaderRef, robotName, runId, - setMissingSteps, - missingSteps, }: ProtocolRunSetupProps): JSX.Element | null { const { t, i18n } = useTranslation('protocol_setup') + const dispatch = useDispatch() const robotProtocolAnalysis = useMostRecentCompletedAnalysis(runId) const storedProtocolAnalysis = useStoredProtocolAnalysis(runId) const protocolAnalysis = robotProtocolAnalysis ?? storedProtocolAnalysis + const { + orderedSteps, + orderedApplicableSteps, + } = useRequiredSetupStepsInOrder({ runId, protocolAnalysis }) const modules = parseAllRequiredModuleModels(protocolAnalysis?.commands ?? []) const robot = useRobot(robotName) @@ -130,39 +119,6 @@ export function ProtocolRunSetup({ const isMissingModule = missingModuleIds.length > 0 - const stepsKeysInOrder = - protocolAnalysis != null - ? [ - ROBOT_CALIBRATION_STEP_KEY, - MODULE_SETUP_KEY, - LPC_KEY, - LABWARE_SETUP_KEY, - LIQUID_SETUP_KEY, - ] - : [ROBOT_CALIBRATION_STEP_KEY, LPC_KEY, LABWARE_SETUP_KEY] - - const targetStepKeyInOrder = stepsKeysInOrder.filter((stepKey: StepKey) => { - if (protocolAnalysis == null) { - return stepKey !== MODULE_SETUP_KEY && stepKey !== LIQUID_SETUP_KEY - } - - if ( - protocolAnalysis.modules.length === 0 && - protocolAnalysis.liquids.length === 0 - ) { - return stepKey !== MODULE_SETUP_KEY && stepKey !== LIQUID_SETUP_KEY - } - - if (protocolAnalysis.modules.length === 0) { - return stepKey !== MODULE_SETUP_KEY - } - - if (protocolAnalysis.liquids.length === 0) { - return stepKey !== LIQUID_SETUP_KEY - } - return true - }) - const liquids = protocolAnalysis?.liquids ?? [] const hasLiquids = liquids.length > 0 const hasModules = protocolAnalysis != null && modules.length > 0 @@ -179,26 +135,10 @@ export function ProtocolRunSetup({ ? t('install_modules', { count: modules.length }) : t('no_deck_hardware_specified') - const [ - labwareSetupComplete, - setLabwareSetupComplete, - ] = React.useState(false) - const [liquidSetupComplete, setLiquidSetupComplete] = React.useState( - false + const missingSteps = useSelector( + (state: State): StepKey[] => getMissingSetupSteps(state, runId) ) - React.useEffect(() => { - if ((robotProtocolAnalysis || storedProtocolAnalysis) && !hasLiquids) { - setLiquidSetupComplete(true) - } - }, [robotProtocolAnalysis, storedProtocolAnalysis, hasLiquids]) - if ( - !hasLiquids && - protocolAnalysis != null && - missingSteps.includes('liquids') - ) { - setMissingSteps(missingSteps.filter(step => step !== 'liquids')) - } - const [lpcComplete, setLpcComplete] = React.useState(false) + if (robot == null) { return null } @@ -216,8 +156,8 @@ export function ProtocolRunSetup({ robotName={robotName} runId={runId} nextStep={ - targetStepKeyInOrder[ - targetStepKeyInOrder.findIndex( + orderedApplicableSteps[ + orderedApplicableSteps.findIndex( v => v === ROBOT_CALIBRATION_STEP_KEY ) + 1 ] @@ -240,11 +180,11 @@ export function ProtocolRunSetup({ incompleteElement: null, }, }, - [MODULE_SETUP_KEY]: { + [MODULE_SETUP_STEP_KEY]: { stepInternals: ( { - setExpandedStepKey(LPC_KEY) + setExpandedStepKey(LPC_STEP_KEY) }} robotName={robotName} runId={runId} @@ -256,7 +196,7 @@ export function ProtocolRunSetup({ ? flexDeckHardwareDescription : ot2DeckHardwareDescription, rightElProps: { - stepKey: MODULE_SETUP_KEY, + stepKey: MODULE_SETUP_STEP_KEY, complete: calibrationStatusModules.complete && !isMissingModule && @@ -272,84 +212,89 @@ export function ProtocolRunSetup({ incompleteElement: null, }, }, - [LPC_KEY]: { + [LPC_STEP_KEY]: { stepInternals: ( { - setLpcComplete(confirmed) + dispatch( + updateRunSetupStepsComplete(runId, { [LPC_STEP_KEY]: confirmed }) + ) if (confirmed) { - setExpandedStepKey(LABWARE_SETUP_KEY) - setMissingSteps( - missingSteps.filter(step => step !== 'applied_labware_offsets') - ) + setExpandedStepKey(LABWARE_SETUP_STEP_KEY) } }} - offsetsConfirmed={lpcComplete} + offsetsConfirmed={!missingSteps.includes(LPC_STEP_KEY)} /> ), description: t('labware_position_check_step_description'), rightElProps: { - stepKey: LPC_KEY, - complete: lpcComplete, + stepKey: LPC_STEP_KEY, + complete: !missingSteps.includes(LPC_STEP_KEY), completeText: t('offsets_ready'), incompleteText: null, incompleteElement: , }, }, - [LABWARE_SETUP_KEY]: { + [LABWARE_SETUP_STEP_KEY]: { stepInternals: ( { - setLabwareSetupComplete(confirmed) + dispatch( + updateRunSetupStepsComplete(runId, { + [LABWARE_SETUP_STEP_KEY]: confirmed, + }) + ) if (confirmed) { - setMissingSteps( - missingSteps.filter(step => step !== 'labware_placement') - ) const nextStep = - targetStepKeyInOrder.findIndex(v => v === LABWARE_SETUP_KEY) === - targetStepKeyInOrder.length - 1 + orderedApplicableSteps.findIndex( + v => v === LABWARE_SETUP_STEP_KEY + ) === + orderedApplicableSteps.length - 1 ? null - : LIQUID_SETUP_KEY + : LIQUID_SETUP_STEP_KEY setExpandedStepKey(nextStep) } }} /> ), - description: t(`${LABWARE_SETUP_KEY}_description`), + description: t(`${LABWARE_SETUP_STEP_KEY}_description`), rightElProps: { - stepKey: LABWARE_SETUP_KEY, - complete: labwareSetupComplete, + stepKey: LABWARE_SETUP_STEP_KEY, + complete: !missingSteps.includes(LABWARE_SETUP_STEP_KEY), completeText: t('placements_ready'), incompleteText: null, incompleteElement: null, }, }, - [LIQUID_SETUP_KEY]: { + [LIQUID_SETUP_STEP_KEY]: { stepInternals: ( { - setLiquidSetupComplete(confirmed) + dispatch( + updateRunSetupStepsComplete(runId, { + [LIQUID_SETUP_STEP_KEY]: confirmed, + }) + ) if (confirmed) { - setMissingSteps(missingSteps.filter(step => step !== 'liquids')) setExpandedStepKey(null) } }} /> ), description: hasLiquids - ? t(`${LIQUID_SETUP_KEY}_description`) + ? t(`${LIQUID_SETUP_STEP_KEY}_description`) : i18n.format(t('liquids_not_in_the_protocol'), 'capitalize'), rightElProps: { - stepKey: LIQUID_SETUP_KEY, - complete: liquidSetupComplete, + stepKey: LIQUID_SETUP_STEP_KEY, + complete: !missingSteps.includes(LIQUID_SETUP_STEP_KEY), completeText: t('liquids_ready'), incompleteText: null, incompleteElement: null, @@ -373,7 +318,7 @@ export function ProtocolRunSetup({ {t('protocol_analysis_failed')} ) : ( - stepsKeysInOrder.map((stepKey, index) => { + orderedSteps.map((stepKey, index) => { const setupStepTitle = t(`${stepKey}_title`) const showEmptySetupStep = (stepKey === 'liquid_setup_step' && !hasLiquids) || @@ -411,7 +356,7 @@ export function ProtocolRunSetup({ {StepDetailMap[stepKey].stepInternals} )} - {index !== stepsKeysInOrder.length - 1 ? ( + {index !== orderedSteps.length - 1 ? ( ) : null} @@ -431,7 +376,7 @@ export function ProtocolRunSetup({ interface NoHardwareRequiredStepCompletion { stepKey: Exclude< StepKey, - typeof ROBOT_CALIBRATION_STEP_KEY | typeof MODULE_SETUP_KEY + typeof ROBOT_CALIBRATION_STEP_KEY | typeof MODULE_SETUP_STEP_KEY > complete: boolean incompleteText: string | null @@ -440,7 +385,7 @@ interface NoHardwareRequiredStepCompletion { } interface HardwareRequiredStepCompletion { - stepKey: typeof ROBOT_CALIBRATION_STEP_KEY | typeof MODULE_SETUP_KEY + stepKey: typeof ROBOT_CALIBRATION_STEP_KEY | typeof MODULE_SETUP_STEP_KEY complete: boolean missingHardware: boolean incompleteText: string | null @@ -457,7 +402,7 @@ const stepRequiresHW = ( props: StepRightElementProps ): props is HardwareRequiredStepCompletion => props.stepKey === ROBOT_CALIBRATION_STEP_KEY || - props.stepKey === MODULE_SETUP_KEY + props.stepKey === MODULE_SETUP_STEP_KEY function StepRightElement(props: StepRightElementProps): JSX.Element | null { if (props.complete) { diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabwarePositionCheck/CurrentOffsetsTable.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabwarePositionCheck/CurrentOffsetsTable.tsx index 849464deee4..e86166b0c9b 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabwarePositionCheck/CurrentOffsetsTable.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabwarePositionCheck/CurrentOffsetsTable.tsx @@ -23,7 +23,7 @@ import { LabwareOffsetTabs } from '/app/organisms/LabwareOffsetTabs' import { OffsetVector } from '/app/molecules/OffsetVector' import { PythonLabwareOffsetSnippet } from '/app/molecules/PythonLabwareOffsetSnippet' import { getDisplayLocation } from '/app/organisms/LabwarePositionCheck/utils/getDisplayLocation' -import { getLabwareDefinitionsFromCommands } from '/app/molecules/Command/utils/getLabwareDefinitionsFromCommands' +import { getLabwareDefinitionsFromCommands } from '/app/local-resources/labware' import type { LabwareOffset } from '@opentrons/api-client' import type { RunTimeCommand, diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupRobotCalibration.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupRobotCalibration.tsx index 90745500149..5202419e290 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupRobotCalibration.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupRobotCalibration.tsx @@ -23,7 +23,7 @@ import { RUN_STATUS_STOPPED } from '@opentrons/api-client' import { useIsFlex } from '/app/redux-resources/robots' import type { ProtocolCalibrationStatus } from '/app/redux/calibration/types' -import type { StepKey } from './ProtocolRunSetup' +import type { StepKey } from '/app/redux/protocol-runs' interface SetupRobotCalibrationProps { robotName: string diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/__tests__/ProtocolRunSetup.test.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/__tests__/ProtocolRunSetup.test.tsx index 64aa0d094ae..84e7fb82e65 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/__tests__/ProtocolRunSetup.test.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/__tests__/ProtocolRunSetup.test.tsx @@ -30,7 +30,9 @@ import { } from '/app/resources/runs' import { useDeckConfigurationCompatibility } from '/app/resources/deck_configuration/hooks' import { useRobot, useIsFlex } from '/app/redux-resources/robots' +import { useRequiredSetupStepsInOrder } from '/app/redux-resources/runs' import { useStoredProtocolAnalysis } from '/app/resources/analysis' +import { getMissingSetupSteps } from '/app/redux/protocol-runs' import { SetupLabware } from '../SetupLabware' import { SetupRobotCalibration } from '../SetupRobotCalibration' @@ -38,7 +40,9 @@ import { SetupLiquids } from '../SetupLiquids' import { SetupModuleAndDeck } from '../SetupModuleAndDeck' import { EmptySetupStep } from '../EmptySetupStep' import { ProtocolRunSetup } from '../ProtocolRunSetup' -import type { MissingSteps } from '../ProtocolRunSetup' +import * as ReduxRuns from '/app/redux/protocol-runs' + +import type { State } from '/app/redux/types' import type * as SharedData from '@opentrons/shared-data' @@ -56,9 +60,12 @@ vi.mock('/app/resources/runs/useUnmatchedModulesForProtocol') vi.mock('/app/resources/runs/useModuleCalibrationStatus') vi.mock('/app/resources/runs/useProtocolAnalysisErrors') vi.mock('/app/redux/config') +vi.mock('/app/redux/protocol-runs') +vi.mock('/app/resources/protocol-runs') vi.mock('/app/resources/deck_configuration/utils') vi.mock('/app/resources/deck_configuration/hooks') vi.mock('/app/redux-resources/robots') +vi.mock('/app/redux-resources/runs') vi.mock('/app/resources/analysis') vi.mock('@opentrons/shared-data', async importOriginal => { const actualSharedData = await importOriginal() @@ -74,20 +81,15 @@ vi.mock('@opentrons/shared-data', async importOriginal => { const ROBOT_NAME = 'otie' const RUN_ID = '1' const MOCK_PROTOCOL_LIQUID_KEY = { liquids: [] } -let mockMissingSteps: MissingSteps = [] -const mockSetMissingSteps = vi.fn((missingSteps: MissingSteps) => { - mockMissingSteps = missingSteps -}) const render = () => { - return renderWithProviders( + return renderWithProviders( , { + initialState: {} as State, i18nInstance: i18n, } )[0] @@ -95,7 +97,6 @@ const render = () => { describe('ProtocolRunSetup', () => { beforeEach(() => { - mockMissingSteps = [] when(vi.mocked(useIsFlex)).calledWith(ROBOT_NAME).thenReturn(false) when(vi.mocked(useMostRecentCompletedAnalysis)) .calledWith(RUN_ID) @@ -103,6 +104,9 @@ describe('ProtocolRunSetup', () => { ...noModulesProtocol, ...MOCK_PROTOCOL_LIQUID_KEY, } as any) + when(vi.mocked(getMissingSetupSteps)) + .calledWith(expect.any(Object), RUN_ID) + .thenReturn([]) when(vi.mocked(useProtocolAnalysisErrors)).calledWith(RUN_ID).thenReturn({ analysisErrors: null, }) @@ -112,6 +116,27 @@ describe('ProtocolRunSetup', () => { ...noModulesProtocol, ...MOCK_PROTOCOL_LIQUID_KEY, } as unknown) as SharedData.ProtocolAnalysisOutput) + when(vi.mocked(useRequiredSetupStepsInOrder)) + .calledWith({ + runId: RUN_ID, + protocolAnalysis: expect.any(Object), + }) + .thenReturn({ + orderedSteps: [ + ReduxRuns.ROBOT_CALIBRATION_STEP_KEY, + ReduxRuns.MODULE_SETUP_STEP_KEY, + ReduxRuns.LPC_STEP_KEY, + ReduxRuns.LABWARE_SETUP_STEP_KEY, + ReduxRuns.LIQUID_SETUP_STEP_KEY, + ], + orderedApplicableSteps: [ + ReduxRuns.ROBOT_CALIBRATION_STEP_KEY, + ReduxRuns.MODULE_SETUP_STEP_KEY, + ReduxRuns.LPC_STEP_KEY, + ReduxRuns.LABWARE_SETUP_STEP_KEY, + ReduxRuns.LIQUID_SETUP_STEP_KEY, + ], + }) vi.mocked(parseAllRequiredModuleModels).mockReturnValue([]) vi.mocked(parseLiquidsInLoadOrder).mockReturnValue([]) when(vi.mocked(useRobot)) @@ -179,6 +204,20 @@ describe('ProtocolRunSetup', () => { when(vi.mocked(useStoredProtocolAnalysis)) .calledWith(RUN_ID) .thenReturn(null) + when(vi.mocked(useRequiredSetupStepsInOrder)) + .calledWith({ runId: RUN_ID, protocolAnalysis: null }) + .thenReturn({ + orderedSteps: [ + ReduxRuns.ROBOT_CALIBRATION_STEP_KEY, + ReduxRuns.LPC_STEP_KEY, + ReduxRuns.LABWARE_SETUP_STEP_KEY, + ], + orderedApplicableSteps: [ + ReduxRuns.ROBOT_CALIBRATION_STEP_KEY, + ReduxRuns.LPC_STEP_KEY, + ReduxRuns.LABWARE_SETUP_STEP_KEY, + ], + }) render() screen.getByText('Loading data...') }) diff --git a/app/src/organisms/Desktop/Devices/RunPreview/index.tsx b/app/src/organisms/Desktop/Devices/RunPreview/index.tsx index dd6c0e7beab..c2a48aba59a 100644 --- a/app/src/organisms/Desktop/Devices/RunPreview/index.tsx +++ b/app/src/organisms/Desktop/Devices/RunPreview/index.tsx @@ -28,13 +28,10 @@ import { useMostRecentCompletedAnalysis, useLastRunCommand, } from '/app/resources/runs' -import { - CommandText, - CommandIcon, - getLabwareDefinitionsFromCommands, -} from '/app/molecules/Command' +import { CommandText, CommandIcon } from '/app/molecules/Command' import { Divider } from '/app/atoms/structure' import { NAV_BAR_WIDTH } from '/app/App/constants' +import { getLabwareDefinitionsFromCommands } from '/app/local-resources/labware' import type { RunStatus } from '@opentrons/api-client' import type { RobotType } from '@opentrons/shared-data' diff --git a/app/src/organisms/Desktop/ProtocolDetails/AnnotatedSteps.tsx b/app/src/organisms/Desktop/ProtocolDetails/AnnotatedSteps.tsx index 5af6922afce..52837338fca 100644 --- a/app/src/organisms/Desktop/ProtocolDetails/AnnotatedSteps.tsx +++ b/app/src/organisms/Desktop/ProtocolDetails/AnnotatedSteps.tsx @@ -14,11 +14,8 @@ import { OVERFLOW_AUTO, } from '@opentrons/components' -import { - CommandIcon, - CommandText, - getLabwareDefinitionsFromCommands, -} from '/app/molecules/Command' +import { CommandIcon, CommandText } from '/app/molecules/Command' +import { getLabwareDefinitionsFromCommands } from '/app/local-resources/labware' import type { CompletedProtocolAnalysis, diff --git a/app/src/organisms/Desktop/ProtocolTimelineScrubber/CommandItem.tsx b/app/src/organisms/Desktop/ProtocolTimelineScrubber/CommandItem.tsx index 573893f096f..e97d30df767 100644 --- a/app/src/organisms/Desktop/ProtocolTimelineScrubber/CommandItem.tsx +++ b/app/src/organisms/Desktop/ProtocolTimelineScrubber/CommandItem.tsx @@ -8,7 +8,7 @@ import { LegacyStyledText, OVERFLOW_SCROLL, } from '@opentrons/components' -import { getCommandTextData } from '/app/molecules/Command/utils/getCommandTextData' +import { getCommandTextData } from '/app/local-resources/commands' import { CommandText } from '/app/molecules/Command' import { COMMAND_WIDTH_PX } from './index' diff --git a/app/src/organisms/Desktop/ProtocolTimelineScrubber/index.tsx b/app/src/organisms/Desktop/ProtocolTimelineScrubber/index.tsx index d92bc62e695..49be035e944 100644 --- a/app/src/organisms/Desktop/ProtocolTimelineScrubber/index.tsx +++ b/app/src/organisms/Desktop/ProtocolTimelineScrubber/index.tsx @@ -27,7 +27,7 @@ import { wellFillFromWellContents, } from './utils' import { CommandItem } from './CommandItem' -import { getLabwareDefinitionsFromCommands } from '/app/molecules/Command' +import { getLabwareDefinitionsFromCommands } from '/app/local-resources/labware' import type { ComponentProps } from 'react' import type { ViewportListRef } from 'react-viewport-list' diff --git a/app/src/organisms/Desktop/RunProgressMeter/hooks/useRunProgressCopy.tsx b/app/src/organisms/Desktop/RunProgressMeter/hooks/useRunProgressCopy.tsx index e9db153498b..8c522b4ff22 100644 --- a/app/src/organisms/Desktop/RunProgressMeter/hooks/useRunProgressCopy.tsx +++ b/app/src/organisms/Desktop/RunProgressMeter/hooks/useRunProgressCopy.tsx @@ -6,12 +6,11 @@ import { } from '@opentrons/api-client' import type * as React from 'react' import { useTranslation } from 'react-i18next' -import { getCommandTextData } from '/app/molecules/Command/utils/getCommandTextData' + +import { getCommandTextData } from '/app/local-resources/commands' +import { getLabwareDefinitionsFromCommands } from '/app/local-resources/labware' import { LegacyStyledText } from '@opentrons/components' -import { - CommandText, - getLabwareDefinitionsFromCommands, -} from '/app/molecules/Command' +import { CommandText } from '/app/molecules/Command' import { TERMINAL_RUN_STATUSES } from '../constants' import type { CommandDetail, RunStatus } from '@opentrons/api-client' diff --git a/app/src/organisms/DropTipWizardFlows/DropTipWizard.tsx b/app/src/organisms/DropTipWizardFlows/DropTipWizard.tsx index a81891d2144..be4cc9979ef 100644 --- a/app/src/organisms/DropTipWizardFlows/DropTipWizard.tsx +++ b/app/src/organisms/DropTipWizardFlows/DropTipWizard.tsx @@ -17,6 +17,7 @@ import { ModalShell, DISPLAY_FLEX, OVERFLOW_HIDDEN, + OVERFLOW_AUTO, } from '@opentrons/components' import { getTopPortalEl } from '/app/App/portal' @@ -307,7 +308,11 @@ function useInitiateExit(): { const SHARED_STYLE = ` display: ${DISPLAY_FLEX}; flex-direction: ${DIRECTION_COLUMN}; - overflow: ${OVERFLOW_HIDDEN}; + overflow-y: ${OVERFLOW_AUTO}; + + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + overflow: ${OVERFLOW_HIDDEN}; + } ` const INTERVENTION_CONTAINER_STYLE = css` diff --git a/app/src/organisms/DropTipWizardFlows/DropTipWizardFlows.tsx b/app/src/organisms/DropTipWizardFlows/DropTipWizardFlows.tsx index 5684b123afb..b6b60315936 100644 --- a/app/src/organisms/DropTipWizardFlows/DropTipWizardFlows.tsx +++ b/app/src/organisms/DropTipWizardFlows/DropTipWizardFlows.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { useDropTipLocations, @@ -64,6 +64,14 @@ export function DropTipWizardFlows( const dropTipRoutingUtils = useDropTipRouting(fixitCommandTypeUtils) const dropTipCommandLocations = useDropTipLocations(props.robotType) // Prefetch to reduce client latency + // If the flow unrenders for any reason (ex, the pipette card managing the flow unrenders), don't re-render the flow + // after it closes. + useEffect(() => { + return () => { + dropTipWithTypeUtils.dropTipCommands.handleCleanUpAndClose() + } + }, []) + return ( & { diff --git a/app/src/organisms/DropTipWizardFlows/hooks/index.ts b/app/src/organisms/DropTipWizardFlows/hooks/index.ts index 09acf2b2a5d..3f3f531a9d8 100644 --- a/app/src/organisms/DropTipWizardFlows/hooks/index.ts +++ b/app/src/organisms/DropTipWizardFlows/hooks/index.ts @@ -1,6 +1,5 @@ export * from './errors' export * from './useDropTipWithType' -export * from './useHomePipettes' export * from './useTipAttachmentStatus' export * from './useDropTipLocations' export { useDropTipRouting } from './useDropTipRouting' diff --git a/app/src/organisms/DropTipWizardFlows/index.ts b/app/src/organisms/DropTipWizardFlows/index.ts index 0030fa29a5a..1b53f36e5c8 100644 --- a/app/src/organisms/DropTipWizardFlows/index.ts +++ b/app/src/organisms/DropTipWizardFlows/index.ts @@ -1,10 +1,6 @@ export * from './DropTipWizardFlows' -export { useTipAttachmentStatus, useHomePipettes } from './hooks' +export { useTipAttachmentStatus } from './hooks' export * from './TipsAttachedModal' -export type { - UseHomePipettesProps, - TipAttachmentStatusResult, - PipetteWithTip, -} from './hooks' +export type { TipAttachmentStatusResult, PipetteWithTip } from './hooks' export type { FixitCommandTypeUtils } from './types' diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SelectRecoveryOption.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SelectRecoveryOption.tsx index 36b22c6ed3c..c44252e2da9 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SelectRecoveryOption.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SelectRecoveryOption.tsx @@ -1,8 +1,10 @@ import { useState, useEffect } from 'react' import head from 'lodash/head' import { useTranslation } from 'react-i18next' +import { css } from 'styled-components' import { + RESPONSIVENESS, DIRECTION_COLUMN, Flex, SPACING, @@ -14,14 +16,8 @@ import { RECOVERY_MAP, ERROR_KINDS, ODD_SECTION_TITLE_STYLE, - ODD_ONLY, - DESKTOP_ONLY, } from '../constants' -import { - RecoveryODDOneDesktopTwoColumnContentWrapper, - RecoveryRadioGroup, - FailedStepNextStep, -} from '../shared' +import { RecoverySingleColumnContentWrapper } from '../shared' import type { ErrorKind, RecoveryContentProps, RecoveryRoute } from '../types' import type { PipetteWithTip } from '/app/organisms/DropTipWizardFlows' @@ -52,7 +48,7 @@ export function SelectRecoveryOptionHome({ currentRecoveryOptionUtils, getRecoveryOptionCopy, analytics, - ...rest + isOnDevice, }: RecoveryContentProps): JSX.Element | null { const { t } = useTranslation('error_recovery') const { proceedToRouteAndStep } = routeUpdateActions @@ -66,7 +62,7 @@ export function SelectRecoveryOptionHome({ useCurrentTipStatus(determineTipStatus) return ( - { analytics.reportActionSelectedEvent(selectedRoute) @@ -83,27 +79,16 @@ export function SelectRecoveryOptionHome({ > {t('choose_a_recovery_action')} - - - - - - + - - + ) } @@ -111,23 +96,21 @@ interface RecoveryOptionsProps { validRecoveryOptions: RecoveryRoute[] setSelectedRoute: (route: RecoveryRoute) => void getRecoveryOptionCopy: RecoveryContentProps['getRecoveryOptionCopy'] - errorKind: ErrorKind + errorKind: RecoveryContentProps['errorKind'] + isOnDevice: RecoveryContentProps['isOnDevice'] selectedRoute?: RecoveryRoute } -// For ODD use only. -export function ODDRecoveryOptions({ + +export function RecoveryOptions({ errorKind, validRecoveryOptions, selectedRoute, setSelectedRoute, getRecoveryOptionCopy, + isOnDevice, }: RecoveryOptionsProps): JSX.Element { return ( - + {validRecoveryOptions.map((recoveryOption: RecoveryRoute) => { const optionName = getRecoveryOptionCopy(recoveryOption, errorKind) return ( @@ -140,6 +123,7 @@ export function ODDRecoveryOptions({ }} isSelected={recoveryOption === selectedRoute} radioButtonType="large" + largeDesktopBorderRadius={!isOnDevice} /> ) })} @@ -147,38 +131,16 @@ export function ODDRecoveryOptions({ ) } -export function DesktopRecoveryOptions({ - errorKind, - validRecoveryOptions, - selectedRoute, - setSelectedRoute, - getRecoveryOptionCopy, -}: RecoveryOptionsProps): JSX.Element { - return ( - { - setSelectedRoute(e.currentTarget.value) - }} - value={selectedRoute} - options={validRecoveryOptions.map( - (option: RecoveryRoute) => - ({ - value: option, - children: ( - - {getRecoveryOptionCopy(option, errorKind)} - - ), - } as const) - )} - /> - ) -} +const RECOVERY_OPTION_CONTAINER_STYLE = css` + flex-direction: ${DIRECTION_COLUMN}; + grid-gap: ${SPACING.spacing4}; + width: 100%; + + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + grid-gap: ${SPACING.spacing8}; + } +` + // Pre-fetch tip attachment status. Users are not blocked from proceeding at this step. export function useCurrentTipStatus( determineTipStatus: () => Promise @@ -254,7 +216,3 @@ export const GENERAL_ERROR_OPTIONS: RecoveryRoute[] = [ RECOVERY_MAP.RETRY_STEP.ROUTE, RECOVERY_MAP.CANCEL_RUN.ROUTE, ] - -const RADIO_GAP = ` - gap: ${SPACING.spacing4}; -` diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/SelectRecoveryOptions.test.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/SelectRecoveryOptions.test.tsx index 0d9fae0f958..a0dd0c778ca 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/SelectRecoveryOptions.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/SelectRecoveryOptions.test.tsx @@ -8,8 +8,7 @@ import { i18n } from '/app/i18n' import { mockRecoveryContentProps } from '../../__fixtures__' import { SelectRecoveryOption, - ODDRecoveryOptions, - DesktopRecoveryOptions, + RecoveryOptions, getRecoveryOptions, GENERAL_ERROR_OPTIONS, OVERPRESSURE_WHILE_ASPIRATING_OPTIONS, @@ -36,21 +35,13 @@ const renderSelectRecoveryOption = ( )[0] } -const renderODDRecoveryOptions = ( - props: React.ComponentProps +const renderRecoveryOptions = ( + props: React.ComponentProps ) => { - return renderWithProviders(, { + return renderWithProviders(, { i18nInstance: i18n, })[0] } -const renderDesktopRecoveryOptions = ( - props: React.ComponentProps -) => { - return renderWithProviders(, { - i18nInstance: i18n, - })[0] -} - describe('SelectRecoveryOption', () => { const { RETRY_STEP, RETRY_NEW_TIPS } = RECOVERY_MAP let props: React.ComponentProps @@ -241,194 +232,188 @@ describe('SelectRecoveryOption', () => { ) }) }) -;([ - ['desktop', renderDesktopRecoveryOptions] as const, - ['odd', renderODDRecoveryOptions] as const, -] as const).forEach(([target, renderer]) => { - describe(`RecoveryOptions on ${target}`, () => { - let props: React.ComponentProps - let mockSetSelectedRoute: Mock - let mockGetRecoveryOptionCopy: Mock - - beforeEach(() => { - mockSetSelectedRoute = vi.fn() - mockGetRecoveryOptionCopy = vi.fn() - const generalRecoveryOptions = getRecoveryOptions( - ERROR_KINDS.GENERAL_ERROR +describe('RecoveryOptions', () => { + let props: React.ComponentProps + let mockSetSelectedRoute: Mock + let mockGetRecoveryOptionCopy: Mock + + beforeEach(() => { + mockSetSelectedRoute = vi.fn() + mockGetRecoveryOptionCopy = vi.fn() + const generalRecoveryOptions = getRecoveryOptions(ERROR_KINDS.GENERAL_ERROR) + + props = { + errorKind: ERROR_KINDS.GENERAL_ERROR, + validRecoveryOptions: generalRecoveryOptions, + setSelectedRoute: mockSetSelectedRoute, + getRecoveryOptionCopy: mockGetRecoveryOptionCopy, + isOnDevice: true, + } + + when(mockGetRecoveryOptionCopy) + .calledWith(RECOVERY_MAP.RETRY_STEP.ROUTE, expect.any(String)) + .thenReturn('Retry step') + when(mockGetRecoveryOptionCopy) + .calledWith(RECOVERY_MAP.RETRY_STEP.ROUTE, ERROR_KINDS.TIP_DROP_FAILED) + .thenReturn('Retry dropping tip') + when(mockGetRecoveryOptionCopy) + .calledWith(RECOVERY_MAP.CANCEL_RUN.ROUTE, expect.any(String)) + .thenReturn('Cancel run') + when(mockGetRecoveryOptionCopy) + .calledWith(RECOVERY_MAP.RETRY_NEW_TIPS.ROUTE, expect.any(String)) + .thenReturn('Retry with new tips') + when(mockGetRecoveryOptionCopy) + .calledWith(RECOVERY_MAP.MANUAL_FILL_AND_SKIP.ROUTE, expect.any(String)) + .thenReturn('Manually fill well and skip to next step') + when(mockGetRecoveryOptionCopy) + .calledWith(RECOVERY_MAP.RETRY_SAME_TIPS.ROUTE, expect.any(String)) + .thenReturn('Retry with same tips') + when(mockGetRecoveryOptionCopy) + .calledWith( + RECOVERY_MAP.SKIP_STEP_WITH_SAME_TIPS.ROUTE, + expect.any(String) ) + .thenReturn('Skip to next step with same tips') + when(mockGetRecoveryOptionCopy) + .calledWith( + RECOVERY_MAP.SKIP_STEP_WITH_NEW_TIPS.ROUTE, + expect.any(String) + ) + .thenReturn('Skip to next step with new tips') + when(mockGetRecoveryOptionCopy) + .calledWith(RECOVERY_MAP.IGNORE_AND_SKIP.ROUTE, expect.any(String)) + .thenReturn('Ignore error and skip to next step') + when(mockGetRecoveryOptionCopy) + .calledWith(RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE, expect.any(String)) + .thenReturn('Manually move labware and skip to next step') + when(mockGetRecoveryOptionCopy) + .calledWith( + RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.ROUTE, + expect.any(String) + ) + .thenReturn('Manually replace labware on deck and retry step') + }) - props = { - errorKind: ERROR_KINDS.GENERAL_ERROR, - validRecoveryOptions: generalRecoveryOptions, - setSelectedRoute: mockSetSelectedRoute, - getRecoveryOptionCopy: mockGetRecoveryOptionCopy, - } - - when(mockGetRecoveryOptionCopy) - .calledWith(RECOVERY_MAP.RETRY_STEP.ROUTE, expect.any(String)) - .thenReturn('Retry step') - when(mockGetRecoveryOptionCopy) - .calledWith(RECOVERY_MAP.RETRY_STEP.ROUTE, ERROR_KINDS.TIP_DROP_FAILED) - .thenReturn('Retry dropping tip') - when(mockGetRecoveryOptionCopy) - .calledWith(RECOVERY_MAP.CANCEL_RUN.ROUTE, expect.any(String)) - .thenReturn('Cancel run') - when(mockGetRecoveryOptionCopy) - .calledWith(RECOVERY_MAP.RETRY_NEW_TIPS.ROUTE, expect.any(String)) - .thenReturn('Retry with new tips') - when(mockGetRecoveryOptionCopy) - .calledWith(RECOVERY_MAP.MANUAL_FILL_AND_SKIP.ROUTE, expect.any(String)) - .thenReturn('Manually fill well and skip to next step') - when(mockGetRecoveryOptionCopy) - .calledWith(RECOVERY_MAP.RETRY_SAME_TIPS.ROUTE, expect.any(String)) - .thenReturn('Retry with same tips') - when(mockGetRecoveryOptionCopy) - .calledWith( - RECOVERY_MAP.SKIP_STEP_WITH_SAME_TIPS.ROUTE, - expect.any(String) - ) - .thenReturn('Skip to next step with same tips') - when(mockGetRecoveryOptionCopy) - .calledWith( - RECOVERY_MAP.SKIP_STEP_WITH_NEW_TIPS.ROUTE, - expect.any(String) - ) - .thenReturn('Skip to next step with new tips') - when(mockGetRecoveryOptionCopy) - .calledWith(RECOVERY_MAP.IGNORE_AND_SKIP.ROUTE, expect.any(String)) - .thenReturn('Ignore error and skip to next step') - when(mockGetRecoveryOptionCopy) - .calledWith(RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE, expect.any(String)) - .thenReturn('Manually move labware and skip to next step') - when(mockGetRecoveryOptionCopy) - .calledWith( - RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.ROUTE, - expect.any(String) - ) - .thenReturn('Manually replace labware on deck and retry step') - }) + it('renders valid recovery options for a general error errorKind', () => { + renderRecoveryOptions(props) - it('renders valid recovery options for a general error errorKind', () => { - renderer(props) + screen.getByRole('label', { name: 'Retry step' }) + screen.getByRole('label', { name: 'Cancel run' }) + }) - screen.getByRole('label', { name: 'Retry step' }) - screen.getByRole('label', { name: 'Cancel run' }) - }) + it(`renders valid recovery options for a ${ERROR_KINDS.OVERPRESSURE_WHILE_ASPIRATING} errorKind`, () => { + props = { + ...props, + validRecoveryOptions: OVERPRESSURE_WHILE_ASPIRATING_OPTIONS, + } - it(`renders valid recovery options for a ${ERROR_KINDS.OVERPRESSURE_WHILE_ASPIRATING} errorKind`, () => { - props = { - ...props, - validRecoveryOptions: OVERPRESSURE_WHILE_ASPIRATING_OPTIONS, - } + renderRecoveryOptions(props) - renderer(props) + screen.getByRole('label', { name: 'Retry with new tips' }) + screen.getByRole('label', { name: 'Cancel run' }) + }) - screen.getByRole('label', { name: 'Retry with new tips' }) - screen.getByRole('label', { name: 'Cancel run' }) - }) + it('updates the selectedRoute when a new option is selected', () => { + renderRecoveryOptions(props) - it('updates the selectedRoute when a new option is selected', () => { - renderer(props) + fireEvent.click(screen.getByRole('label', { name: 'Cancel run' })) - fireEvent.click(screen.getByRole('label', { name: 'Cancel run' })) + expect(mockSetSelectedRoute).toHaveBeenCalledWith( + RECOVERY_MAP.CANCEL_RUN.ROUTE + ) + }) - expect(mockSetSelectedRoute).toHaveBeenCalledWith( - RECOVERY_MAP.CANCEL_RUN.ROUTE - ) + it(`renders valid recovery options for a ${ERROR_KINDS.NO_LIQUID_DETECTED} errorKind`, () => { + props = { + ...props, + validRecoveryOptions: NO_LIQUID_DETECTED_OPTIONS, + } + + renderRecoveryOptions(props) + + screen.getByRole('label', { + name: 'Manually fill well and skip to next step', }) + screen.getByRole('label', { name: 'Ignore error and skip to next step' }) + screen.getByRole('label', { name: 'Cancel run' }) + }) - it(`renders valid recovery options for a ${ERROR_KINDS.NO_LIQUID_DETECTED} errorKind`, () => { - props = { - ...props, - validRecoveryOptions: NO_LIQUID_DETECTED_OPTIONS, - } + it(`renders valid recovery options for a ${ERROR_KINDS.OVERPRESSURE_PREPARE_TO_ASPIRATE} errorKind`, () => { + props = { + ...props, + validRecoveryOptions: OVERPRESSURE_PREPARE_TO_ASPIRATE, + } - renderer(props) + renderRecoveryOptions(props) - screen.getByRole('label', { - name: 'Manually fill well and skip to next step', - }) - screen.getByRole('label', { name: 'Ignore error and skip to next step' }) - screen.getByRole('label', { name: 'Cancel run' }) - }) + screen.getByRole('label', { name: 'Retry with new tips' }) + screen.getByRole('label', { name: 'Retry with same tips' }) + screen.getByRole('label', { name: 'Cancel run' }) + }) + + it(`renders valid recovery options for a ${ERROR_KINDS.OVERPRESSURE_WHILE_DISPENSING} errorKind`, () => { + props = { + ...props, + validRecoveryOptions: OVERPRESSURE_WHILE_DISPENSING_OPTIONS, + } + + renderRecoveryOptions(props) + + screen.getByRole('label', { name: 'Skip to next step with same tips' }) + screen.getByRole('label', { name: 'Skip to next step with new tips' }) + screen.getByRole('label', { name: 'Cancel run' }) + }) - it(`renders valid recovery options for a ${ERROR_KINDS.OVERPRESSURE_PREPARE_TO_ASPIRATE} errorKind`, () => { - props = { - ...props, - validRecoveryOptions: OVERPRESSURE_PREPARE_TO_ASPIRATE, - } + it(`renders valid recovery options for a ${ERROR_KINDS.TIP_NOT_DETECTED} errorKind`, () => { + props = { + ...props, + validRecoveryOptions: TIP_NOT_DETECTED_OPTIONS, + } - renderer(props) + renderRecoveryOptions(props) - screen.getByRole('label', { name: 'Retry with new tips' }) - screen.getByRole('label', { name: 'Retry with same tips' }) - screen.getByRole('label', { name: 'Cancel run' }) + screen.getByRole('label', { + name: 'Retry step', }) + screen.getByRole('label', { + name: 'Ignore error and skip to next step', + }) + screen.getByRole('label', { name: 'Cancel run' }) + }) - it(`renders valid recovery options for a ${ERROR_KINDS.OVERPRESSURE_WHILE_DISPENSING} errorKind`, () => { - props = { - ...props, - validRecoveryOptions: OVERPRESSURE_WHILE_DISPENSING_OPTIONS, - } + it(`renders valid recovery options for a ${ERROR_KINDS.TIP_DROP_FAILED} errorKind`, () => { + props = { + ...props, + errorKind: ERROR_KINDS.TIP_DROP_FAILED, + validRecoveryOptions: TIP_DROP_FAILED_OPTIONS, + } - renderer(props) + renderRecoveryOptions(props) - screen.getByRole('label', { name: 'Skip to next step with same tips' }) - screen.getByRole('label', { name: 'Skip to next step with new tips' }) - screen.getByRole('label', { name: 'Cancel run' }) + screen.getByRole('label', { + name: 'Retry dropping tip', }) - - it(`renders valid recovery options for a ${ERROR_KINDS.TIP_NOT_DETECTED} errorKind`, () => { - props = { - ...props, - validRecoveryOptions: TIP_NOT_DETECTED_OPTIONS, - } - - renderer(props) - - screen.getByRole('label', { - name: 'Retry step', - }) - screen.getByRole('label', { - name: 'Ignore error and skip to next step', - }) - screen.getByRole('label', { name: 'Cancel run' }) + screen.getByRole('label', { + name: 'Ignore error and skip to next step', }) + screen.getByRole('label', { name: 'Cancel run' }) + }) - it(`renders valid recovery options for a ${ERROR_KINDS.TIP_DROP_FAILED} errorKind`, () => { - props = { - ...props, - errorKind: ERROR_KINDS.TIP_DROP_FAILED, - validRecoveryOptions: TIP_DROP_FAILED_OPTIONS, - } - - renderer(props) - - screen.getByRole('label', { - name: 'Retry dropping tip', - }) - screen.getByRole('label', { - name: 'Ignore error and skip to next step', - }) - screen.getByRole('label', { name: 'Cancel run' }) - }) + it(`renders valid recovery options for a ${ERROR_KINDS.GRIPPER_ERROR} errorKind`, () => { + props = { + ...props, + validRecoveryOptions: GRIPPER_ERROR_OPTIONS, + } + + renderRecoveryOptions(props) - it(`renders valid recovery options for a ${ERROR_KINDS.GRIPPER_ERROR} errorKind`, () => { - props = { - ...props, - validRecoveryOptions: GRIPPER_ERROR_OPTIONS, - } - - renderer(props) - - screen.getByRole('label', { - name: 'Manually move labware and skip to next step', - }) - screen.getByRole('label', { - name: 'Manually replace labware on deck and retry step', - }) - screen.getByRole('label', { name: 'Cancel run' }) + screen.getByRole('label', { + name: 'Manually move labware and skip to next step', + }) + screen.getByRole('label', { + name: 'Manually replace labware on deck and retry step', }) + screen.getByRole('label', { name: 'Cancel run' }) }) }) diff --git a/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx b/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx index b4fda69bd13..d73d402585d 100644 --- a/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx @@ -8,7 +8,7 @@ import { RUN_STATUS_RUNNING, RUN_STATUS_STOP_REQUESTED, } from '@opentrons/api-client' -import { getLabwareDefinitionsFromCommands } from '/app/molecules/Command' +import { getLoadedLabwareDefinitionsByUri } from '@opentrons/shared-data' import { renderWithProviders } from '/app/__testing-utils__' import { i18n } from '/app/i18n' @@ -33,7 +33,13 @@ vi.mock('/app/redux/config') vi.mock('../RecoverySplash') vi.mock('/app/redux-resources/analytics') vi.mock('@opentrons/react-api-client') -vi.mock('/app/molecules/Command') +vi.mock('@opentrons/shared-data', async () => { + const actual = await vi.importActual('@opentrons/shared-data') + return { + ...actual, + getLoadedLabwareDefinitionsByUri: vi.fn(), + } +}) vi.mock('react-redux', async () => { const actual = await vi.importActual('react-redux') return { @@ -45,7 +51,6 @@ vi.mock('react-redux', async () => { describe('useErrorRecoveryFlows', () => { beforeEach(() => { vi.mocked(useCurrentlyRecoveringFrom).mockReturnValue('mockCommand' as any) - vi.mocked(getLabwareDefinitionsFromCommands).mockReturnValue([]) }) it('should have initial state of isERActive as false', () => { @@ -143,7 +148,7 @@ describe('ErrorRecoveryFlows', () => { runStatus: RUN_STATUS_AWAITING_RECOVERY, failedCommandByRunRecord: mockFailedCommand, runId: 'MOCK_RUN_ID', - protocolAnalysis: {} as any, + protocolAnalysis: null, } vi.mocked(ErrorRecoveryWizard).mockReturnValue(
MOCK WIZARD
) vi.mocked(RecoverySplash).mockReturnValue(
MOCK RUN PAUSED SPLASH
) @@ -168,6 +173,7 @@ describe('ErrorRecoveryFlows', () => { intent: 'recovering', showTakeover: false, }) + vi.mocked(getLoadedLabwareDefinitionsByUri).mockReturnValue({}) }) it('renders the wizard when showERWizard is true', () => { diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.tsx similarity index 75% rename from app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.ts rename to app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.tsx index a98818b6efd..b0716af5c8a 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.tsx @@ -1,6 +1,8 @@ import { describe, it, expect } from 'vitest' -import { renderHook } from '@testing-library/react' +import { screen, renderHook } from '@testing-library/react' +import { renderWithProviders } from '/app/__testing-utils__' +import { i18n } from '/app/i18n' import { getRelevantWellName, getRelevantFailedLabwareCmdFrom, @@ -8,6 +10,9 @@ import { } from '../useFailedLabwareUtils' import { DEFINED_ERROR_TYPES } from '../../constants' +import type { ComponentProps } from 'react' +import type { GetRelevantLwLocationsParams } from '../useFailedLabwareUtils' + describe('getRelevantWellName', () => { const failedPipetteInfo = { data: { @@ -159,12 +164,26 @@ describe('getRelevantFailedLabwareCmdFrom', () => { }) }) -// TODO(jh 10-15-24): This testing will can more useful once translation is refactored out of this function. +const TestWrapper = (props: GetRelevantLwLocationsParams) => { + const displayLocation = useRelevantFailedLwLocations(props) + return ( + <> +
{`Current Loc: ${displayLocation.displayNameCurrentLoc}`}
+
{`New Loc: ${displayLocation.displayNameNewLoc}`}
+ + ) +} + +const render = (props: ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + describe('useRelevantFailedLwLocations', () => { - const mockProtocolAnalysis = {} as any - const mockAllRunDefs = [] as any + const mockRunRecord = { data: { modules: [], labware: [] } } as any const mockFailedLabware = { - location: { slot: 'D1' }, + location: { slotName: 'D1' }, } as any it('should return current location for non-moveLabware commands', () => { @@ -172,41 +191,53 @@ describe('useRelevantFailedLwLocations', () => { commandType: 'aspirate', } as any + render({ + failedLabware: mockFailedLabware, + failedCommandByRunRecord: mockFailedCommand, + runRecord: mockRunRecord, + }) + + screen.getByText('Current Loc: Slot D1') + screen.getByText('New Loc: null') + const { result } = renderHook(() => useRelevantFailedLwLocations({ failedLabware: mockFailedLabware, failedCommandByRunRecord: mockFailedCommand, - protocolAnalysis: mockProtocolAnalysis, - allRunDefs: mockAllRunDefs, + runRecord: mockRunRecord, }) ) - expect(result.current).toEqual({ - currentLoc: '', - newLoc: null, - }) + expect(result.current.currentLoc).toStrictEqual({ slotName: 'D1' }) + expect(result.current.newLoc).toBeNull() }) - it('should return current and new location for moveLabware commands', () => { + it('should return current and new locations for moveLabware commands', () => { const mockFailedCommand = { commandType: 'moveLabware', params: { - newLocation: { slot: 'C2' }, + newLocation: { slotName: 'C2' }, }, } as any + render({ + failedLabware: mockFailedLabware, + failedCommandByRunRecord: mockFailedCommand, + runRecord: mockRunRecord, + }) + + screen.getByText('Current Loc: Slot D1') + screen.getByText('New Loc: Slot C2') + const { result } = renderHook(() => useRelevantFailedLwLocations({ failedLabware: mockFailedLabware, failedCommandByRunRecord: mockFailedCommand, - protocolAnalysis: mockProtocolAnalysis, - allRunDefs: mockAllRunDefs, + runRecord: mockRunRecord, }) ) - expect(result.current).toEqual({ - currentLoc: '', - newLoc: '', - }) + expect(result.current.currentLoc).toStrictEqual({ slotName: 'D1' }) + expect(result.current.newLoc).toStrictEqual({ slotName: 'C2' }) }) }) diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryToasts.test.tsx b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryToasts.test.tsx index 7c6b3b74065..c572618bbcc 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryToasts.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryToasts.test.tsx @@ -14,13 +14,13 @@ import { } from '../useRecoveryToasts' import { RECOVERY_MAP } from '../../constants' import { useToaster } from '../../../ToasterOven' -import { useCommandTextString } from '/app/molecules/Command' +import { useCommandTextString } from '/app/local-resources/commands' import type { Mock } from 'vitest' import type { BuildToast } from '../useRecoveryToasts' vi.mock('../../../ToasterOven') -vi.mock('/app/molecules/Command') +vi.mock('/app/local-resources/commands') const TEST_COMMAND = 'test command' const TC_COMMAND = diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useDeckMapUtils.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useDeckMapUtils.ts index 95dac5abdb7..06453d06d08 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useDeckMapUtils.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useDeckMapUtils.ts @@ -2,7 +2,6 @@ import { useMemo } from 'react' import { getDeckDefFromRobotType, - getLoadedLabwareDefinitionsByUri, getFixedTrashLabwareDefinition, getModuleDef2, getPositionFromSlotId, @@ -11,6 +10,11 @@ import { THERMOCYCLER_MODULE_V1, } from '@opentrons/shared-data' +import { + getRunLabwareRenderInfo, + getRunModuleRenderInfo, +} from '/app/organisms/InterventionModal/utils' + import type { Run } from '@opentrons/api-client' import type { DeckDefinition, @@ -22,14 +26,21 @@ import type { LoadedLabware, RobotType, LabwareDefinitionsByUri, + LoadedModule, } from '@opentrons/shared-data' import type { ErrorRecoveryFlowsProps } from '..' import type { UseFailedLabwareUtilsResult } from './useFailedLabwareUtils' +import type { + RunLabwareInfo, + RunModuleInfo, +} from '/app/organisms/InterventionModal/utils' +import type { ERUtilsProps } from './useERUtils' interface UseDeckMapUtilsProps { runId: ErrorRecoveryFlowsProps['runId'] protocolAnalysis: ErrorRecoveryFlowsProps['protocolAnalysis'] failedLabwareUtils: UseFailedLabwareUtilsResult + labwareDefinitionsByUri: ERUtilsProps['labwareDefinitionsByUri'] runRecord?: Run } @@ -37,6 +48,11 @@ export interface UseDeckMapUtilsResult { deckConfig: CutoutConfigProtocolSpec[] modulesOnDeck: RunCurrentModulesOnDeck[] labwareOnDeck: RunCurrentLabwareOnDeck[] + loadedLabware: LoadedLabware[] + loadedModules: LoadedModule[] + movedLabwareDef: LabwareDefinition2 | null + moduleRenderInfo: RunModuleInfo[] + labwareRenderInfo: RunLabwareInfo[] highlightLabwareEventuallyIn: string[] kind: 'intervention' robotType: RobotType @@ -47,19 +63,12 @@ export function useDeckMapUtils({ runRecord, runId, failedLabwareUtils, + labwareDefinitionsByUri, }: UseDeckMapUtilsProps): UseDeckMapUtilsResult { const robotType = protocolAnalysis?.robotType ?? OT2_ROBOT_TYPE const deckConfig = getSimplestDeckConfigForProtocol(protocolAnalysis) const deckDef = getDeckDefFromRobotType(robotType) - const labwareDefinitionsByUri = useMemo( - () => - protocolAnalysis != null - ? getLoadedLabwareDefinitionsByUri(protocolAnalysis?.commands) - : null, - [protocolAnalysis] - ) - const currentModulesInfo = useMemo( () => getRunCurrentModulesInfo({ @@ -93,6 +102,35 @@ export function useDeckMapUtils({ [runId, protocolAnalysis, runRecord, deckDef, failedLabwareUtils] ) + const movedLabwareDef = + labwareDefinitionsByUri != null && failedLabwareUtils.failedLabware != null + ? labwareDefinitionsByUri[failedLabwareUtils.failedLabware.definitionUri] + : null + + const moduleRenderInfo = useMemo( + () => + runRecord != null && labwareDefinitionsByUri != null + ? getRunModuleRenderInfo( + runRecord.data, + deckDef, + labwareDefinitionsByUri + ) + : [], + [deckDef, labwareDefinitionsByUri, runRecord] + ) + + const labwareRenderInfo = useMemo( + () => + runRecord != null && labwareDefinitionsByUri != null + ? getRunLabwareRenderInfo( + runRecord.data, + labwareDefinitionsByUri, + deckDef + ) + : [], + [deckDef, labwareDefinitionsByUri, runRecord] + ) + return { deckConfig, modulesOnDeck: runCurrentModules.map( @@ -112,6 +150,11 @@ export function useDeckMapUtils({ .filter(maybeSlot => maybeSlot != null) as string[], kind: 'intervention', robotType, + loadedModules: runRecord?.data.modules ?? [], + loadedLabware: runRecord?.data.labware ?? [], + movedLabwareDef, + moduleRenderInfo, + labwareRenderInfo, } } diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts index 365bf01de36..57691a30e55 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts @@ -20,7 +20,11 @@ import { useShowDoorInfo } from './useShowDoorInfo' import { useCleanupRecoveryState } from './useCleanupRecoveryState' import { useFailedPipetteUtils } from './useFailedPipetteUtils' -import type { LabwareDefinition2, RobotType } from '@opentrons/shared-data' +import type { + LabwareDefinition2, + LabwareDefinitionsByUri, + RobotType, +} from '@opentrons/shared-data' import type { IRecoveryMap, RouteStep, RecoveryRoute } from '../types' import type { ErrorRecoveryFlowsProps } from '..' import type { UseRouteUpdateActionsResult } from './useRouteUpdateActions' @@ -48,6 +52,7 @@ export type ERUtilsProps = Omit & { failedCommand: ReturnType showTakeover: boolean allRunDefs: LabwareDefinition2[] + labwareDefinitionsByUri: LabwareDefinitionsByUri | null } export interface ERUtilsResults { @@ -82,6 +87,7 @@ export function useERUtils({ runStatus, showTakeover, allRunDefs, + labwareDefinitionsByUri, }: ERUtilsProps): ERUtilsResults { const { data: attachedInstruments } = useInstrumentsQuery() const { data: runRecord } = useNotifyRunQuery(runId) @@ -151,7 +157,6 @@ export function useERUtils({ failedPipetteInfo, runRecord, runCommands, - allRunDefs, }) const recoveryCommands = useRecoveryCommands({ @@ -169,6 +174,7 @@ export function useERUtils({ runRecord, protocolAnalysis, failedLabwareUtils, + labwareDefinitionsByUri, }) const recoveryActionMutationUtils = useRecoveryActionMutation( diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts index ba86e77c553..d108bfb7d0a 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts @@ -11,10 +11,11 @@ import { import { ERROR_KINDS } from '../constants' import { getErrorKind } from '../utils' -import { getLoadedLabware } from '/app/molecules/Command/utils/accessors' -import { getLabwareDisplayLocation } from '/app/molecules/Command' +import { + getLoadedLabware, + getLabwareDisplayLocation, +} from '/app/local-resources/labware' -import type { TFunction } from 'i18next' import type { WellGroup } from '@opentrons/components' import type { CommandsData, PipetteData, Run } from '@opentrons/api-client' import type { @@ -27,6 +28,7 @@ import type { MoveLabwareRunTimeCommand, LabwareLocation, } from '@opentrons/shared-data' +import type { LabwareDisplayLocationSlotOnly } from '/app/local-resources/labware' import type { ErrorRecoveryFlowsProps } from '..' import type { ERUtilsProps } from './useERUtils' @@ -34,14 +36,15 @@ interface UseFailedLabwareUtilsProps { failedCommandByRunRecord: ERUtilsProps['failedCommandByRunRecord'] protocolAnalysis: ErrorRecoveryFlowsProps['protocolAnalysis'] failedPipetteInfo: PipetteData | null - allRunDefs: LabwareDefinition2[] runCommands?: CommandsData runRecord?: Run } interface RelevantFailedLabwareLocations { - currentLoc: string - newLoc: string | null + displayNameCurrentLoc: string + displayNameNewLoc: string | null + currentLoc: LabwareLocation | null + newLoc: LabwareLocation | null } export type UseFailedLabwareUtilsResult = UseTipSelectionUtilsResult & { @@ -53,6 +56,7 @@ export type UseFailedLabwareUtilsResult = UseTipSelectionUtilsResult & { relevantWellName: string | null /* The user-content nickname of the failed labware, if any */ failedLabwareNickname: string | null + /* Details relating to the labware location. */ failedLabwareLocations: RelevantFailedLabwareLocations } @@ -68,7 +72,6 @@ export function useFailedLabwareUtils({ failedPipetteInfo, runCommands, runRecord, - allRunDefs, }: UseFailedLabwareUtilsProps): UseFailedLabwareUtilsResult { const recentRelevantFailedLabwareCmd = useMemo( () => @@ -104,8 +107,7 @@ export function useFailedLabwareUtils({ const failedLabwareLocations = useRelevantFailedLwLocations({ failedLabware, failedCommandByRunRecord, - protocolAnalysis, - allRunDefs, + runRecord, }) return { @@ -281,7 +283,7 @@ export function getFailedCmdRelevantLabware( const labwareNickname = protocolAnalysis != null ? getLoadedLabware( - protocolAnalysis, + protocolAnalysis.labware, recentRelevantFailedLabwareCmd?.params.labwareId || '' )?.displayName ?? null : null @@ -336,9 +338,9 @@ export function getRelevantWellName( } } -type GetRelevantLwLocationsParams = Pick< +export type GetRelevantLwLocationsParams = Pick< UseFailedLabwareUtilsProps, - 'protocolAnalysis' | 'failedCommandByRunRecord' | 'allRunDefs' + 'runRecord' | 'failedCommandByRunRecord' > & { failedLabware: UseFailedLabwareUtilsResult['failedLabware'] } @@ -346,43 +348,51 @@ type GetRelevantLwLocationsParams = Pick< export function useRelevantFailedLwLocations({ failedLabware, failedCommandByRunRecord, - protocolAnalysis, - allRunDefs, + runRecord, }: GetRelevantLwLocationsParams): RelevantFailedLabwareLocations { const { t } = useTranslation('protocol_command_text') - const canGetDisplayLocation = - protocolAnalysis != null && failedLabware != null - - const buildLocationCopy = useMemo(() => { - return (location: LabwareLocation | undefined): string | null => { - return canGetDisplayLocation && location != null - ? getLabwareDisplayLocation( - protocolAnalysis, - allRunDefs, - location, - t as TFunction, - FLEX_ROBOT_TYPE, - false // Always return the "full" copy, which is the desktop copy. - ) - : null - } - }, [canGetDisplayLocation, allRunDefs]) - const currentLocation = useMemo(() => { - return buildLocationCopy(failedLabware?.location) ?? '' - }, [canGetDisplayLocation]) + const BASE_DISPLAY_PARAMS: Omit< + LabwareDisplayLocationSlotOnly, + 'location' + > = { + loadedLabwares: runRecord?.data?.labware ?? [], + loadedModules: runRecord?.data?.modules ?? [], + robotType: FLEX_ROBOT_TYPE, + t, + detailLevel: 'slot-only', + isOnDevice: false, // Always return the "slot XYZ" copy, which is the desktop copy. + } + + const displayNameCurrentLoc = getLabwareDisplayLocation({ + ...BASE_DISPLAY_PARAMS, + location: failedLabware?.location ?? null, + }) - const newLocation = useMemo(() => { + const getNewLocation = (): Pick< + RelevantFailedLabwareLocations, + 'displayNameNewLoc' | 'newLoc' + > => { switch (failedCommandByRunRecord?.commandType) { case 'moveLabware': - return buildLocationCopy(failedCommandByRunRecord.params.newLocation) + return { + displayNameNewLoc: getLabwareDisplayLocation({ + ...BASE_DISPLAY_PARAMS, + location: failedCommandByRunRecord.params.newLocation, + }), + newLoc: failedCommandByRunRecord.params.newLocation, + } default: - return null + return { + displayNameNewLoc: null, + newLoc: null, + } } - }, [canGetDisplayLocation, failedCommandByRunRecord?.key]) + } return { - currentLoc: currentLocation, - newLoc: newLocation, + displayNameCurrentLoc, + currentLoc: failedLabware?.location ?? null, + ...getNewLocation(), } } diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryToasts.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryToasts.ts index ed5aaaeaae5..2edf732bfdd 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryToasts.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryToasts.ts @@ -2,11 +2,11 @@ import { useTranslation } from 'react-i18next' import { useToaster } from '../../ToasterOven' import { RECOVERY_MAP } from '../constants' -import { useCommandTextString } from '/app/molecules/Command' +import { useCommandTextString } from '/app/local-resources/commands' import type { StepCounts } from '/app/resources/protocols/hooks' import type { CurrentRecoveryOptionUtils } from './useRecoveryRouting' -import type { UseCommandTextStringParams } from '/app/molecules/Command' +import type { UseCommandTextStringParams } from '/app/local-resources/commands' export type BuildToast = Omit & { isOnDevice: boolean diff --git a/app/src/organisms/ErrorRecoveryFlows/index.tsx b/app/src/organisms/ErrorRecoveryFlows/index.tsx index a447df2dafe..124c4fea65f 100644 --- a/app/src/organisms/ErrorRecoveryFlows/index.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/index.tsx @@ -13,7 +13,10 @@ import { RUN_STATUS_STOP_REQUESTED, RUN_STATUS_SUCCEEDED, } from '@opentrons/api-client' -import { OT2_ROBOT_TYPE } from '@opentrons/shared-data' +import { + getLoadedLabwareDefinitionsByUri, + OT2_ROBOT_TYPE, +} from '@opentrons/shared-data' import { useHost } from '@opentrons/react-api-client' import { getIsOnDevice } from '/app/redux/config' @@ -30,7 +33,6 @@ import { import type { RunStatus } from '@opentrons/api-client' import type { CompletedProtocolAnalysis } from '@opentrons/shared-data' import type { FailedCommand } from './types' -import { getLabwareDefinitionsFromCommands } from '/app/molecules/Command' const VALID_ER_RUN_STATUSES: RunStatus[] = [ RUN_STATUS_AWAITING_RECOVERY, @@ -127,13 +129,19 @@ export function ErrorRecoveryFlows( const robotName = useHost()?.robotName ?? 'robot' const isValidRobotSideAnalysis = protocolAnalysis != null - const allRunDefs = useMemo( + + // TODO(jh, 10-22-24): EXEC-769. + const labwareDefinitionsByUri = useMemo( () => protocolAnalysis != null - ? getLabwareDefinitionsFromCommands(protocolAnalysis.commands) - : [], + ? getLoadedLabwareDefinitionsByUri(protocolAnalysis?.commands) + : null, [isValidRobotSideAnalysis] ) + const allRunDefs = + labwareDefinitionsByUri != null + ? Object.values(labwareDefinitionsByUri) + : [] const { showTakeover, @@ -151,6 +159,7 @@ export function ErrorRecoveryFlows( showTakeover, failedCommand: failedCommandBySource, allRunDefs, + labwareDefinitionsByUri, }) const renderWizard = diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/GripperReleaseLabware.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/GripperReleaseLabware.tsx index f57357f6451..3e0c24756d2 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/GripperReleaseLabware.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/GripperReleaseLabware.tsx @@ -7,6 +7,7 @@ import { RESPONSIVENESS, Flex, StyledText, + JUSTIFY_CENTER, } from '@opentrons/components' import { TwoColumn } from '/app/molecules/InterventionModal' @@ -15,6 +16,8 @@ import { RECOVERY_MAP } from '/app/organisms/ErrorRecoveryFlows/constants' import { RecoveryFooterButtons } from './RecoveryFooterButtons' import { RecoverySingleColumnContentWrapper } from './RecoveryContentWrapper' +import gripperReleaseAnimation from '/app/assets/videos/error-recovery/Gripper_Release.webm' + import type { JSX } from 'react' import type { RecoveryContentProps } from '../types' @@ -51,7 +54,20 @@ export function GripperReleaseLabware({ heading={t('labware_released_from_current_height')} />
-
ANIMATION GOES HERE
+ + + ['infoProps']['newLocationProps'] => - newLoc != null ? { deckLabel: newLoc.toUpperCase() } : undefined + displayNameNewLoc != null + ? { deckLabel: displayNameNewLoc.toUpperCase() } + : undefined return ( { + switch (selectedRecoveryOption) { + case MANUAL_MOVE_AND_SKIP.ROUTE: { + const { newLoc, currentLoc } = failedLabwareUtils.failedLabwareLocations + const { + movedLabwareDef, + moduleRenderInfo, + labwareRenderInfo, + ...restUtils + } = deckMapUtils + + const failedLwId = failedLabware?.id ?? '' + + const isValidDeck = + currentLoc != null && newLoc != null && movedLabwareDef != null + + return isValidDeck ? ( + + {moduleRenderInfo.map( + ({ + x, + y, + moduleId, + moduleDef, + nestedLabwareDef, + nestedLabwareId, + }) => ( + + {nestedLabwareDef != null && + nestedLabwareId !== failedLwId ? ( + + ) : null} + + ) + )} + {labwareRenderInfo + .filter(l => l.labwareId !== failedLwId) + .map(({ x, y, labwareDef, labwareId }) => ( + + {labwareDef != null && labwareId !== failedLwId ? ( + + ) : null} + + ))} + + } + /> + ) : ( + + ) + } + default: + return + } + } + return ( @@ -109,9 +187,7 @@ export function TwoColLwInfoAndDeck( type={buildType()} bannerText={buildBannerText()} /> - - - + {buildDeckView()} diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/GripperReleaseLabware.test.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/GripperReleaseLabware.test.tsx index 9eff4a09ba4..3bdd9f97819 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/GripperReleaseLabware.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/GripperReleaseLabware.test.tsx @@ -9,6 +9,10 @@ import { clickButtonLabeled } from '/app/organisms/ErrorRecoveryFlows/__tests__/ import type { Mock } from 'vitest' +vi.mock('/app/assets/videos/error-recovery/Gripper_Release.webm', () => ({ + default: 'mocked-animation-path.webm', +})) + const render = (props: React.ComponentProps) => { return renderWithProviders(, { i18nInstance: i18n, @@ -48,4 +52,14 @@ describe('GripperReleaseLabware', () => { expect(mockHandleMotionRouting).toHaveBeenCalled() }) + + it('renders gripper animation', () => { + render(props) + + screen.getByRole('presentation', { hidden: true }) + expect(screen.getByTestId('gripper-animation')).toHaveAttribute( + 'src', + 'mocked-animation-path.webm' + ) + }) }) diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/LeftColumnLabwareInfo.test.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/LeftColumnLabwareInfo.test.tsx index e2e6c268ef8..f38e1e06922 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/LeftColumnLabwareInfo.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/LeftColumnLabwareInfo.test.tsx @@ -27,8 +27,8 @@ describe('LeftColumnLabwareInfo', () => { failedLabwareName: 'MOCK_LW_NAME', failedLabwareNickname: 'MOCK_LW_NICKNAME', failedLabwareLocations: { - currentLoc: 'slot A1', - newLoc: 'slot B2', + displayNameCurrentLoc: 'slot A1', + displayNameNewLoc: 'slot B2', }, } as any, type: 'location', @@ -76,7 +76,7 @@ describe('LeftColumnLabwareInfo', () => { }) it('does not include newLocationProps when newLoc is not provided', () => { - props.failedLabwareUtils.failedLabwareLocations.newLoc = null + props.failedLabwareUtils.failedLabwareLocations.displayNameNewLoc = null render(props) expect(vi.mocked(InterventionContent)).toHaveBeenCalledWith( @@ -91,9 +91,12 @@ describe('LeftColumnLabwareInfo', () => { it('converts location labels to uppercase', () => { props.failedLabwareUtils.failedLabwareLocations = { - currentLoc: 'slot A1', - newLoc: 'slot B2', + displayNameCurrentLoc: 'slot A1', + displayNameNewLoc: 'slot B2', + newLoc: {} as any, + currentLoc: {} as any, } + render(props) expect(vi.mocked(InterventionContent)).toHaveBeenCalledWith( diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/SelectTips.test.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/SelectTips.test.tsx index 9a8fc10f5d6..08db6269c4d 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/SelectTips.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/SelectTips.test.tsx @@ -53,7 +53,10 @@ describe('SelectTips', () => { failedLabwareUtils: { selectedTipLocations: { A1: null }, areTipsSelected: true, - failedLabwareLocations: { newLoc: null, currentLoc: 'A1' }, + failedLabwareLocations: { + displayNameNewLoc: null, + displayNameCurrentLoc: 'A1', + }, } as any, } @@ -161,7 +164,10 @@ describe('SelectTips', () => { failedLabwareUtils: { selectedTipLocations: null, areTipsSelected: false, - failedLabwareLocations: { newLoc: null, currentLoc: '' }, + failedLabwareLocations: { + displayNameNewLoc: null, + displayNameCurrentLoc: '', + }, } as any, } diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/TwoColLwInfoAndDeck.test.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/TwoColLwInfoAndDeck.test.tsx index f2206c8f010..2f24fc0f3bb 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/TwoColLwInfoAndDeck.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/TwoColLwInfoAndDeck.test.tsx @@ -1,4 +1,7 @@ import { describe, it, vi, expect, beforeEach } from 'vitest' +import { screen } from '@testing-library/react' + +import { MoveLabwareOnDeck } from '@opentrons/components' import { renderWithProviders } from '/app/__testing-utils__' import { i18n } from '/app/i18n' @@ -11,6 +14,13 @@ import { getSlotNameAndLwLocFrom } from '../../hooks/useDeckMapUtils' import type * as React from 'react' import type { Mock } from 'vitest' +vi.mock('@opentrons/components', async () => { + const actual = await vi.importActual('@opentrons/components') + return { + ...actual, + MoveLabwareOnDeck: vi.fn(), + } +}) vi.mock('../LeftColumnLabwareInfo') vi.mock('../../hooks/useDeckMapUtils') @@ -39,11 +49,17 @@ describe('TwoColLwInfoAndDeck', () => { failedLabwareUtils: { relevantWellName: 'A1', failedLabware: { location: 'C1' }, + failedLabwareLocations: { newLoc: {}, currentLoc: {} }, + }, + deckMapUtils: { + movedLabwareDef: {}, + moduleRenderInfo: [], + labwareRenderInfo: [], }, - deckMapUtils: {}, currentRecoveryOptionUtils: { selectedRecoveryOption: RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE, }, + isOnDevice: true, } as any vi.mocked(LeftColumnLabwareInfo).mockReturnValue( @@ -131,4 +147,34 @@ describe('TwoColLwInfoAndDeck', () => { expect.anything() ) }) + + it(`renders a move labware on deck view if the selected recovery option is ${RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE} and props are valid`, () => { + vi.mocked(MoveLabwareOnDeck).mockReturnValue( +
MOCK_MOVE_LW_ON_DECK
+ ) + + props.currentRecoveryOptionUtils.selectedRecoveryOption = + RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE + render(props) + + screen.getByText('MOCK_MOVE_LW_ON_DECK') + }) + + it(`does not render a move labware on deck view if the selected recovery option is ${RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE} and props are invalid`, () => { + vi.mocked(MoveLabwareOnDeck).mockReturnValue( +
MOCK_MOVE_LW_ON_DECK
+ ) + + props.currentRecoveryOptionUtils.selectedRecoveryOption = + RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE + props.deckMapUtils = { + movedLabwareDef: null, + moduleRenderInfo: null, + labwareRenderInfo: null, + } as any + + render(props) + + expect(screen.queryByText('MOCK_MOVE_LW_ON_DECK')).not.toBeInTheDocument() + }) }) diff --git a/app/src/organisms/ErrorRecoveryFlows/utils/__tests__/getErrorKind.test.ts b/app/src/organisms/ErrorRecoveryFlows/utils/__tests__/getErrorKind.test.ts index 1aa7080a52a..fb9eea82c63 100644 --- a/app/src/organisms/ErrorRecoveryFlows/utils/__tests__/getErrorKind.test.ts +++ b/app/src/organisms/ErrorRecoveryFlows/utils/__tests__/getErrorKind.test.ts @@ -7,6 +7,11 @@ import type { RunCommandError, RunTimeCommand } from '@opentrons/shared-data' describe('getErrorKind', () => { it.each([ + { + commandType: 'prepareToAspirate', + errorType: DEFINED_ERROR_TYPES.OVERPRESSURE, + expectedError: ERROR_KINDS.OVERPRESSURE_PREPARE_TO_ASPIRATE, + }, { commandType: 'aspirate', errorType: DEFINED_ERROR_TYPES.OVERPRESSURE, diff --git a/app/src/organisms/ErrorRecoveryFlows/utils/getErrorKind.ts b/app/src/organisms/ErrorRecoveryFlows/utils/getErrorKind.ts index a537c3cf295..30fc4783473 100644 --- a/app/src/organisms/ErrorRecoveryFlows/utils/getErrorKind.ts +++ b/app/src/organisms/ErrorRecoveryFlows/utils/getErrorKind.ts @@ -13,9 +13,12 @@ export function getErrorKind(failedCommand: RunTimeCommand | null): ErrorKind { const errorType = failedCommand?.error?.errorType if (errorIsDefined) { - // todo(mm, 2024-07-02): Also handle aspirateInPlace and dispenseInPlace. - // https://opentrons.atlassian.net/browse/EXEC-593 if ( + commandType === 'prepareToAspirate' && + errorType === DEFINED_ERROR_TYPES.OVERPRESSURE + ) { + return ERROR_KINDS.OVERPRESSURE_PREPARE_TO_ASPIRATE + } else if ( (commandType === 'aspirate' || commandType === 'aspirateInPlace') && errorType === DEFINED_ERROR_TYPES.OVERPRESSURE ) { diff --git a/app/src/organisms/InterventionModal/MoveLabwareInterventionContent.tsx b/app/src/organisms/InterventionModal/MoveLabwareInterventionContent.tsx index d44a96ecfa8..af561b6c15d 100644 --- a/app/src/organisms/InterventionModal/MoveLabwareInterventionContent.tsx +++ b/app/src/organisms/InterventionModal/MoveLabwareInterventionContent.tsx @@ -38,10 +38,8 @@ import { getModuleModelFromRunData, } from './utils' import { Divider } from '/app/atoms/structure' -import { - getLoadedLabware, - getLoadedModule, -} from '/app/molecules/Command/utils/accessors' +import { getLoadedModule } from '/app/local-resources/modules' +import { getLoadedLabware } from '/app/local-resources/labware' import { useNotifyDeckConfigurationQuery } from '/app/resources/deck_configuration' import type { @@ -135,7 +133,7 @@ export function MoveLabwareInterventionContent({ deckDef ) const oldLabwareLocation = - getLoadedLabware(run, command.params.labwareId)?.location ?? null + getLoadedLabware(run.labware, command.params.labwareId)?.location ?? null const labwareName = getLabwareNameFromRunData( run, @@ -275,8 +273,8 @@ function LabwareDisplayLocation( console.warn('labware is located on an unknown module model') } else { const slotName = - getLoadedModule(protocolData, location.moduleId)?.location?.slotName ?? - '' + getLoadedModule(protocolData.modules, location.moduleId)?.location + ?.slotName ?? '' const isModuleUnderAdapterThermocycler = getModuleType(moduleModel) === THERMOCYCLER_MODULE_TYPE if (isModuleUnderAdapterThermocycler) { @@ -309,8 +307,8 @@ function LabwareDisplayLocation( console.warn('labware is located on an adapter on an unknown module') } else { const slotName = - getLoadedModule(protocolData, adapter.location.moduleId)?.location - ?.slotName ?? '' + getLoadedModule(protocolData.modules, adapter.location.moduleId) + ?.location?.slotName ?? '' const isModuleUnderAdapterThermocycler = getModuleType(moduleModel) === THERMOCYCLER_MODULE_TYPE if (isModuleUnderAdapterThermocycler) { diff --git a/app/src/organisms/InterventionModal/utils/getLabwareNameFromRunData.ts b/app/src/organisms/InterventionModal/utils/getLabwareNameFromRunData.ts index b6671a32a3b..55b48efee29 100644 --- a/app/src/organisms/InterventionModal/utils/getLabwareNameFromRunData.ts +++ b/app/src/organisms/InterventionModal/utils/getLabwareNameFromRunData.ts @@ -1,6 +1,8 @@ import { getLabwareDefURI, getLabwareDisplayName } from '@opentrons/shared-data' -import { getLoadedLabware } from '/app/molecules/Command/utils/accessors' -import { getLabwareDefinitionsFromCommands } from '/app/molecules/Command/utils/getLabwareDefinitionsFromCommands' +import { + getLoadedLabware, + getLabwareDefinitionsFromCommands, +} from '/app/local-resources/labware' import type { RunTimeCommand } from '@opentrons/shared-data' import type { RunData } from '@opentrons/api-client' @@ -15,7 +17,7 @@ export function getLabwareNameFromRunData( labwareId: string, commands: RunTimeCommand[] ): string { - const loadedLabware = getLoadedLabware(protocolData, labwareId) + const loadedLabware = getLoadedLabware(protocolData.labware, labwareId) if (loadedLabware == null) { return '' } else if (FIXED_TRASH_DEF_URIS.includes(loadedLabware.definitionUri)) { diff --git a/app/src/organisms/InterventionModal/utils/getModuleDisplayLocationFromRunData.ts b/app/src/organisms/InterventionModal/utils/getModuleDisplayLocationFromRunData.ts index 3301cb6c77c..0b96641e9e5 100644 --- a/app/src/organisms/InterventionModal/utils/getModuleDisplayLocationFromRunData.ts +++ b/app/src/organisms/InterventionModal/utils/getModuleDisplayLocationFromRunData.ts @@ -1,4 +1,4 @@ -import { getLoadedModule } from '/app/molecules/Command/utils/accessors' +import { getLoadedModule } from '/app/local-resources/modules' import type { RunData } from '@opentrons/api-client' @@ -6,6 +6,6 @@ export function getModuleDisplayLocationFromRunData( protocolData: RunData, moduleId: string ): string { - const loadedModule = getLoadedModule(protocolData, moduleId) + const loadedModule = getLoadedModule(protocolData.modules, moduleId) return loadedModule != null ? loadedModule.location.slotName : '' } diff --git a/app/src/organisms/InterventionModal/utils/getModuleModelFromRunData.ts b/app/src/organisms/InterventionModal/utils/getModuleModelFromRunData.ts index c709e5b9ab4..e22c1895918 100644 --- a/app/src/organisms/InterventionModal/utils/getModuleModelFromRunData.ts +++ b/app/src/organisms/InterventionModal/utils/getModuleModelFromRunData.ts @@ -1,4 +1,4 @@ -import { getLoadedModule } from '/app/molecules/Command/utils/accessors' +import { getLoadedModule } from '/app/local-resources/modules' import type { RunData } from '@opentrons/api-client' import type { ModuleModel } from '@opentrons/shared-data' @@ -7,6 +7,6 @@ export function getModuleModelFromRunData( protocolData: RunData, moduleId: string ): ModuleModel | null { - const loadedModule = getLoadedModule(protocolData, moduleId) + const loadedModule = getLoadedModule(protocolData.modules, moduleId) return loadedModule != null ? loadedModule.model : null } diff --git a/app/src/organisms/LabwarePositionCheck/CheckItem.tsx b/app/src/organisms/LabwarePositionCheck/CheckItem.tsx index 9659319d24d..c9050b5dd3f 100644 --- a/app/src/organisms/LabwarePositionCheck/CheckItem.tsx +++ b/app/src/organisms/LabwarePositionCheck/CheckItem.tsx @@ -23,7 +23,7 @@ import { } from '@opentrons/shared-data' import { useSelector } from 'react-redux' import { getLabwareDef } from './utils/labware' -import { getLabwareDefinitionsFromCommands } from '/app/molecules/Command/utils/getLabwareDefinitionsFromCommands' +import { getLabwareDefinitionsFromCommands } from '/app/local-resources/labware' import { UnorderedList } from '/app/molecules/UnorderedList' import { getCurrentOffsetForLabwareInLocation } from '/app/transformations/analysis' import { getIsOnDevice } from '/app/redux/config' diff --git a/app/src/organisms/LabwarePositionCheck/IntroScreen/index.tsx b/app/src/organisms/LabwarePositionCheck/IntroScreen/index.tsx index 8c372750b78..dbaa5970c6c 100644 --- a/app/src/organisms/LabwarePositionCheck/IntroScreen/index.tsx +++ b/app/src/organisms/LabwarePositionCheck/IntroScreen/index.tsx @@ -30,7 +30,7 @@ import { getTopPortalEl } from '/app/App/portal' import { SmallButton } from '/app/atoms/buttons' import { CALIBRATION_PROBE } from '/app/organisms/PipetteWizardFlows/constants' import { TerseOffsetTable } from '../ResultsSummary' -import { getLabwareDefinitionsFromCommands } from '/app/molecules/Command/utils/getLabwareDefinitionsFromCommands' +import { getLabwareDefinitionsFromCommands } from '/app/local-resources/labware' import type { LabwareOffset } from '@opentrons/api-client' import type { diff --git a/app/src/organisms/LabwarePositionCheck/PickUpTip.tsx b/app/src/organisms/LabwarePositionCheck/PickUpTip.tsx index f93d2febe1b..c7505a13d92 100644 --- a/app/src/organisms/LabwarePositionCheck/PickUpTip.tsx +++ b/app/src/organisms/LabwarePositionCheck/PickUpTip.tsx @@ -22,7 +22,7 @@ import { UnorderedList } from '/app/molecules/UnorderedList' import { getCurrentOffsetForLabwareInLocation } from '/app/transformations/analysis' import { TipConfirmation } from './TipConfirmation' import { getLabwareDef } from './utils/labware' -import { getLabwareDefinitionsFromCommands } from '/app/molecules/Command/utils/getLabwareDefinitionsFromCommands' +import { getLabwareDefinitionsFromCommands } from '/app/local-resources/labware' import { getDisplayLocation } from './utils/getDisplayLocation' import { useSelector } from 'react-redux' import { getIsOnDevice } from '/app/redux/config' diff --git a/app/src/organisms/LabwarePositionCheck/ResultsSummary.tsx b/app/src/organisms/LabwarePositionCheck/ResultsSummary.tsx index e4489cea914..98f88fac2bd 100644 --- a/app/src/organisms/LabwarePositionCheck/ResultsSummary.tsx +++ b/app/src/organisms/LabwarePositionCheck/ResultsSummary.tsx @@ -38,7 +38,7 @@ import { import { SmallButton } from '/app/atoms/buttons' import { LabwareOffsetTabs } from '/app/organisms/LabwareOffsetTabs' import { getCurrentOffsetForLabwareInLocation } from '/app/transformations/analysis' -import { getLabwareDefinitionsFromCommands } from '/app/molecules/Command/utils/getLabwareDefinitionsFromCommands' +import { getLabwareDefinitionsFromCommands } from '/app/local-resources/labware' import { getDisplayLocation } from './utils/getDisplayLocation' import type { diff --git a/app/src/organisms/LabwarePositionCheck/ReturnTip.tsx b/app/src/organisms/LabwarePositionCheck/ReturnTip.tsx index 8ba2f78d125..fce1f443829 100644 --- a/app/src/organisms/LabwarePositionCheck/ReturnTip.tsx +++ b/app/src/organisms/LabwarePositionCheck/ReturnTip.tsx @@ -13,7 +13,7 @@ import { } from '@opentrons/shared-data' import { UnorderedList } from '/app/molecules/UnorderedList' import { getLabwareDef } from './utils/labware' -import { getLabwareDefinitionsFromCommands } from '/app/molecules/Command/utils/getLabwareDefinitionsFromCommands' +import { getLabwareDefinitionsFromCommands } from '/app/local-resources/labware' import { getDisplayLocation } from './utils/getDisplayLocation' import { RobotMotionLoader } from './RobotMotionLoader' import { PrepareSpace } from './PrepareSpace' diff --git a/app/src/organisms/LabwarePositionCheck/useLaunchLPC.tsx b/app/src/organisms/LabwarePositionCheck/useLaunchLPC.tsx index fad314f7af3..18c906d2998 100644 --- a/app/src/organisms/LabwarePositionCheck/useLaunchLPC.tsx +++ b/app/src/organisms/LabwarePositionCheck/useLaunchLPC.tsx @@ -11,7 +11,7 @@ import { useMostRecentCompletedAnalysis, } from '/app/resources/runs' import { LabwarePositionCheck } from '.' -import { getLabwareDefinitionsFromCommands } from '/app/molecules/Command/utils/getLabwareDefinitionsFromCommands' +import { getLabwareDefinitionsFromCommands } from '/app/local-resources/labware' import type { RobotType } from '@opentrons/shared-data' diff --git a/app/src/organisms/LabwarePositionCheck/utils/getProbeBasedLPCSteps.ts b/app/src/organisms/LabwarePositionCheck/utils/getProbeBasedLPCSteps.ts index 0f03ad0e92b..f5e4ed86f0b 100644 --- a/app/src/organisms/LabwarePositionCheck/utils/getProbeBasedLPCSteps.ts +++ b/app/src/organisms/LabwarePositionCheck/utils/getProbeBasedLPCSteps.ts @@ -2,7 +2,7 @@ import { isEqual } from 'lodash' import { SECTIONS } from '../constants' import { getLabwareDefURI, getPipetteNameSpecs } from '@opentrons/shared-data' import { getLabwareLocationCombos } from '../../ApplyHistoricOffsets/hooks/getLabwareLocationCombos' -import { getLabwareDefinitionsFromCommands } from '/app/molecules/Command/utils/getLabwareDefinitionsFromCommands' +import { getLabwareDefinitionsFromCommands } from '/app/local-resources/labware' import type { CompletedProtocolAnalysis, diff --git a/app/src/organisms/LabwarePositionCheck/utils/getTipBasedLPCSteps.ts b/app/src/organisms/LabwarePositionCheck/utils/getTipBasedLPCSteps.ts index c03660a6f20..47c30424e95 100644 --- a/app/src/organisms/LabwarePositionCheck/utils/getTipBasedLPCSteps.ts +++ b/app/src/organisms/LabwarePositionCheck/utils/getTipBasedLPCSteps.ts @@ -1,6 +1,6 @@ import { isEqual } from 'lodash' import { SECTIONS } from '../constants' -import { getLabwareDefinitionsFromCommands } from '/app/molecules/Command/utils/getLabwareDefinitionsFromCommands' +import { getLabwareDefinitionsFromCommands } from '/app/local-resources/labware' import { getLabwareDefURI, getIsTiprack, diff --git a/app/src/organisms/LabwarePositionCheck/utils/labware.ts b/app/src/organisms/LabwarePositionCheck/utils/labware.ts index 2fd03ccc0c0..70096061c33 100644 --- a/app/src/organisms/LabwarePositionCheck/utils/labware.ts +++ b/app/src/organisms/LabwarePositionCheck/utils/labware.ts @@ -5,7 +5,7 @@ import { getLabwareDefURI, } from '@opentrons/shared-data' import { getModuleInitialLoadInfo } from '/app/transformations/commands' -import { getLabwareDefinitionsFromCommands } from '/app/molecules/Command/utils/getLabwareDefinitionsFromCommands' +import { getLabwareDefinitionsFromCommands } from '/app/local-resources/labware' import type { CompletedProtocolAnalysis, LabwareDefinition2, diff --git a/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupOffsets/index.tsx b/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupOffsets/index.tsx index 3f287ea80e6..3082df45a2a 100644 --- a/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupOffsets/index.tsx +++ b/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupOffsets/index.tsx @@ -17,7 +17,7 @@ import { ODDBackButton } from '/app/molecules/ODDBackButton' import { FloatingActionButton, SmallButton } from '/app/atoms/buttons' import type { SetupScreens } from '../types' import { TerseOffsetTable } from '/app/organisms/LabwarePositionCheck/ResultsSummary' -import { getLabwareDefinitionsFromCommands } from '/app/molecules/Command/utils/getLabwareDefinitionsFromCommands' +import { getLabwareDefinitionsFromCommands } from '/app/local-resources/labware' import { useNotifyRunQuery, useMostRecentCompletedAnalysis, diff --git a/app/src/organisms/ODD/QuickTransferFlow/utils/generateCompatibleLabwareForPipette.ts b/app/src/organisms/ODD/QuickTransferFlow/utils/generateCompatibleLabwareForPipette.ts index 29e0847bf02..38d62455854 100644 --- a/app/src/organisms/ODD/QuickTransferFlow/utils/generateCompatibleLabwareForPipette.ts +++ b/app/src/organisms/ODD/QuickTransferFlow/utils/generateCompatibleLabwareForPipette.ts @@ -14,7 +14,8 @@ export function generateCompatibleLabwareForPipette( (acc, definition) => { if ( definition.allowedRoles != null && - definition.allowedRoles.includes('adapter') + (definition.allowedRoles.includes('adapter') || + definition.allowedRoles.includes('lid')) ) { return acc } else if (pipetteSpecs.channels === 1) { diff --git a/app/src/organisms/ODD/RunningProtocol/CurrentRunningProtocolCommand.tsx b/app/src/organisms/ODD/RunningProtocol/CurrentRunningProtocolCommand.tsx index f87f7cd71e9..be9e5e25cbb 100644 --- a/app/src/organisms/ODD/RunningProtocol/CurrentRunningProtocolCommand.tsx +++ b/app/src/organisms/ODD/RunningProtocol/CurrentRunningProtocolCommand.tsx @@ -21,7 +21,7 @@ import { RUN_STATUS_RUNNING, RUN_STATUS_IDLE } from '@opentrons/api-client' import { CommandText } from '/app/molecules/Command' import { RunTimer } from '/app/molecules/RunTimer' -import { getCommandTextData } from '/app/molecules/Command/utils/getCommandTextData' +import { getCommandTextData } from '/app/local-resources/commands' import { PlayPauseButton } from './PlayPauseButton' import { StopButton } from './StopButton' import { ANALYTICS_PROTOCOL_RUN_ACTION } from '/app/redux/analytics' diff --git a/app/src/organisms/ODD/RunningProtocol/RunningProtocolCommandList.tsx b/app/src/organisms/ODD/RunningProtocol/RunningProtocolCommandList.tsx index 3e928ed88b4..e49b725ab35 100644 --- a/app/src/organisms/ODD/RunningProtocol/RunningProtocolCommandList.tsx +++ b/app/src/organisms/ODD/RunningProtocol/RunningProtocolCommandList.tsx @@ -23,7 +23,7 @@ import { import { RUN_STATUS_RUNNING, RUN_STATUS_IDLE } from '@opentrons/api-client' import { CommandText, CommandIcon } from '/app/molecules/Command' -import { getCommandTextData } from '/app/molecules/Command/utils/getCommandTextData' +import { getCommandTextData } from '/app/local-resources/commands' import { PlayPauseButton } from './PlayPauseButton' import { StopButton } from './StopButton' import { ANALYTICS_PROTOCOL_RUN_ACTION } from '/app/redux/analytics' diff --git a/app/src/pages/Desktop/Devices/ProtocolRunDetails/index.tsx b/app/src/pages/Desktop/Devices/ProtocolRunDetails/index.tsx index 7ea4ceae7c6..4abb609c4fc 100644 --- a/app/src/pages/Desktop/Devices/ProtocolRunDetails/index.tsx +++ b/app/src/pages/Desktop/Devices/ProtocolRunDetails/index.tsx @@ -25,10 +25,7 @@ import { ApiHostProvider } from '@opentrons/react-api-client' import { useSyncRobotClock } from '/app/organisms/Desktop/Devices/hooks' import { ProtocolRunHeader } from '/app/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader' import { RunPreview } from '/app/organisms/Desktop/Devices/RunPreview' -import { - ProtocolRunSetup, - initialMissingSteps, -} from '/app/organisms/Desktop/Devices/ProtocolRun/ProtocolRunSetup' +import { ProtocolRunSetup } from '/app/organisms/Desktop/Devices/ProtocolRun/ProtocolRunSetup' import { BackToTopButton } from '/app/organisms/Desktop/Devices/ProtocolRun/BackToTopButton' import { ProtocolRunModuleControls } from '/app/organisms/Desktop/Devices/ProtocolRun/ProtocolRunModuleControls' import { ProtocolRunRuntimeParameters } from '/app/organisms/Desktop/Devices/ProtocolRun/ProtocolRunRunTimeParameters' @@ -187,10 +184,6 @@ function PageContents(props: PageContentsProps): JSX.Element { } }, [jumpedIndex]) - const [missingSteps, setMissingSteps] = useState< - ReturnType - >(initialMissingSteps()) - const makeHandleScrollToStep = (i: number) => () => { listRef.current?.scrollToIndex(i, true, -1 * JUMP_OFFSET_FROM_TOP_PX) } @@ -210,8 +203,6 @@ function PageContents(props: PageContentsProps): JSX.Element { protocolRunHeaderRef={protocolRunHeaderRef} robotName={robotName} runId={runId} - setMissingSteps={setMissingSteps} - missingSteps={missingSteps} /> ), backToTop: ( @@ -269,7 +260,6 @@ function PageContents(props: PageContentsProps): JSX.Element { robotName={robotName} runId={runId} makeHandleJumpToStep={makeHandleJumpToStep} - missingSetupSteps={missingSteps} /> { + const orderedSteps = + protocolAnalysis == null ? NO_ANALYSIS_STEPS_IN_ORDER : ALL_STEPS_IN_ORDER + + const orderedApplicableSteps = + protocolAnalysis == null + ? NO_ANALYSIS_STEPS_IN_ORDER + : ALL_STEPS_IN_ORDER.filter((stepKey: StepKey) => { + if (protocolAnalysis.modules.length === 0) { + return stepKey !== MODULE_SETUP_STEP_KEY + } + + if (protocolAnalysis.liquids.length === 0) { + return stepKey !== LIQUID_SETUP_STEP_KEY + } + return true + }) + return { orderedSteps: orderedSteps as StepKey[], orderedApplicableSteps } +} + +const keyFor = ( + analysis: CompletedProtocolAnalysis | ProtocolAnalysisOutput | null + // @ts-expect-error(sf, 2024-10-23): purposeful weak object typing +): string | null => analysis?.id ?? analysis?.metadata?.id ?? null + +export function useRequiredSetupStepsInOrder({ + runId, + protocolAnalysis, +}: UseRequiredSetupStepsInOrderProps): UseRequiredSetupStepsInOrderReturn { + const dispatch = useDispatch() + const requiredSteps = useSelector(state => + getSetupStepsRequired(state, runId) + ) + + useEffect(() => { + const applicable = keysInOrder(protocolAnalysis) + dispatch( + updateRunSetupStepsRequired(runId, { + ...ALL_STEPS_IN_ORDER.reduce< + UpdateRunSetupStepsRequiredAction['payload']['required'] + >( + (acc, thiskey) => ({ + ...acc, + [thiskey]: applicable.orderedApplicableSteps.includes(thiskey), + }), + {} + ), + }) + ) + }, [runId, keyFor(protocolAnalysis), dispatch]) + return protocolAnalysis == null + ? { + orderedSteps: NO_ANALYSIS_STEPS_IN_ORDER, + orderedApplicableSteps: NO_ANALYSIS_STEPS_IN_ORDER, + } + : { + orderedSteps: ALL_STEPS_IN_ORDER, + orderedApplicableSteps: ALL_STEPS_IN_ORDER.filter( + step => (requiredSteps as Required> | null)?.[step] + ), + } +} diff --git a/app/src/redux-resources/runs/index.ts b/app/src/redux-resources/runs/index.ts new file mode 100644 index 00000000000..fc78d35129c --- /dev/null +++ b/app/src/redux-resources/runs/index.ts @@ -0,0 +1 @@ +export * from './hooks' diff --git a/app/src/redux/config/actions.ts b/app/src/redux/config/actions.ts index e0a6906b17f..915fce0a8f0 100644 --- a/app/src/redux/config/actions.ts +++ b/app/src/redux/config/actions.ts @@ -55,6 +55,7 @@ export const configInitialized = ( ): Types.ConfigInitializedAction => ({ type: Constants.INITIALIZED, payload: { config }, + meta: { shell: true }, }) // config value has been updated @@ -64,6 +65,7 @@ export const configValueUpdated = ( ): Types.ConfigValueUpdatedAction => ({ type: Constants.VALUE_UPDATED, payload: { path, value }, + meta: { shell: true }, }) export function toggleDevtools(): Types.ToggleConfigValueAction { diff --git a/app/src/redux/config/types.ts b/app/src/redux/config/types.ts index b408a2204e2..5d6b4b83ac9 100644 --- a/app/src/redux/config/types.ts +++ b/app/src/redux/config/types.ts @@ -16,11 +16,13 @@ export type ConfigState = Config | null export interface ConfigInitializedAction { type: typeof INITIALIZED payload: { config: Config } + meta: { shell: true } } export interface ConfigValueUpdatedAction { type: typeof VALUE_UPDATED payload: { path: string; value: any } + meta: { shell: true } } export interface UpdateConfigValueAction { diff --git a/app/src/redux/protocol-runs/__tests__/reducer.test.ts b/app/src/redux/protocol-runs/__tests__/reducer.test.ts new file mode 100644 index 00000000000..e10ce306f7d --- /dev/null +++ b/app/src/redux/protocol-runs/__tests__/reducer.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest' + +import { protocolRunReducer } from '../reducer' +import { + updateRunSetupStepsComplete, + updateRunSetupStepsRequired, +} from '../actions' +import * as Constants from '../constants' + +describe('protocol runs reducer', () => { + const INITIAL = { + [Constants.ROBOT_CALIBRATION_STEP_KEY]: { + required: true, + complete: false, + }, + [Constants.MODULE_SETUP_STEP_KEY]: { required: true, complete: false }, + [Constants.LPC_STEP_KEY]: { required: true, complete: false }, + [Constants.LABWARE_SETUP_STEP_KEY]: { + required: true, + complete: false, + }, + [Constants.LIQUID_SETUP_STEP_KEY]: { required: true, complete: false }, + } + it('establishes an empty state if you tell it one', () => { + const nextState = protocolRunReducer( + undefined, + updateRunSetupStepsComplete('some-run-id', {}) + ) + expect(nextState['some-run-id']?.setup).toEqual(INITIAL) + }) + it('updates complete based on an action', () => { + const nextState = protocolRunReducer( + { + 'some-run-id': { + setup: { + ...INITIAL, + [Constants.LIQUID_SETUP_STEP_KEY]: { + complete: true, + required: true, + }, + }, + }, + }, + updateRunSetupStepsComplete('some-run-id', { + [Constants.LPC_STEP_KEY]: true, + }) + ) + expect(nextState['some-run-id']?.setup).toEqual({ + ...INITIAL, + [Constants.LIQUID_SETUP_STEP_KEY]: { + required: true, + complete: true, + }, + [Constants.LPC_STEP_KEY]: { required: true, complete: true }, + }) + }) + it('updates required based on an action', () => { + const nextState = protocolRunReducer( + { + 'some-run-id': { + setup: INITIAL, + }, + }, + updateRunSetupStepsRequired('some-run-id', { + [Constants.LIQUID_SETUP_STEP_KEY]: false, + }) + ) + expect(nextState['some-run-id']?.setup).toEqual({ + ...INITIAL, + [Constants.LIQUID_SETUP_STEP_KEY]: { + required: false, + complete: false, + }, + }) + }) +}) diff --git a/app/src/redux/protocol-runs/actions.ts b/app/src/redux/protocol-runs/actions.ts new file mode 100644 index 00000000000..378ee297ed2 --- /dev/null +++ b/app/src/redux/protocol-runs/actions.ts @@ -0,0 +1,18 @@ +import * as Constants from './constants' +import type * as Types from './types' + +export const updateRunSetupStepsComplete = ( + runId: string, + complete: Types.UpdateRunSetupStepsCompleteAction['payload']['complete'] +): Types.UpdateRunSetupStepsCompleteAction => ({ + type: Constants.UPDATE_RUN_SETUP_STEPS_COMPLETE, + payload: { runId, complete }, +}) + +export const updateRunSetupStepsRequired = ( + runId: string, + required: Types.UpdateRunSetupStepsRequiredAction['payload']['required'] +): Types.UpdateRunSetupStepsRequiredAction => ({ + type: Constants.UPDATE_RUN_SETUP_STEPS_REQUIRED, + payload: { runId, required }, +}) diff --git a/app/src/redux/protocol-runs/constants.ts b/app/src/redux/protocol-runs/constants.ts new file mode 100644 index 00000000000..04f28f760d3 --- /dev/null +++ b/app/src/redux/protocol-runs/constants.ts @@ -0,0 +1,18 @@ +export const ROBOT_CALIBRATION_STEP_KEY: 'robot_calibration_step' = + 'robot_calibration_step' +export const MODULE_SETUP_STEP_KEY: 'module_setup_step' = 'module_setup_step' +export const LPC_STEP_KEY: 'labware_position_check_step' = + 'labware_position_check_step' +export const LABWARE_SETUP_STEP_KEY: 'labware_setup_step' = 'labware_setup_step' +export const LIQUID_SETUP_STEP_KEY: 'liquid_setup_step' = 'liquid_setup_step' + +export const SETUP_STEP_KEYS = [ + ROBOT_CALIBRATION_STEP_KEY, + MODULE_SETUP_STEP_KEY, + LPC_STEP_KEY, + LABWARE_SETUP_STEP_KEY, + LIQUID_SETUP_STEP_KEY, +] as const + +export const UPDATE_RUN_SETUP_STEPS_COMPLETE = 'protocolRuns:UPDATE_RUN_SETUP_STEPS_COMPLETE' as const +export const UPDATE_RUN_SETUP_STEPS_REQUIRED = 'protocolRuns:UPDATE_RUN_SETUP_STEPS_REQUIRED' as const diff --git a/app/src/redux/protocol-runs/index.ts b/app/src/redux/protocol-runs/index.ts new file mode 100644 index 00000000000..9f709c0dbcb --- /dev/null +++ b/app/src/redux/protocol-runs/index.ts @@ -0,0 +1,7 @@ +// runs constants, actions, selectors, and types + +export * from './actions' +export * from './constants' +export * from './selectors' + +export type * from './types' diff --git a/app/src/redux/protocol-runs/reducer.ts b/app/src/redux/protocol-runs/reducer.ts new file mode 100644 index 00000000000..0b2d8378a67 --- /dev/null +++ b/app/src/redux/protocol-runs/reducer.ts @@ -0,0 +1,63 @@ +import * as Constants from './constants' + +import type { Reducer } from 'redux' +import type { Action } from '../types' + +import type { ProtocolRunState, RunSetupStatus } from './types' + +const INITIAL_STATE: ProtocolRunState = {} + +const INITIAL_SETUP_STEP_STATE = { complete: false, required: true } + +const INITIAL_RUN_SETUP_STATE: RunSetupStatus = { + [Constants.ROBOT_CALIBRATION_STEP_KEY]: INITIAL_SETUP_STEP_STATE, + [Constants.MODULE_SETUP_STEP_KEY]: INITIAL_SETUP_STEP_STATE, + [Constants.LPC_STEP_KEY]: INITIAL_SETUP_STEP_STATE, + [Constants.LABWARE_SETUP_STEP_KEY]: INITIAL_SETUP_STEP_STATE, + [Constants.LIQUID_SETUP_STEP_KEY]: INITIAL_SETUP_STEP_STATE, +} + +export const protocolRunReducer: Reducer = ( + state = INITIAL_STATE, + action +) => { + switch (action.type) { + case Constants.UPDATE_RUN_SETUP_STEPS_COMPLETE: { + return { + ...state, + [action.payload.runId]: { + setup: Constants.SETUP_STEP_KEYS.reduce( + (currentState, step) => ({ + ...currentState, + [step]: { + complete: + action.payload.complete[step] ?? currentState[step].complete, + required: currentState[step].required, + }, + }), + state[action.payload.runId]?.setup ?? INITIAL_RUN_SETUP_STATE + ), + }, + } + } + case Constants.UPDATE_RUN_SETUP_STEPS_REQUIRED: { + return { + ...state, + [action.payload.runId]: { + setup: Constants.SETUP_STEP_KEYS.reduce( + (currentState, step) => ({ + ...currentState, + [step]: { + required: + action.payload.required[step] ?? currentState[step].required, + complete: currentState[step].complete, + }, + }), + state[action.payload.runId]?.setup ?? INITIAL_RUN_SETUP_STATE + ), + }, + } + } + } + return state +} diff --git a/app/src/redux/protocol-runs/selectors.ts b/app/src/redux/protocol-runs/selectors.ts new file mode 100644 index 00000000000..ca91c7a71ab --- /dev/null +++ b/app/src/redux/protocol-runs/selectors.ts @@ -0,0 +1,91 @@ +import type { State } from '../types' +import type * as Types from './types' + +export const getSetupStepComplete: ( + state: State, + runId: string, + step: Types.StepKey +) => boolean | null = (state, runId, step) => + getSetupStepsComplete(state, runId)?.[step] ?? null + +export const getSetupStepsComplete: ( + state: State, + runId: string +) => Types.StepMap | null = (state, runId) => { + const setup = state.protocolRuns[runId]?.setup + if (setup == null) { + return null + } + return (Object.entries(setup) as Array< + [Types.StepKey, Types.StepState] + >).reduce>>( + (acc, [step, state]) => ({ + ...acc, + [step]: state.complete, + }), + {} + ) as Types.StepMap +} + +export const getSetupStepRequired: ( + state: State, + runId: string, + step: Types.StepKey +) => boolean | null = (state, runId, step) => + getSetupStepsRequired(state, runId)?.[step] ?? null + +export const getSetupStepsRequired: ( + state: State, + runId: string +) => Types.StepMap | null = (state, runId) => { + const setup = state.protocolRuns[runId]?.setup + if (setup == null) { + return null + } + return (Object.entries(setup) as Array< + [Types.StepKey, Types.StepState] + >).reduce>>( + (acc, [step, state]) => ({ ...acc, [step]: state.required }), + {} + ) as Types.StepMap +} + +export const getSetupStepMissing: ( + state: State, + runId: string, + step: Types.StepKey +) => boolean | null = (state, runId, step) => + getSetupStepsMissing(state, runId)?.[step] || null + +export const getSetupStepsMissing: ( + state: State, + runId: string +) => Types.StepMap | null = (state, runId) => { + const setup = state.protocolRuns[runId]?.setup + if (setup == null) { + return null + } + return (Object.entries(setup) as Array< + [Types.StepKey, Types.StepState] + >).reduce>>( + (acc, [step, state]) => ({ + ...acc, + [step]: state.required && !state.complete, + }), + {} + ) as Types.StepMap +} + +export const getMissingSetupSteps: ( + state: State, + runId: string +) => Types.StepKey[] = (state, runId) => { + const missingStepMap = getSetupStepsMissing(state, runId) + if (missingStepMap == null) return [] + const missingStepList = (Object.entries(missingStepMap) as Array< + [Types.StepKey, boolean] + >) + .map(([step, missing]) => (missing ? step : null)) + .filter(stepName => stepName != null) + return missingStepList as Types.StepKey[] +} diff --git a/app/src/redux/protocol-runs/types.ts b/app/src/redux/protocol-runs/types.ts new file mode 100644 index 00000000000..c14d556d495 --- /dev/null +++ b/app/src/redux/protocol-runs/types.ts @@ -0,0 +1,61 @@ +import type { + ROBOT_CALIBRATION_STEP_KEY, + MODULE_SETUP_STEP_KEY, + LPC_STEP_KEY, + LABWARE_SETUP_STEP_KEY, + LIQUID_SETUP_STEP_KEY, + UPDATE_RUN_SETUP_STEPS_COMPLETE, + UPDATE_RUN_SETUP_STEPS_REQUIRED, +} from './constants' + +export type RobotCalibrationStepKey = typeof ROBOT_CALIBRATION_STEP_KEY +export type ModuleSetupStepKey = typeof MODULE_SETUP_STEP_KEY +export type LPCStepKey = typeof LPC_STEP_KEY +export type LabwareSetupStepKey = typeof LABWARE_SETUP_STEP_KEY +export type LiquidSetupStepKey = typeof LIQUID_SETUP_STEP_KEY + +export type StepKey = + | RobotCalibrationStepKey + | ModuleSetupStepKey + | LPCStepKey + | LabwareSetupStepKey + | LiquidSetupStepKey + +export interface StepState { + required: boolean + complete: boolean +} + +export type StepMap = { [Step in StepKey]: V } + +export type RunSetupStatus = { + [Step in StepKey]: StepState +} + +export interface PerRunUIState { + setup: RunSetupStatus +} + +export type ProtocolRunState = Partial<{ + readonly [runId: string]: PerRunUIState +}> + +export interface UpdateRunSetupStepsCompleteAction { + type: typeof UPDATE_RUN_SETUP_STEPS_COMPLETE + payload: { + runId: string + complete: Partial<{ [Step in StepKey]: boolean }> + } +} + +export interface UpdateRunSetupStepsRequiredAction { + type: typeof UPDATE_RUN_SETUP_STEPS_REQUIRED + payload: { + runId: string + required: Partial<{ [Step in StepKey]: boolean }> + } +} + +export type ProtocolRunAction = + | UpdateRunSetupStepsCompleteAction + | UpdateRunSetupStepsRequiredAction diff --git a/app/src/redux/reducer.ts b/app/src/redux/reducer.ts index 44831b0d70e..e21dbded781 100644 --- a/app/src/redux/reducer.ts +++ b/app/src/redux/reducer.ts @@ -48,6 +48,9 @@ import { calibrationReducer } from './calibration/reducer' // local protocol storage from file system state import { protocolStorageReducer } from './protocol-storage/reducer' +// local protocol run state +import { protocolRunReducer } from './protocol-runs/reducer' + import type { Reducer } from 'redux' import type { State, Action } from './types' @@ -68,4 +71,5 @@ export const rootReducer: Reducer = combineReducers({ sessions: sessionReducer, calibration: calibrationReducer, protocolStorage: protocolStorageReducer, + protocolRuns: protocolRunReducer, }) diff --git a/app/src/redux/shell/update.ts b/app/src/redux/shell/update.ts index 7c9e3be1f58..aa5fb601840 100644 --- a/app/src/redux/shell/update.ts +++ b/app/src/redux/shell/update.ts @@ -3,11 +3,7 @@ import { createSelector } from 'reselect' import type { State } from '../types' -import type { - ShellUpdateAction, - ShellUpdateState, - RobotMassStorageDeviceEnumerated, -} from './types' +import type { ShellUpdateAction, ShellUpdateState } from './types' // command sent to app-shell via meta.shell === true export function checkShellUpdate(): ShellUpdateAction { @@ -37,16 +33,3 @@ export const getAvailableShellUpdate: ( ) => string | null = createSelector(getShellUpdateState, state => state.available && state.info ? state.info.version : null ) - -export function checkMassStorage( - state: State -): RobotMassStorageDeviceEnumerated { - return { - type: 'shell:ROBOT_MASS_STORAGE_DEVICE_ENUMERATED', - payload: { - rootPath: '', - filePaths: state.shell.filePaths, - }, - meta: { shell: true }, - } -} diff --git a/app/src/redux/types.ts b/app/src/redux/types.ts index 9ed69c3e71f..d3f502cdc40 100644 --- a/app/src/redux/types.ts +++ b/app/src/redux/types.ts @@ -37,6 +37,8 @@ import type { AlertsState, AlertsAction } from './alerts/types' import type { SessionState, SessionsAction } from './sessions/types' import type { AnalyticsTriggerAction } from './analytics/types' +import type { ProtocolRunState, ProtocolRunAction } from './protocol-runs/types' + export interface State { readonly robotApi: RobotApiState readonly robotAdmin: RobotAdminState @@ -54,6 +56,7 @@ export interface State { readonly sessions: SessionState readonly calibration: CalibrationState readonly protocolStorage: ProtocolStorageState + readonly protocolRuns: ProtocolRunState } export type Action = @@ -78,6 +81,7 @@ export type Action = | CalibrationAction | AnalyticsTriggerAction | AddCustomLabwareFromCreatorAction + | ProtocolRunAction export type GetState = () => State diff --git a/components/src/atoms/ListItem/ListItem.stories.tsx b/components/src/atoms/ListItem/ListItem.stories.tsx index 0738b583cde..dbe4739249d 100644 --- a/components/src/atoms/ListItem/ListItem.stories.tsx +++ b/components/src/atoms/ListItem/ListItem.stories.tsx @@ -50,7 +50,7 @@ export const ListItemDescriptorDefault: Story = { type: 'noActive', children: ( mock content} description={
mock description
} /> @@ -63,7 +63,7 @@ export const ListItemDescriptorMini: Story = { type: 'noActive', children: ( mock content} description={
mock description
} /> diff --git a/components/src/atoms/ListItem/ListItemChildren/ListItemDescriptor.tsx b/components/src/atoms/ListItem/ListItemChildren/ListItemDescriptor.tsx index 51d9ca9e181..12b494ea894 100644 --- a/components/src/atoms/ListItem/ListItemChildren/ListItemDescriptor.tsx +++ b/components/src/atoms/ListItem/ListItemChildren/ListItemDescriptor.tsx @@ -2,43 +2,34 @@ import { Flex } from '../../../primitives' import { ALIGN_FLEX_START, DIRECTION_ROW, - FLEX_AUTO, JUSTIFY_SPACE_BETWEEN, } from '../../../styles' import { SPACING } from '../../../ui-style-constants' interface ListItemDescriptorProps { - type: 'default' | 'mini' + type: 'default' | 'large' description: JSX.Element | string content: JSX.Element | string + isInSlideout?: boolean } export const ListItemDescriptor = ( props: ListItemDescriptorProps ): JSX.Element => { - const { description, content, type } = props + const { description, content, type, isInSlideout = false } = props return ( - + {description} - - {content} - + {content} ) } diff --git a/components/src/atoms/ListItem/__tests__/ListItem.test.tsx b/components/src/atoms/ListItem/__tests__/ListItem.test.tsx index 2f25b883fae..9cb34e3524f 100644 --- a/components/src/atoms/ListItem/__tests__/ListItem.test.tsx +++ b/components/src/atoms/ListItem/__tests__/ListItem.test.tsx @@ -26,7 +26,6 @@ describe('ListItem', () => { screen.getByText('mock listitem content') const listItem = screen.getByTestId('ListItem_error') expect(listItem).toHaveStyle(`backgroundColor: ${COLORS.red35}`) - expect(listItem).toHaveStyle(`padding: 0`) expect(listItem).toHaveStyle(`borderRadius: ${BORDERS.borderRadius4}`) }) it('should render correct style - noActive', () => { @@ -35,7 +34,6 @@ describe('ListItem', () => { screen.getByText('mock listitem content') const listItem = screen.getByTestId('ListItem_noActive') expect(listItem).toHaveStyle(`backgroundColor: ${COLORS.grey30}`) - expect(listItem).toHaveStyle(`padding: 0`) expect(listItem).toHaveStyle(`borderRadius: ${BORDERS.borderRadius4}`) }) it('should render correct style - success', () => { @@ -44,7 +42,6 @@ describe('ListItem', () => { screen.getByText('mock listitem content') const listItem = screen.getByTestId('ListItem_success') expect(listItem).toHaveStyle(`backgroundColor: ${COLORS.green35}`) - expect(listItem).toHaveStyle(`padding: 0`) expect(listItem).toHaveStyle(`borderRadius: ${BORDERS.borderRadius4}`) }) it('should render correct style - warning', () => { @@ -53,7 +50,6 @@ describe('ListItem', () => { screen.getByText('mock listitem content') const listItem = screen.getByTestId('ListItem_warning') expect(listItem).toHaveStyle(`backgroundColor: ${COLORS.yellow35}`) - expect(listItem).toHaveStyle(`padding: 0`) expect(listItem).toHaveStyle(`borderRadius: ${BORDERS.borderRadius4}`) }) it('should call on click when pressed', () => { diff --git a/components/src/atoms/ListItem/index.tsx b/components/src/atoms/ListItem/index.tsx index 39367f935c1..cb61f0a4d3c 100644 --- a/components/src/atoms/ListItem/index.tsx +++ b/components/src/atoms/ListItem/index.tsx @@ -56,7 +56,6 @@ export function ListItem(props: ListItemProps): JSX.Element { background-color: ${listItemProps.backgroundColor}; width: 100%; height: ${FLEX_MAX_CONTENT}; - padding: 0; border-radius: ${BORDERS.borderRadius4}; @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { diff --git a/components/src/atoms/Toast/index.tsx b/components/src/atoms/Toast/index.tsx index 04a202b69cf..e4a9af243ab 100644 --- a/components/src/atoms/Toast/index.tsx +++ b/components/src/atoms/Toast/index.tsx @@ -370,7 +370,7 @@ export function Toast(props: ToastProps): JSX.Element { ) : null} {message} - {linkText ? ( + {linkText != null ? ( { diff --git a/components/src/icons/icon-data.ts b/components/src/icons/icon-data.ts index bafc46e94f9..edf90a1512c 100644 --- a/components/src/icons/icon-data.ts +++ b/components/src/icons/icon-data.ts @@ -49,6 +49,11 @@ export const ICON_DATA_BY_NAME = { 'M19.9974 25.8332C21.6085 25.8332 22.9835 25.2637 24.1224 24.1248C25.2613 22.9859 25.8307 21.6109 25.8307 19.9998C25.8307 18.3887 25.2613 17.0137 24.1224 15.8748C22.9835 14.7359 21.6085 14.1665 19.9974 14.1665C18.3863 14.1665 17.0113 14.7359 15.8724 15.8748C14.7335 17.0137 14.1641 18.3887 14.1641 19.9998C14.1641 21.6109 14.7335 22.9859 15.8724 24.1248C17.0113 25.2637 18.3863 25.8332 19.9974 25.8332ZM19.9974 28.3332C17.6918 28.3332 15.7266 27.5207 14.1016 25.8957C12.4766 24.2707 11.6641 22.3054 11.6641 19.9998C11.6641 17.6943 12.4766 15.729 14.1016 14.104C15.7266 12.479 17.6918 11.6665 19.9974 11.6665C22.3029 11.6665 24.2682 12.479 25.8932 14.104C27.5182 15.729 28.3307 17.6943 28.3307 19.9998C28.3307 22.3054 27.5182 24.2707 25.8932 25.8957C24.2682 27.5207 22.3029 28.3332 19.9974 28.3332ZM2.91406 21.2498C2.55295 21.2498 2.25434 21.1318 2.01823 20.8957C1.78212 20.6596 1.66406 20.3609 1.66406 19.9998C1.66406 19.6387 1.78212 19.3401 2.01823 19.104C2.25434 18.8679 2.55295 18.7498 2.91406 18.7498H7.08073C7.44184 18.7498 7.74045 18.8679 7.97656 19.104C8.21267 19.3401 8.33073 19.6387 8.33073 19.9998C8.33073 20.3609 8.21267 20.6596 7.97656 20.8957C7.74045 21.1318 7.44184 21.2498 7.08073 21.2498H2.91406ZM32.9141 21.2498C32.5529 21.2498 32.2543 21.1318 32.0182 20.8957C31.7821 20.6596 31.6641 20.3609 31.6641 19.9998C31.6641 19.6387 31.7821 19.3401 32.0182 19.104C32.2543 18.8679 32.5529 18.7498 32.9141 18.7498H37.0807C37.4418 18.7498 37.7404 18.8679 37.9766 19.104C38.2127 19.3401 38.3307 19.6387 38.3307 19.9998C38.3307 20.3609 38.2127 20.6596 37.9766 20.8957C37.7404 21.1318 37.4418 21.2498 37.0807 21.2498H32.9141ZM19.9974 8.33317C19.6363 8.33317 19.3377 8.21511 19.1016 7.979C18.8654 7.74289 18.7474 7.44428 18.7474 7.08317V2.9165C18.7474 2.55539 18.8654 2.25678 19.1016 2.02067C19.3377 1.78456 19.6363 1.6665 19.9974 1.6665C20.3585 1.6665 20.6571 1.78456 20.8932 2.02067C21.1293 2.25678 21.2474 2.55539 21.2474 2.9165V7.08317C21.2474 7.44428 21.1293 7.74289 20.8932 7.979C20.6571 8.21511 20.3585 8.33317 19.9974 8.33317ZM19.9974 38.3332C19.6363 38.3332 19.3377 38.2151 19.1016 37.979C18.8654 37.7429 18.7474 37.4443 18.7474 37.0832V32.9165C18.7474 32.5554 18.8654 32.2568 19.1016 32.0207C19.3377 31.7846 19.6363 31.6665 19.9974 31.6665C20.3585 31.6665 20.6571 31.7846 20.8932 32.0207C21.1293 32.2568 21.2474 32.5554 21.2474 32.9165V37.0832C21.2474 37.4443 21.1293 37.7429 20.8932 37.979C20.6571 38.2151 20.3585 38.3332 19.9974 38.3332ZM9.99739 11.7498L7.6224 9.4165C7.3724 9.1665 7.25434 8.86789 7.26823 8.52067C7.28212 8.17345 7.40017 7.87484 7.6224 7.62484C7.8724 7.37484 8.17101 7.24984 8.51823 7.24984C8.86545 7.24984 9.16406 7.37484 9.41406 7.62484L11.7474 9.99984C11.9696 10.2498 12.0807 10.5415 12.0807 10.8748C12.0807 11.2082 11.9696 11.4859 11.7474 11.7082C11.5252 11.9582 11.2404 12.0832 10.8932 12.0832C10.546 12.0832 10.2474 11.9721 9.99739 11.7498ZM30.5807 32.3748L28.2474 29.9998C28.0252 29.7498 27.9141 29.4512 27.9141 29.104C27.9141 28.7568 28.0391 28.4721 28.2891 28.2498C28.5113 27.9998 28.7891 27.8748 29.1224 27.8748C29.4557 27.8748 29.7474 27.9998 29.9974 28.2498L32.3724 30.5832C32.6224 30.8332 32.7404 31.1318 32.7266 31.479C32.7127 31.8262 32.5946 32.1248 32.3724 32.3748C32.1224 32.6248 31.8238 32.7498 31.4766 32.7498C31.1293 32.7498 30.8307 32.6248 30.5807 32.3748ZM28.2474 11.7498C27.9974 11.4998 27.8724 11.2082 27.8724 10.8748C27.8724 10.5415 27.9974 10.2498 28.2474 9.99984L30.5807 7.62484C30.8307 7.37484 31.1293 7.25678 31.4766 7.27067C31.8238 7.28456 32.1224 7.40261 32.3724 7.62484C32.6224 7.87484 32.7474 8.17345 32.7474 8.52067C32.7474 8.86789 32.6224 9.1665 32.3724 9.4165L29.9974 11.7498C29.7752 11.9721 29.4904 12.0832 29.1432 12.0832C28.796 12.0832 28.4974 11.9721 28.2474 11.7498ZM7.6224 32.3748C7.3724 32.1248 7.2474 31.8262 7.2474 31.479C7.2474 31.1318 7.3724 30.8332 7.6224 30.5832L9.99739 28.2498C10.2474 27.9998 10.5391 27.8748 10.8724 27.8748C11.2057 27.8748 11.4974 27.9998 11.7474 28.2498C11.9974 28.4998 12.1224 28.7915 12.1224 29.1248C12.1224 29.4582 11.9974 29.7498 11.7474 29.9998L9.41406 32.3748C9.16406 32.6248 8.86545 32.7429 8.51823 32.729C8.17101 32.7151 7.8724 32.5971 7.6224 32.3748Z', viewBox: '0 0 40 40', }, + browser: { + path: + 'M2.91234 16.6691L2.43352 17.8238C2.70738 17.9373 3.00766 18 3.32258 18H4.15726V16.75H3.32258C3.17484 16.75 3.03707 16.7208 2.91234 16.6691ZM15.8427 16.75V18H16.6774C16.9923 18 17.2926 17.9373 17.5665 17.8238L17.0877 16.6691C16.9629 16.7208 16.8252 16.75 16.6774 16.75H15.8427ZM17.75 5.03226H19V4.32258C19 4.00766 18.9373 3.70738 18.8238 3.43353L17.6691 3.91234C17.7208 4.03707 17.75 4.17484 17.75 4.32258V5.03226ZM3.32258 2H4.15726V3.25H3.32258C3.17484 3.25 3.03707 3.27917 2.91234 3.3309L2.43353 2.17624C2.70738 2.06268 3.00766 2 3.32258 2ZM2.25 14.9677H1V15.6774C1 15.9923 1.06268 16.2926 1.17624 16.5665L2.3309 16.0877C2.27917 15.9629 2.25 15.8252 2.25 15.6774V14.9677ZM2.25 13.5484H1V12.129H2.25V13.5484ZM2.25 10.7097H1V9.29032H2.25V10.7097ZM2.25 7.87097H1V6.45161H2.25V7.87097ZM2.25 5.03226H1V4.32258C1 4.00766 1.06268 3.70737 1.17624 3.43352L2.3309 3.91234C2.27917 4.03707 2.25 4.17484 2.25 4.32258V5.03226ZM5.82661 3.25V2H7.49597V3.25H5.82661ZM9.16532 3.25V2H10.8347V3.25H9.16532ZM12.504 3.25V2H14.1734V3.25H12.504ZM15.8427 3.25V2H16.6774C16.9923 2 17.2926 2.06268 17.5665 2.17624L17.0877 3.3309C16.9629 3.27917 16.8252 3.25 16.6774 3.25H15.8427ZM17.75 6.45161H19V7.87097H17.75V6.45161ZM17.75 9.29032H19V10.7097H17.75V9.29032ZM17.75 12.129H19V13.5484H17.75V12.129ZM17.75 14.9677H19V15.6774C19 15.9923 18.9373 16.2926 18.8238 16.5665L17.6691 16.0877C17.7208 15.9629 17.75 15.8252 17.75 15.6774V14.9677ZM14.1734 16.75V18H12.504V16.75H14.1734ZM10.8347 16.75V18H9.16532V16.75H10.8347ZM7.49597 16.75V18H5.82661V16.75H7.49597ZM14 6L10.1096 7.04242L11.287 8.21975L8.21975 11.287L7.04242 10.1096L6 14L9.89036 12.9576L8.71303 11.7803L11.7803 8.71303L12.9576 9.89036L14 6Z', + viewBox: '0 0 20 20', + }, build: { path: 'M10.7431 6.14234C13.4839 4.31098 16.7063 3.3335 20.0026 3.3335C22.1913 3.3335 24.3586 3.76459 26.3807 4.60217C28.4028 5.43975 30.2401 6.66741 31.7877 8.21505C33.3354 9.76269 34.563 11.6 35.4006 13.6221C36.2382 15.6442 36.6693 17.8115 36.6693 20.0002C36.6693 23.2965 35.6918 26.5188 33.8604 29.2597C32.0291 32.0005 29.4261 34.1367 26.3807 35.3982C23.3352 36.6596 19.9841 36.9897 16.7511 36.3466C13.5181 35.7035 10.5484 34.1162 8.2175 31.7853C5.88662 29.4544 4.29928 26.4847 3.65619 23.2517C3.0131 20.0187 3.34316 16.6675 4.60462 13.6221C5.86608 10.5767 8.00229 7.97369 10.7431 6.14234ZM12.595 31.0864C14.7877 32.5515 17.3655 33.3335 20.0026 33.3335C23.5388 33.3335 26.9302 31.9287 29.4307 29.4283C31.9312 26.9278 33.3359 23.5364 33.3359 20.0002C33.3359 17.3631 32.554 14.7852 31.0889 12.5926C29.6238 10.3999 27.5414 8.69094 25.1051 7.68177C22.6687 6.6726 19.9878 6.40856 17.4014 6.92303C14.815 7.4375 12.4392 8.70737 10.5745 10.5721C8.70982 12.4368 7.43994 14.8125 6.92547 17.399C6.41101 19.9854 6.67505 22.6663 7.68422 25.1026C8.69339 27.539 10.4024 29.6213 12.595 31.0864ZM22.6519 15.5417C22.9506 16.6721 22.8987 17.8666 22.503 18.9669L28.1696 24.6502C28.2477 24.7277 28.3097 24.8198 28.352 24.9214C28.3943 25.0229 28.4161 25.1318 28.4161 25.2419C28.4161 25.3519 28.3943 25.4608 28.352 25.5623C28.3097 25.6639 28.2477 25.756 28.1696 25.8335L25.8363 28.1835C25.7588 28.2616 25.6667 28.3236 25.5651 28.3659C25.4636 28.4082 25.3546 28.43 25.2446 28.43C25.1346 28.43 25.0257 28.4082 24.9242 28.3659C24.8226 28.3236 24.7304 28.2616 24.653 28.1835L18.9696 22.5002C17.8655 22.9096 16.6626 22.9712 15.5225 22.6766C14.3823 22.3821 13.3597 21.7456 12.592 20.8527C11.8243 19.9598 11.3484 18.8533 11.2282 17.6819C11.108 16.5105 11.3493 15.3304 11.9196 14.3002L15.8363 18.2169L18.2363 15.8669L14.3196 11.9502C15.3466 11.3912 16.5192 11.1576 17.6819 11.2803C18.8447 11.403 19.9427 11.8763 20.8303 12.6374C21.7179 13.3984 22.3532 14.4113 22.6519 15.5417Z', @@ -716,6 +721,11 @@ export const ICON_DATA_BY_NAME = { 'M10.8307 8.3335L1.66406 31.6668H4.78906L7.16406 25.4168H17.8307L20.2057 31.6668H23.3307L14.1641 8.3335H10.8307ZM16.8307 22.7502H8.16406L12.4141 11.4585H12.5807L16.8307 22.7502ZM30.1577 16.6668L24.1641 31.6668H26.2073L27.7602 27.649H34.7346L36.2875 31.6668H38.3307L32.3371 16.6668H30.1577ZM34.0807 25.9347H28.4141L31.1929 18.6758H31.3019L34.0807 25.9347Z', viewBox: '0 0 40 40', }, + 'tip-position': { + path: + 'M10.75 2H9.25V4.75H10.75V2ZM10.75 9.25V7.25H9.25V9.25H7.25V10.75H9.25V12.75H10.75V10.75H12.75V9.25H10.75ZM10.75 18V15.25H9.25V18H10.75ZM2 9.25V10.75H4.75V9.25H2ZM18 9.25H15.25V10.75H18V9.25Z', + viewBox: '0 0 20 20', + }, transfer: { path: 'M3.33333 12.1673C3.33333 10.5423 3.89931 9.16384 5.03125 8.0319C6.16319 6.89996 7.54167 6.33398 9.16667 6.33398C10.6528 6.33398 11.9479 6.82357 13.0521 7.80273C14.1562 8.7819 14.7917 10.0076 14.9583 11.4798L16.3333 10.1673L17.5 11.334L14.1667 14.6673L10.8333 11.334L12.0208 10.1673L13.25 11.3965C13.0556 10.4104 12.5764 9.59787 11.8125 8.95898C11.0486 8.3201 10.1667 8.00065 9.16667 8.00065C8.01389 8.00065 7.03125 8.4069 6.21875 9.2194C5.40625 10.0319 5 11.0145 5 12.1673L5 14.6673L3.33333 14.6673L3.33333 12.1673Z', diff --git a/components/src/images/labware/measurement-guide/index.ts b/components/src/images/labware/measurement-guide/index.ts index b866627db1d..6b6c5e14fee 100644 --- a/components/src/images/labware/measurement-guide/index.ts +++ b/components/src/images/labware/measurement-guide/index.ts @@ -107,6 +107,10 @@ const FOOTPRINT_DIAGRAMS: Diagrams = { new URL(FOOTPRINT_IMAGE_RELATIVE_PATH, import.meta.url).href, new URL(DIMENSIONS_HEIGHT_PLATE_IMAGE_RELATIVE_PATH, import.meta.url).href, ], + lid: [ + new URL(FOOTPRINT_IMAGE_RELATIVE_PATH, import.meta.url).href, + new URL(DIMENSIONS_HEIGHT_PLATE_IMAGE_RELATIVE_PATH, import.meta.url).href, + ], } const ALUM_BLOCK_FOOTPRINTS: Diagrams = { diff --git a/components/src/organisms/Toolbox/index.tsx b/components/src/organisms/Toolbox/index.tsx index 4346806b861..e7437e1e57f 100644 --- a/components/src/organisms/Toolbox/index.tsx +++ b/components/src/organisms/Toolbox/index.tsx @@ -76,18 +76,18 @@ export function Toolbox(props: ToolboxProps): JSX.Element { ...(side === 'left' && { left: '0' }), ...(horizontalSide === 'bottom' && { bottom: '0' }), ...(horizontalSide === 'top' && { top: '5rem' }), + zIndex: 10, } : {} return ( Optional[int]: @@ -15,19 +15,8 @@ def execute(client: pmk.SSHClient, command: str, args: list) -> Optional[int]: stdin, stdout, stderr = client.exec_command(command, get_pty=True) stdout_lines: List[str] = [] stderr_lines: List[str] = [] - time.sleep(15) + time.sleep(25) - # check stdout, stderr - - # Check the exit status of the command. - # while not stdout.channel.exit_status_ready(): - # if stdout.channel.recv_ready(): - # stdout_lines = stdout.readlines() - # print(f"{args[0]} output:", "".join(stdout_lines)) - # if stderr.channel.recv_ready(): - # stderr_lines = stderr.readlines() - # print(f"{args[0]} ERROR:", "".join(stdout_lines)) - # return 1 if stderr.channel.recv_ready: stderr_lines = stderr.readlines() if stderr_lines != []: @@ -58,24 +47,9 @@ def connect_ssh(ip: str) -> pmk.SSHClient: return client -# Load Robot IPs -file_name = sys.argv[1] -robot_ips = [] -robot_names = [] - -with open(file_name) as file: - for line in file.readlines(): - info = line.split(",") - if "Y" in info[2]: - robot_ips.append(info[0]) - robot_names.append(info[1]) - -cmd = "nohup python3 -m hardware_testing.scripts.abr_asair_sensor {name} {duration} {frequency}" -cd = "cd /opt/opentrons-robot-server && " -print("Executing Script on All Robots:") - - -def run_command_on_ip(index: int) -> None: +def run_command_on_ip( + index: int, robot_ips: List[str], robot_names: List[str], cd: str, cmd: str +) -> None: """Execute ssh command and start abr_asair script on the specified robot.""" curr_ip = robot_ips[index] try: @@ -87,15 +61,35 @@ def run_command_on_ip(index: int) -> None: print(f"Error running command on {curr_ip}: {e}") -# Launch the processes for each robot. -processes = [] -for index in range(len(robot_ips)): - process = multiprocessing.Process(target=run_command_on_ip, args=(index,)) - processes.append(process) +def run(file_name: str) -> List[Any]: + """Run asair script module.""" + # Load Robot IPs + cmd = "nohup python3 -m hardware_testing.scripts.abr_asair_sensor {name} {duration} {frequency}" + cd = "cd /opt/opentrons-robot-server && " + robot_ips = [] + robot_names = [] + with open(file_name) as file: + for line in file.readlines(): + info = line.split(",") + if "Y" in info[2]: + robot_ips.append(info[0]) + robot_names.append(info[1]) + print("Executing Script on All Robots:") + # Launch the processes for each robot. + processes = [] + for index in range(len(robot_ips)): + process = multiprocessing.Process( + target=run_command_on_ip, args=(index, robot_ips, robot_names, cd, cmd) + ) + processes.append(process) + return processes if __name__ == "__main__": # Wait for all processes to finish. + file_name = sys.argv[1] + processes = run(file_name) + for process in processes: process.start() time.sleep(20) diff --git a/hardware-testing/hardware_testing/scripts/abr_asair_sensor.py b/hardware-testing/hardware_testing/scripts/abr_asair_sensor.py index 1e8fca0358c..ba41f9399f1 100644 --- a/hardware-testing/hardware_testing/scripts/abr_asair_sensor.py +++ b/hardware-testing/hardware_testing/scripts/abr_asair_sensor.py @@ -80,7 +80,7 @@ def __init__(self, robot: str, duration: int, frequency: int) -> None: break # write to google sheet try: - if google_sheet.creditals.access_token_expired: + if google_sheet.credentials.access_token_expired: google_sheet.gc.login() google_sheet.write_header(header) google_sheet.update_row_index() diff --git a/hardware-testing/hardware_testing/scripts/gripper_ot3.py b/hardware-testing/hardware_testing/scripts/gripper_ot3.py index 511ea11809d..cd131b8f13a 100644 --- a/hardware-testing/hardware_testing/scripts/gripper_ot3.py +++ b/hardware-testing/hardware_testing/scripts/gripper_ot3.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from typing import Optional, List, Any, Dict -from opentrons.config.defaults_ot3 import CapacitivePassSettings, OutputOptions +from opentrons.config.defaults_ot3 import CapacitivePassSettings from opentrons.hardware_control.ot3api import OT3API from hardware_testing.opentrons_api import types @@ -73,7 +73,6 @@ max_overrun_distance_mm=1, speed_mm_per_s=1, sensor_threshold_pf=0.5, - output_option=OutputOptions.sync_only, ) LABWARE_PROBE_CORNER_TOP_LEFT_XY = { "plate": Point(x=5, y=-5), diff --git a/hardware/opentrons_hardware/firmware_bindings/messages/messages.py b/hardware/opentrons_hardware/firmware_bindings/messages/messages.py index 0249ddec69e..35683bc1afb 100644 --- a/hardware/opentrons_hardware/firmware_bindings/messages/messages.py +++ b/hardware/opentrons_hardware/firmware_bindings/messages/messages.py @@ -74,6 +74,7 @@ defs.BaselineSensorResponse, defs.SetSensorThresholdRequest, defs.ReadFromSensorResponse, + defs.BatchReadFromSensorResponse, defs.SensorThresholdResponse, defs.SensorDiagnosticRequest, defs.SensorDiagnosticResponse, diff --git a/hardware/opentrons_hardware/hardware_control/tool_sensors.py b/hardware/opentrons_hardware/hardware_control/tool_sensors.py index 173a8c2738b..95076f01c1c 100644 --- a/hardware/opentrons_hardware/hardware_control/tool_sensors.py +++ b/hardware/opentrons_hardware/hardware_control/tool_sensors.py @@ -1,5 +1,6 @@ """Functions for commanding motion limited by tool sensors.""" import asyncio +from contextlib import AsyncExitStack from functools import partial from typing import ( Union, @@ -11,6 +12,7 @@ AsyncContextManager, Optional, AsyncIterator, + Mapping, ) from logging import getLogger from numpy import float64 @@ -41,6 +43,7 @@ from opentrons_hardware.sensors.sensor_driver import SensorDriver, LogListener from opentrons_hardware.sensors.types import ( sensor_fixed_point_conversion, + SensorDataType, ) from opentrons_hardware.sensors.sensor_types import ( SensorInformation, @@ -61,28 +64,13 @@ ) LOG = getLogger(__name__) + PipetteProbeTarget = Literal[NodeId.pipette_left, NodeId.pipette_right] InstrumentProbeTarget = Union[PipetteProbeTarget, Literal[NodeId.gripper]] ProbeSensorDict = Union[ Dict[SensorId, PressureSensor], Dict[SensorId, CapacitiveSensor] ] -pressure_output_file_heading = [ - "time(s)", - "Pressure(pascals)", - "z_velocity(mm/s)", - "plunger_velocity(mm/s)", - "threshold(pascals)", -] - -capacitive_output_file_heading = [ - "time(s)", - "Capacitance(farads)", - "z_velocity(mm/s)", - "plunger_velocity(mm/s)", - "threshold(farads)", -] - def _fix_pass_step_for_buffer( move_group: MoveGroupStep, @@ -167,124 +155,6 @@ def _build_pass_step( return move_group -async def run_sync_buffer_to_csv( - messenger: CanMessenger, - mount_speed: float, - plunger_speed: float, - threshold: float, - head_node: NodeId, - move_group: MoveGroupRunner, - log_files: Dict[SensorId, str], - tool: InstrumentProbeTarget, - sensor_type: SensorType, - output_file_heading: list[str], - raise_z: Optional[MoveGroupRunner] = None, -) -> Dict[NodeId, MotorPositionStatus]: - """Runs the sensor pass move group and creates a csv file with the results.""" - sensor_metadata = [0, 0, mount_speed, plunger_speed, threshold] - positions = await move_group.run(can_messenger=messenger) - # wait a little to see the dropoff curve - await asyncio.sleep(0.15) - for sensor_id in log_files.keys(): - await messenger.ensure_send( - node_id=tool, - message=BindSensorOutputRequest( - payload=BindSensorOutputRequestPayload( - sensor=SensorTypeField(sensor_type), - sensor_id=SensorIdField(sensor_id), - binding=SensorOutputBindingField(SensorOutputBinding.none), - ) - ), - expected_nodes=[tool], - ) - if raise_z is not None: - # if probing is finished, move the head node back up before requesting the data buffer - if positions[head_node].move_ack == MoveCompleteAck.stopped_by_condition: - await raise_z.run(can_messenger=messenger) - for sensor_id in log_files.keys(): - sensor_capturer = LogListener( - mount=head_node, - data_file=log_files[sensor_id], - file_heading=output_file_heading, - sensor_metadata=sensor_metadata, - ) - async with sensor_capturer: - messenger.add_listener(sensor_capturer, None) - request = SendAccumulatedSensorDataRequest( - payload=SendAccumulatedSensorDataPayload( - sensor_id=SensorIdField(sensor_id), - sensor_type=SensorTypeField(sensor_type), - ) - ) - await messenger.send( - node_id=tool, - message=request, - ) - await sensor_capturer.wait_for_complete( - message_index=request.payload.message_index.value - ) - messenger.remove_listener(sensor_capturer) - return positions - - -async def run_stream_output_to_csv( - messenger: CanMessenger, - sensors: ProbeSensorDict, - mount_speed: float, - plunger_speed: float, - threshold: float, - head_node: NodeId, - move_group: MoveGroupRunner, - log_files: Dict[SensorId, str], - output_file_heading: list[str], -) -> Dict[NodeId, MotorPositionStatus]: - """Runs the sensor pass move group and creates a csv file with the results.""" - sensor_metadata = [0, 0, mount_speed, plunger_speed, threshold] - sensor_capturer = LogListener( - mount=head_node, - data_file=log_files[ - next(iter(log_files)) - ], # hardcode to the first file, need to think more on this - file_heading=output_file_heading, - sensor_metadata=sensor_metadata, - ) - binding = [SensorOutputBinding.sync, SensorOutputBinding.report] - binding_field = SensorOutputBindingField.from_flags(binding) - for sensor_id in sensors.keys(): - sensor_info = sensors[sensor_id].sensor - await messenger.ensure_send( - node_id=sensor_info.node_id, - message=BindSensorOutputRequest( - payload=BindSensorOutputRequestPayload( - sensor=SensorTypeField(sensor_info.sensor_type), - sensor_id=SensorIdField(sensor_info.sensor_id), - binding=binding_field, - ) - ), - expected_nodes=[sensor_info.node_id], - ) - - messenger.add_listener(sensor_capturer, None) - async with sensor_capturer: - positions = await move_group.run(can_messenger=messenger) - messenger.remove_listener(sensor_capturer) - - for sensor_id in sensors.keys(): - sensor_info = sensors[sensor_id].sensor - await messenger.ensure_send( - node_id=sensor_info.node_id, - message=BindSensorOutputRequest( - payload=BindSensorOutputRequestPayload( - sensor=SensorTypeField(sensor_info.sensor_type), - sensor_id=SensorIdField(sensor_info.sensor_id), - binding=SensorOutputBindingField(SensorOutputBinding.none), - ) - ), - expected_nodes=[sensor_info.node_id], - ) - return positions - - async def _setup_pressure_sensors( messenger: CanMessenger, sensor_id: SensorId, @@ -351,42 +221,42 @@ async def _setup_capacitive_sensors( return result -async def _run_with_binding( +async def finalize_logs( messenger: CanMessenger, - sensors: ProbeSensorDict, - sensor_runner: MoveGroupRunner, - binding: List[SensorOutputBinding], -) -> Dict[NodeId, MotorPositionStatus]: - binding_field = SensorOutputBindingField.from_flags(binding) - for sensor_id in sensors.keys(): - sensor_info = sensors[sensor_id].sensor + tool: NodeId, + listeners: Dict[SensorId, LogListener], + sensors: Mapping[SensorId, Union[CapacitiveSensor, PressureSensor]], +) -> None: + """Signal the sensors to finish sending their data and wait for it to flush out.""" + for s_id in sensors.keys(): + # Tell the sensor to stop recording await messenger.ensure_send( - node_id=sensor_info.node_id, - message=BindSensorOutputRequest( - payload=BindSensorOutputRequestPayload( - sensor=SensorTypeField(sensor_info.sensor_type), - sensor_id=SensorIdField(sensor_info.sensor_id), - binding=binding_field, - ) - ), - expected_nodes=[sensor_info.node_id], - ) - - result = await sensor_runner.run(can_messenger=messenger) - for sensor_id in sensors.keys(): - sensor_info = sensors[sensor_id].sensor - await messenger.ensure_send( - node_id=sensor_info.node_id, + node_id=tool, message=BindSensorOutputRequest( payload=BindSensorOutputRequestPayload( - sensor=SensorTypeField(sensor_info.sensor_type), - sensor_id=SensorIdField(sensor_info.sensor_id), + sensor=SensorTypeField(sensors[s_id].sensor.sensor_type), + sensor_id=SensorIdField(s_id), binding=SensorOutputBindingField(SensorOutputBinding.none), ) ), - expected_nodes=[sensor_info.node_id], + expected_nodes=[tool], ) - return result + request = SendAccumulatedSensorDataRequest( + payload=SendAccumulatedSensorDataPayload( + sensor_id=SensorIdField(s_id), + sensor_type=SensorTypeField(sensors[s_id].sensor.sensor_type), + ) + ) + # set the message index of the Ack that signals this sensor is finished sending data + listeners[s_id].set_stop_ack(request.payload.message_index.value) + # tell the sensor to clear it's queue + await messenger.send( + node_id=tool, + message=request, + ) + # wait for the data to finish sending + for listener in listeners.values(): + await listener.wait_for_complete() async def liquid_probe( @@ -399,15 +269,13 @@ async def liquid_probe( threshold_pascals: float, plunger_impulse_time: float, num_baseline_reads: int, - csv_output: bool = False, - sync_buffer_output: bool = False, - can_bus_only_output: bool = False, - data_files: Optional[Dict[SensorId, str]] = None, sensor_id: SensorId = SensorId.S0, force_both_sensors: bool = False, + response_queue: Optional[ + asyncio.Queue[Dict[SensorId, List[SensorDataType]]] + ] = None, ) -> Dict[NodeId, MotorPositionStatus]: """Move the mount and pipette simultaneously while reading from the pressure sensor.""" - log_files: Dict[SensorId, str] = {} if not data_files else data_files sensor_driver = SensorDriver() threshold_fixed_point = threshold_pascals * sensor_fixed_point_conversion sensor_binding = None @@ -420,7 +288,7 @@ async def liquid_probe( + SensorOutputBinding.report + SensorOutputBinding.multi_sensor_sync ) - pressure_sensors = await _setup_pressure_sensors( + pressure_sensors: Dict[SensorId, PressureSensor] = await _setup_pressure_sensors( messenger, sensor_id, tool, @@ -440,6 +308,7 @@ async def liquid_probe( duration=float64(plunger_impulse_time), present_nodes=[tool], ) + sensor_group = _build_pass_step( movers=[head_node, tool], distance={head_node: max_z_distance, tool: p_pass_distance}, @@ -449,64 +318,56 @@ async def liquid_probe( stop_condition=MoveStopCondition.sync_line, binding_flags=sensor_binding, ) - if sync_buffer_output: - sensor_group = _fix_pass_step_for_buffer( - sensor_group, - movers=[head_node, tool], - distance={head_node: max_z_distance, tool: p_pass_distance}, - speed={head_node: mount_speed, tool: plunger_speed}, - sensor_type=SensorType.pressure, - sensor_id=sensor_id, - stop_condition=MoveStopCondition.sync_line, - binding_flags=sensor_binding, - ) + sensor_group = _fix_pass_step_for_buffer( + sensor_group, + movers=[head_node, tool], + distance={head_node: max_z_distance, tool: p_pass_distance}, + speed={head_node: mount_speed, tool: plunger_speed}, + sensor_type=SensorType.pressure, + sensor_id=sensor_id, + stop_condition=MoveStopCondition.sync_line, + binding_flags=sensor_binding, + ) sensor_runner = MoveGroupRunner(move_groups=[[lower_plunger], [sensor_group]]) - if csv_output: - return await run_stream_output_to_csv( - messenger, - pressure_sensors, - mount_speed, - plunger_speed, - threshold_pascals, - head_node, - sensor_runner, - log_files, - pressure_output_file_heading, - ) - elif sync_buffer_output: - raise_z = create_step( - distance={head_node: float64(max_z_distance)}, - velocity={head_node: float64(-1 * mount_speed)}, - acceleration={}, - duration=float64(max_z_distance / mount_speed), - present_nodes=[head_node], - ) - raise_z_runner = MoveGroupRunner(move_groups=[[raise_z]]) - - return await run_sync_buffer_to_csv( - messenger=messenger, - mount_speed=mount_speed, - plunger_speed=plunger_speed, - threshold=threshold_pascals, - head_node=head_node, - move_group=sensor_runner, - raise_z=raise_z_runner, - log_files=log_files, - tool=tool, - sensor_type=SensorType.pressure, - output_file_heading=pressure_output_file_heading, - ) - elif can_bus_only_output: - binding = [SensorOutputBinding.sync, SensorOutputBinding.report] - return await _run_with_binding( - messenger, pressure_sensors, sensor_runner, binding - ) - else: # none - binding = [SensorOutputBinding.sync] - return await _run_with_binding( - messenger, pressure_sensors, sensor_runner, binding - ) + + raise_z = create_step( + distance={head_node: float64(max_z_distance)}, + velocity={head_node: float64(-1 * mount_speed)}, + acceleration={}, + duration=float64(max_z_distance / mount_speed), + present_nodes=[head_node], + ) + + raise_z_runner = MoveGroupRunner(move_groups=[[raise_z]]) + listeners = { + s_id: LogListener(messenger, pressure_sensors[s_id]) + for s_id in pressure_sensors.keys() + } + + LOG.info( + f"Starting LLD pass: {head_node} {sensor_id} max p distance {max_p_distance} max z distance {max_z_distance}" + ) + async with AsyncExitStack() as binding_stack: + for listener in listeners.values(): + await binding_stack.enter_async_context(listener) + positions = await sensor_runner.run(can_messenger=messenger) + if positions[head_node].move_ack == MoveCompleteAck.stopped_by_condition: + LOG.info( + f"Liquid found {head_node} motor_postion {positions[head_node].motor_position} encoder position {positions[head_node].encoder_position}" + ) + await raise_z_runner.run(can_messenger=messenger) + await finalize_logs(messenger, tool, listeners, pressure_sensors) + + # give response data to any consumer that wants it + if response_queue: + for s_id in listeners.keys(): + data = listeners[s_id].get_data() + if data: + for d in data: + response_queue.put_nowait({s_id: data}) + + return positions async def check_overpressure( @@ -536,10 +397,9 @@ async def capacitive_probe( mount_speed: float, sensor_id: SensorId = SensorId.S0, relative_threshold_pf: float = 1.0, - csv_output: bool = False, - sync_buffer_output: bool = False, - can_bus_only_output: bool = False, - data_files: Optional[Dict[SensorId, str]] = None, + response_queue: Optional[ + asyncio.Queue[dict[SensorId, list[SensorDataType]]] + ] = None, ) -> MotorPositionStatus: """Move the specified tool down until its capacitive sensor triggers. @@ -549,7 +409,6 @@ async def capacitive_probe( The direction is sgn(distance)*sgn(speed), so you can set the direction either by negating speed or negating distance. """ - log_files: Dict[SensorId, str] = {} if not data_files else data_files sensor_driver = SensorDriver() pipette_present = tool in [NodeId.pipette_left, NodeId.pipette_right] @@ -577,53 +436,36 @@ async def capacitive_probe( sensor_id=sensor_id, stop_condition=MoveStopCondition.sync_line, ) - if sync_buffer_output: - sensor_group = _fix_pass_step_for_buffer( - sensor_group, - movers=movers, - distance=probe_distance, - speed=probe_speed, - sensor_type=SensorType.capacitive, - sensor_id=sensor_id, - stop_condition=MoveStopCondition.sync_line, - ) + + sensor_group = _fix_pass_step_for_buffer( + sensor_group, + movers=movers, + distance=probe_distance, + speed=probe_speed, + sensor_type=SensorType.capacitive, + sensor_id=sensor_id, + stop_condition=MoveStopCondition.sync_line, + ) runner = MoveGroupRunner(move_groups=[[sensor_group]]) - if csv_output: - positions = await run_stream_output_to_csv( - messenger, - capacitive_sensors, - mount_speed, - 0.0, - relative_threshold_pf, - mover, - runner, - log_files, - capacitive_output_file_heading, - ) - elif sync_buffer_output: - positions = await run_sync_buffer_to_csv( - messenger, - mount_speed, - 0.0, - relative_threshold_pf, - mover, - runner, - log_files, - tool=tool, - sensor_type=SensorType.capacitive, - output_file_heading=capacitive_output_file_heading, - ) - elif can_bus_only_output: - binding = [SensorOutputBinding.sync, SensorOutputBinding.report] - positions = await _run_with_binding( - messenger, capacitive_sensors, runner, binding - ) - else: - binding = [SensorOutputBinding.sync] - positions = await _run_with_binding( - messenger, capacitive_sensors, runner, binding - ) + + listeners = { + s_id: LogListener(messenger, capacitive_sensors[s_id]) + for s_id in capacitive_sensors.keys() + } + async with AsyncExitStack() as binding_stack: + for listener in listeners.values(): + await binding_stack.enter_async_context(listener) + positions = await runner.run(can_messenger=messenger) + await finalize_logs(messenger, tool, listeners, capacitive_sensors) + + # give response data to any consumer that wants it + if response_queue: + for s_id in listeners.keys(): + data = listeners[s_id].get_data() + if data: + for d in data: + response_queue.put_nowait({s_id: data}) return positions[mover] diff --git a/hardware/opentrons_hardware/sensors/__init__.py b/hardware/opentrons_hardware/sensors/__init__.py index adc4f0c52af..3ae059861a1 100644 --- a/hardware/opentrons_hardware/sensors/__init__.py +++ b/hardware/opentrons_hardware/sensors/__init__.py @@ -1 +1,3 @@ """Sub-module for sensor drivers.""" + +SENSOR_LOG_NAME = "pipettes-sensor-log" diff --git a/hardware/opentrons_hardware/sensors/sensor_driver.py b/hardware/opentrons_hardware/sensors/sensor_driver.py index 611bc091970..0f1904f8a26 100644 --- a/hardware/opentrons_hardware/sensors/sensor_driver.py +++ b/hardware/opentrons_hardware/sensors/sensor_driver.py @@ -1,9 +1,8 @@ """Capacitve Sensor Driver Class.""" import time import asyncio -import csv -from typing import Optional, AsyncIterator, Any, Sequence +from typing import Optional, AsyncIterator, Any, Sequence, List, Union from contextlib import asynccontextmanager, suppress from logging import getLogger @@ -19,7 +18,6 @@ from opentrons_hardware.firmware_bindings.constants import ( SensorOutputBinding, SensorThresholdMode, - NodeId, ) from opentrons_hardware.sensors.types import ( SensorDataType, @@ -32,7 +30,12 @@ SensorThresholdInformation, ) -from opentrons_hardware.sensors.sensor_types import BaseSensorType, ThresholdSensorType +from opentrons_hardware.sensors.sensor_types import ( + BaseSensorType, + ThresholdSensorType, + PressureSensor, + CapacitiveSensor, +) from opentrons_hardware.firmware_bindings.messages.payloads import ( BindSensorOutputRequestPayload, ) @@ -46,8 +49,10 @@ ) from .sensor_abc import AbstractSensorDriver from .scheduler import SensorScheduler +from . import SENSOR_LOG_NAME LOG = getLogger(__name__) +SENSOR_LOG = getLogger(SENSOR_LOG_NAME) class SensorDriver(AbstractSensorDriver): @@ -226,43 +231,50 @@ class LogListener: def __init__( self, - mount: NodeId, - data_file: Any, - file_heading: Sequence[str], - sensor_metadata: Sequence[Any], + messenger: CanMessenger, + sensor: Union[PressureSensor, CapacitiveSensor], ) -> None: """Build the capturer.""" - self.csv_writer = Any - self.data_file = data_file - self.file_heading = file_heading - self.sensor_metadata = sensor_metadata - self.response_queue: asyncio.Queue[float] = asyncio.Queue() - self.mount = mount + self.response_queue: asyncio.Queue[SensorDataType] = asyncio.Queue() + self.tool = sensor.sensor.node_id self.start_time = 0.0 self.event: Any = None + self.messenger = messenger + self.sensor = sensor + self.type = sensor.sensor.sensor_type + self.id = sensor.sensor.sensor_id - async def __aenter__(self) -> None: - """Create a csv heading for logging pressure readings.""" - self.data_file = open(self.data_file, "w") - self.csv_writer = csv.writer(self.data_file) - self.csv_writer.writerows([self.file_heading, self.sensor_metadata]) + def get_data(self) -> Optional[List[SensorDataType]]: + """Return the sensor data captured by this listener.""" + if self.response_queue.empty(): + return None + data: List[SensorDataType] = [] + while not self.response_queue.empty(): + data.append(self.response_queue.get_nowait()) + return data + async def __aenter__(self) -> None: + """Start logging sensor readings.""" + self.messenger.add_listener(self, None) self.start_time = time.time() + SENSOR_LOG.info(f"Data capture for {self.tool.name} started {self.start_time}") async def __aexit__(self, *args: Any) -> None: - """Close csv file.""" - self.data_file.close() + """Finish the capture.""" + self.messenger.remove_listener(self) + SENSOR_LOG.info(f"Data capture for {self.tool.name} ended {time.time()}") - async def wait_for_complete( - self, wait_time: float = 10, message_index: int = 0 - ) -> None: - """Wait for the data to stop, only use this with a send_accumulated_data_request.""" + def set_stop_ack(self, message_index: int = 0) -> None: + """Tell the Listener which message index to wait for.""" self.event = asyncio.Event() self.expected_ack = message_index + + async def wait_for_complete(self, wait_time: float = 10) -> None: + """Wait for the data to stop.""" with suppress(asyncio.TimeoutError): await asyncio.wait_for(self.event.wait(), wait_time) if not self.event.is_set(): - LOG.error("Did not receive the full data set from the sensor") + SENSOR_LOG.error("Did not receive the full data set from the sensor") self.event = None def __call__( @@ -271,30 +283,44 @@ def __call__( arbitration_id: ArbitrationId, ) -> None: """Callback entry point for capturing messages.""" + if arbitration_id.parts.originating_node_id != self.tool: + # check that this is from the node we care about + return if isinstance(message, message_definitions.ReadFromSensorResponse): + if ( + message.payload.sensor_id.value is not self.id + or message.payload.sensor is not self.type + ): + # ignore sensor responses from other sensors + return data = sensor_types.SensorDataType.build( message.payload.sensor_data, message.payload.sensor - ).to_float() + ) self.response_queue.put_nowait(data) - current_time = round((time.time() - self.start_time), 3) - self.csv_writer.writerow([current_time, data]) # type: ignore + SENSOR_LOG.info( + f"Revieved from {arbitration_id}: {message.payload.sensor_id}:{message.payload.sensor}: {data}" + ) if isinstance(message, message_definitions.BatchReadFromSensorResponse): data_length = message.payload.data_length.value data_bytes = message.payload.sensor_data.value data_ints = [ - int.from_bytes(data_bytes[i * 4 : i * 4 + 4]) + int.from_bytes(data_bytes[i * 4 : i * 4 + 4], byteorder="little") for i in range(data_length) ] - for d in data_ints: - data = sensor_types.SensorDataType.build( - d, message.payload.sensor - ).to_float() - self.response_queue.put_nowait(data) - current_time = round((time.time() - self.start_time), 3) - self.csv_writer.writerow([current_time, data]) + data_floats = [ + sensor_types.SensorDataType.build(d, message.payload.sensor) + for d in data_ints + ] + + for d in data_floats: + self.response_queue.put_nowait(d) + SENSOR_LOG.info( + f"Revieved from {arbitration_id}: {message.payload.sensor_id}:{message.payload.sensor}: {data_floats}" + ) if isinstance(message, message_definitions.Acknowledgement): if ( self.event is not None and message.payload.message_index.value == self.expected_ack ): + SENSOR_LOG.info("Finished receiving sensor data") self.event.set() diff --git a/hardware/tests/opentrons_hardware/hardware_control/test_tool_sensors.py b/hardware/tests/opentrons_hardware/hardware_control/test_tool_sensors.py index 2dc7614da63..0c53b81057a 100644 --- a/hardware/tests/opentrons_hardware/hardware_control/test_tool_sensors.py +++ b/hardware/tests/opentrons_hardware/hardware_control/test_tool_sensors.py @@ -1,12 +1,10 @@ """Test the tool-sensor coordination code.""" import logging from mock import patch, AsyncMock, call -import os import pytest from contextlib import asynccontextmanager from typing import Iterator, List, Tuple, AsyncIterator, Any, Dict, Callable from opentrons_hardware.firmware_bindings.messages.message_definitions import ( - AddLinearMoveRequest, ExecuteMoveGroupRequest, MoveCompleted, ReadFromSensorResponse, @@ -50,7 +48,6 @@ SensorType, SensorThresholdMode, SensorOutputBinding, - MoveStopCondition, ) from opentrons_hardware.sensors.scheduler import SensorScheduler from opentrons_hardware.sensors.sensor_driver import SensorDriver @@ -187,78 +184,7 @@ def check_second_move( ), ] - def get_responder() -> Iterator[ - Callable[ - [NodeId, MessageDefinition], List[Tuple[NodeId, MessageDefinition, NodeId]] - ] - ]: - yield check_first_move - yield check_second_move - - responder_getter = get_responder() - - def move_responder( - node_id: NodeId, message: MessageDefinition - ) -> List[Tuple[NodeId, MessageDefinition, NodeId]]: - message.payload.serialize() - if isinstance(message, ExecuteMoveGroupRequest): - responder = next(responder_getter) - return responder(node_id, message) - else: - return [] - - message_send_loopback.add_responder(move_responder) - - position = await liquid_probe( - messenger=mock_messenger, - tool=target_node, - head_node=motor_node, - max_p_distance=70, - mount_speed=10, - plunger_speed=8, - threshold_pascals=threshold_pascals, - plunger_impulse_time=0.2, - num_baseline_reads=20, - csv_output=False, - sync_buffer_output=False, - can_bus_only_output=False, - sensor_id=SensorId.S0, - ) - assert position[motor_node].positions_only()[0] == 14 - assert mock_sensor_threshold.call_args_list[0][0][0] == SensorThresholdInformation( - sensor=sensor_info, - data=SensorDataType.build(threshold_pascals * 65536, sensor_info.sensor_type), - mode=SensorThresholdMode.absolute, - ) - - -@pytest.mark.parametrize( - "csv_output, sync_buffer_output, can_bus_only_output, move_stop_condition", - [ - (True, False, False, MoveStopCondition.sync_line), - (True, True, False, MoveStopCondition.sensor_report), - (False, False, True, MoveStopCondition.sync_line), - ], -) -async def test_liquid_probe_output_options( - mock_messenger: AsyncMock, - mock_bind_output: AsyncMock, - message_send_loopback: CanLoopback, - mock_sensor_threshold: AsyncMock, - csv_output: bool, - sync_buffer_output: bool, - can_bus_only_output: bool, - move_stop_condition: MoveStopCondition, -) -> None: - """Test that liquid_probe targets the right nodes.""" - sensor_info = SensorInformation( - sensor_type=SensorType.pressure, - sensor_id=SensorId.S0, - node_id=NodeId.pipette_left, - ) - test_csv_file: str = os.path.join(os.getcwd(), "test.csv") - - def check_first_move( + def check_third_move( node_id: NodeId, message: MessageDefinition ) -> List[Tuple[NodeId, MessageDefinition, NodeId]]: return [ @@ -274,44 +200,10 @@ def check_first_move( ack_id=UInt8Field(1), ) ), - NodeId.pipette_left, + motor_node, ) ] - def check_second_move( - node_id: NodeId, message: MessageDefinition - ) -> List[Tuple[NodeId, MessageDefinition, NodeId]]: - return [ - ( - NodeId.host, - MoveCompleted( - payload=MoveCompletedPayload( - group_id=UInt8Field(1), - seq_id=UInt8Field(0), - current_position_um=UInt32Field(14000), - encoder_position_um=Int32Field(14000), - position_flags=MotorPositionFlagsField(0), - ack_id=UInt8Field(2), - ) - ), - NodeId.head_l, - ), - ( - NodeId.host, - MoveCompleted( - payload=MoveCompletedPayload( - group_id=UInt8Field(1), - seq_id=UInt8Field(0), - current_position_um=UInt32Field(14000), - encoder_position_um=Int32Field(14000), - position_flags=MotorPositionFlagsField(0), - ack_id=UInt8Field(2), - ) - ), - NodeId.pipette_left, - ), - ] - def get_responder() -> Iterator[ Callable[ [NodeId, MessageDefinition], List[Tuple[NodeId, MessageDefinition, NodeId]] @@ -319,6 +211,7 @@ def get_responder() -> Iterator[ ]: yield check_first_move yield check_second_move + yield check_third_move responder_getter = get_responder() @@ -330,42 +223,26 @@ def move_responder( responder = next(responder_getter) return responder(node_id, message) else: - if ( - isinstance(message, AddLinearMoveRequest) - and node_id == NodeId.pipette_left - and message.payload.group_id == 2 - ): - assert ( - message.payload.request_stop_condition.value == move_stop_condition - ) return [] message_send_loopback.add_responder(move_responder) - try: - position = await liquid_probe( - messenger=mock_messenger, - tool=NodeId.pipette_left, - head_node=NodeId.head_l, - max_p_distance=70, - mount_speed=10, - plunger_speed=8, - threshold_pascals=14, - plunger_impulse_time=0.2, - num_baseline_reads=20, - csv_output=csv_output, - sync_buffer_output=sync_buffer_output, - can_bus_only_output=can_bus_only_output, - data_files={SensorId.S0: test_csv_file}, - sensor_id=SensorId.S0, - ) - finally: - if os.path.isfile(test_csv_file): - # clean up the test file this creates if it exists - os.remove(test_csv_file) - assert position[NodeId.head_l].positions_only()[0] == 14 + + position = await liquid_probe( + messenger=mock_messenger, + tool=target_node, + head_node=motor_node, + max_p_distance=70, + mount_speed=10, + plunger_speed=8, + threshold_pascals=threshold_pascals, + plunger_impulse_time=0.2, + num_baseline_reads=20, + sensor_id=SensorId.S0, + ) + assert position[motor_node].positions_only()[0] == 14 assert mock_sensor_threshold.call_args_list[0][0][0] == SensorThresholdInformation( sensor=sensor_info, - data=SensorDataType.build(14 * 65536, sensor_info.sensor_type), + data=SensorDataType.build(threshold_pascals * 65536, sensor_info.sensor_type), mode=SensorThresholdMode.absolute, ) diff --git a/labware-library/Makefile b/labware-library/Makefile index a074edd4092..0ea32b222ee 100644 --- a/labware-library/Makefile +++ b/labware-library/Makefile @@ -37,13 +37,25 @@ dev: serve: all node ../scripts/serve-static dist +.PHONY: clean-downloads +clean-downloads: + shx rm -rf cypress/downloads + # end to end tests .PHONY: test-e2e -test-e2e: +test-e2e: clean-downloads concurrently --no-color --kill-others --success first --names "labware-library-server,labware-library-tests" \ - "$(MAKE) dev CYPRESS=1 GTM_ID=''" \ + "$(MAKE) dev GTM_ID=''" \ "wait-on http://localhost:5179/ && echo \"Running cypress at $(date)\" && cypress run --browser chrome --headless --record false" +REMOTE_BASE_URL ?= https://labware.opentrons.com +.PHONY: test-e2e-remote +test-e2e-remote: clean-downloads + @echo "Running cypress tests against $(CYPRESS_BASE_URL)" + @echo "example: make test-e2e-remote REMOTE_BASE_URL='https://labware.opentrons.com'" + @echo + cypress run --browser chrome --headless --config "baseUrl=$(REMOTE_BASE_URL)" + # unit tests .PHONY: test test: diff --git a/labware-library/README.md b/labware-library/README.md index 06982723d7d..3466de2cacf 100644 --- a/labware-library/README.md +++ b/labware-library/README.md @@ -83,3 +83,7 @@ Certain environment variables, when set, will affect the artifact output. | OT_LL_MIXPANEL_DEV_ID | some string ID | Mixpanel token for dev environment. | | OT_LL_VERSION | semver string eg "1.2.3" | reported to analytics. Read from package.json by default. | | OT_LL_BUILD_DATE | result of `new Date().toUTCString()` | reported to analytics. Uses current date-time by default. | + +## Cypress + +`npx cypress open` will open the Cypress test runner. From there, you can run the tests in the `labware-library` directory. diff --git a/labware-library/cypress.config.js b/labware-library/cypress.config.js index e1871656f84..7df4db9fcc6 100644 --- a/labware-library/cypress.config.js +++ b/labware-library/cypress.config.js @@ -3,11 +3,6 @@ const { defineConfig } = require('cypress') module.exports = defineConfig({ video: false, e2e: { - // We've imported your old cypress plugins here. - // You may want to clean this up later by importing these. - setupNodeEvents(on, config) { - return require('./cypress/plugins/index.js')(on, config) - }, baseUrl: 'http://localhost:5179', }, }) diff --git a/labware-library/cypress/e2e/home.cy.js b/labware-library/cypress/e2e/home.cy.js index 79a39e8712e..20c8015e834 100644 --- a/labware-library/cypress/e2e/home.cy.js +++ b/labware-library/cypress/e2e/home.cy.js @@ -1,7 +1,8 @@ +import { navigateToUrl } from '../support/e2e' + describe('The Desktop Home Page', () => { beforeEach(() => { - cy.visit('/') - cy.viewport('macbook-15') + navigateToUrl('/') }) it('successfully loads', () => { diff --git a/labware-library/cypress/e2e/labware-creator/create.cy.js b/labware-library/cypress/e2e/labware-creator/create.cy.js index 299a3444a86..917b263cb78 100644 --- a/labware-library/cypress/e2e/labware-creator/create.cy.js +++ b/labware-library/cypress/e2e/labware-creator/create.cy.js @@ -2,10 +2,11 @@ // an element is in view before clicking or checking with // { force: true } +import { navigateToUrl } from '../../support/e2e' + context('The Labware Creator Landing Page', () => { beforeEach(() => { - cy.visit('/#/create') - cy.viewport('macbook-15') + navigateToUrl('/#/create') }) describe('The initial text', () => { diff --git a/labware-library/cypress/e2e/labware-creator/customTubeRack.cy.js b/labware-library/cypress/e2e/labware-creator/customTubeRack.cy.js index 319e7f4ea81..b7f9cbbcc30 100644 --- a/labware-library/cypress/e2e/labware-creator/customTubeRack.cy.js +++ b/labware-library/cypress/e2e/labware-creator/customTubeRack.cy.js @@ -1,13 +1,13 @@ -import 'cypress-file-upload' -import { expectDeepEqual } from '@opentrons/shared-data/js/cypressUtils' - -const expectedExportFixture = - '../fixtures/somerackbrand_24_tuberack_1500ul.json' +import { + navigateToUrl, + fileHelper, + wellBottomImageLocator, +} from '../../support/e2e' +const fileHolder = fileHelper('somerackbrand_24_tuberack_1500ul') context('Tubes and Rack', () => { before(() => { - cy.visit('/#/create') - cy.viewport('macbook-15') + navigateToUrl('/#/create') }) describe('Custom 6 x 4 tube rack', () => { @@ -109,24 +109,29 @@ context('Tubes and Rack', () => { cy.contains('Diameter is a required field').should('not.exist') // well bottom shape and depth + // check flat cy.get("input[name='wellBottomShape'][value='flat']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('exist') - cy.get("img[src*='_round.']").should('not.exist') - cy.get("img[src*='_v.']").should('not.exist') + cy.get(wellBottomImageLocator.flat).should('exist') + cy.get(wellBottomImageLocator.round).should('not.exist') + cy.get(wellBottomImageLocator.v).should('not.exist') + + // check u shaped cy.get("input[name='wellBottomShape'][value='u']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('not.exist') - cy.get("img[src*='_round.']").should('exist') - cy.get("img[src*='_v.']").should('not.exist') + cy.get(wellBottomImageLocator.flat).should('not.exist') + cy.get(wellBottomImageLocator.round).should('exist') + cy.get(wellBottomImageLocator.v).should('not.exist') + + // check v shaped cy.get("input[name='wellBottomShape'][value='v']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('not.exist') - cy.get("img[src*='_round.']").should('not.exist') - cy.get("img[src*='_v.']").should('exist') + cy.get(wellBottomImageLocator.flat).should('not.exist') + cy.get(wellBottomImageLocator.round).should('not.exist') + cy.get(wellBottomImageLocator.v).should('exist') cy.get("input[name='wellDepth']").focus().blur() cy.contains('Depth is a required field').should('exist') cy.get("input[name='wellDepth']").type('100').blur() @@ -159,28 +164,20 @@ context('Tubes and Rack', () => { cy.get( "input[placeholder='somerackbrand 24 Tube Rack with sometubebrand 1.5 mL']" ).should('exist') - cy.get("input[placeholder='somerackbrand_24_tuberack_1500ul']").should( + cy.get(`input[placeholder='${fileHolder.downloadFileStem}']`).should( 'exist' ) - - // now try again with all fields inputed - cy.fixture(expectedExportFixture).then(expectedExportLabwareDef => { - cy.get('button').contains('EXPORT FILE').click() - - cy.window() - .its('__lastSavedFileBlob__') - .should('be.a', 'blob') - .should(async blob => { - const labwareDefText = await blob.text() - const savedDef = JSON.parse(labwareDefText) - - expectDeepEqual(assert, savedDef, expectedExportLabwareDef) + cy.get('button').contains('EXPORT FILE').click() + + cy.fixture(fileHolder.expectedExportFixture).then( + expectedExportLabwareDef => { + cy.readFile(fileHolder.downloadPath).then(actualExportLabwareDef => { + expect(actualExportLabwareDef).to.deep.equal( + expectedExportLabwareDef + ) }) - - cy.window() - .its('__lastSavedFileName__') - .should('equal', `somerackbrand_24_tuberack_1500ul.json`) - }) + } + ) }) }) }) diff --git a/labware-library/cypress/e2e/labware-creator/fileImport.cy.js b/labware-library/cypress/e2e/labware-creator/fileImport.cy.js index e0fc480107f..616edca7d5b 100644 --- a/labware-library/cypress/e2e/labware-creator/fileImport.cy.js +++ b/labware-library/cypress/e2e/labware-creator/fileImport.cy.js @@ -1,11 +1,15 @@ -import { expectDeepEqual } from '@opentrons/shared-data/js/cypressUtils' +import { + navigateToUrl, + fileHelper, + wellBottomImageLocator, +} from '../../support/e2e' +const fileHolder = fileHelper('testpro_15_wellplate_5ul') const importedLabwareFile = 'TestLabwareDefinition.json' describe('File Import', () => { before(() => { - cy.visit('/#/create') - cy.viewport('macbook-15') + navigateToUrl('/#/create') }) it('tests the file import flow', () => { @@ -49,9 +53,9 @@ describe('File Import', () => { // verify well bottom and depth cy.get("input[name='wellBottomShape'][value='flat']").should('exist') - cy.get("img[src*='_flat.']").should('exist') - cy.get("img[src*='_round.']").should('not.exist') - cy.get("img[src*='_v.']").should('not.exist') + cy.get(wellBottomImageLocator.flat).should('exist') + cy.get(wellBottomImageLocator.round).should('not.exist') + cy.get(wellBottomImageLocator.v).should('not.exist') cy.get("input[name='wellDepth'][value='5']").should('exist') // verify grid spacing @@ -69,7 +73,9 @@ describe('File Import', () => { // File info cy.get("input[placeholder='TestPro 15 Well Plate 5 µL']").should('exist') - cy.get("input[placeholder='testpro_15_wellplate_5ul']").should('exist') + cy.get(`input[placeholder='${fileHolder.downloadFileStem}']`).should( + 'exist' + ) // All fields present cy.get('button[class*="_export_button_"]').click({ force: true }) @@ -77,20 +83,12 @@ describe('File Import', () => { 'Please resolve all invalid fields in order to export the labware definition' ).should('not.exist') - cy.fixture(importedLabwareFile).then(expected => { - cy.window() - .its('__lastSavedFileBlob__') - .should('be.a', 'blob') // wait until we get the blob - .should(async blob => { - const labwareDefText = await blob.text() - const savedDef = JSON.parse(labwareDefText) - - expectDeepEqual(assert, savedDef, expected) + cy.fixture(fileHolder.expectedExportFixture).then( + expectedExportLabwareDef => { + cy.readFile(fileHolder.downloadPath).then(actualExportLabwareDef => { + expect(actualExportLabwareDef).to.deep.equal(expectedExportLabwareDef) }) - }) - - cy.window() - .its('__lastSavedFileName__') - .should('equal', 'testpro_15_wellplate_5ul.json') + } + ) }) }) diff --git a/labware-library/cypress/e2e/labware-creator/reservoir.cy.js b/labware-library/cypress/e2e/labware-creator/reservoir.cy.js index 75197208859..32b18c88a40 100644 --- a/labware-library/cypress/e2e/labware-creator/reservoir.cy.js +++ b/labware-library/cypress/e2e/labware-creator/reservoir.cy.js @@ -1,11 +1,13 @@ -// Scrolling seems wonky, so I disabled checking to see if -// an element is in view before clicking or checking with -// { force: true } +import { + navigateToUrl, + fileHelper, + wellBottomImageLocator, +} from '../../support/e2e' +const fileHolder = fileHelper('testpro_10_reservoir_250ul') context('Reservoirs', () => { before(() => { - cy.visit('/#/create') - cy.viewport('macbook-15') + navigateToUrl('/#/create') }) describe('Reservoir', () => { @@ -143,21 +145,21 @@ context('Reservoirs', () => { cy.get("input[name='wellBottomShape'][value='flat']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('exist') - cy.get("img[src*='_round.']").should('not.exist') - cy.get("img[src*='_v.']").should('not.exist') + cy.get(wellBottomImageLocator.flat).should('exist') + cy.get(wellBottomImageLocator.round).should('not.exist') + cy.get(wellBottomImageLocator.v).should('not.exist') cy.get("input[name='wellBottomShape'][value='u']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('not.exist') - cy.get("img[src*='_round.']").should('exist') - cy.get("img[src*='_v.']").should('not.exist') + cy.get(wellBottomImageLocator.flat).should('not.exist') + cy.get(wellBottomImageLocator.round).should('exist') + cy.get(wellBottomImageLocator.v).should('not.exist') cy.get("input[name='wellBottomShape'][value='v']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('not.exist') - cy.get("img[src*='_round.']").should('not.exist') - cy.get("img[src*='_v.']").should('exist') + cy.get(wellBottomImageLocator.flat).should('not.exist') + cy.get(wellBottomImageLocator.round).should('not.exist') + cy.get(wellBottomImageLocator.v).should('exist') cy.get("input[name='wellDepth']").focus().blur() cy.contains('Depth is a required field').should('exist') cy.get("input[name='wellDepth']").type('70').blur() @@ -198,13 +200,24 @@ context('Reservoirs', () => { // File info cy.get("input[placeholder='TestPro 10 Reservoir 250 µL']").should('exist') - cy.get("input[placeholder='testpro_10_reservoir_250ul']").should('exist') + cy.get(`input[placeholder='${fileHolder.downloadFileStem}']`).should( + 'exist' + ) // All fields present cy.get('button[class*="_export_button_"]').click({ force: true }) cy.contains( 'Please resolve all invalid fields in order to export the labware definition' ).should('not.exist') + cy.fixture(fileHolder.expectedExportFixture).then( + expectedExportLabwareDef => { + cy.readFile(fileHolder.downloadPath).then(actualExportLabwareDef => { + expect(actualExportLabwareDef).to.deep.equal( + expectedExportLabwareDef + ) + }) + } + ) }) }) }) diff --git a/labware-library/cypress/e2e/labware-creator/tipRack.cy.js b/labware-library/cypress/e2e/labware-creator/tipRack.cy.js index e69e3dd7285..4d27a47effc 100644 --- a/labware-library/cypress/e2e/labware-creator/tipRack.cy.js +++ b/labware-library/cypress/e2e/labware-creator/tipRack.cy.js @@ -1,12 +1,9 @@ -import 'cypress-file-upload' -import { expectDeepEqual } from '@opentrons/shared-data/js/cypressUtils' - -const expectedExportFixture = '../fixtures/generic_1_tiprack_20ul.json' +import { navigateToUrl, fileHelper } from '../../support/e2e' +const fileHolder = fileHelper('generic_1_tiprack_20ul') describe('Create a Tip Rack', () => { before(() => { - cy.visit('/#/create') - cy.viewport('macbook-15') + navigateToUrl('/#/create') }) it('Should create a tip rack', () => { // Tip Rack Selection from drop down @@ -242,26 +239,19 @@ describe('Create a Tip Rack', () => { cy.get('input[name="displayName"]') .clear() .type('Brand Chalu 1 Tip Rack 20ul') - cy.get('input[name="loadName"]').clear().type('generic_1_tiprack_20ul') + cy.get('input[name="loadName"]').clear().type(fileHolder.downloadFileStem) // Verify the exported file to the fixture cy.get('button').contains('EXPORT FILE').click() - cy.fixture(expectedExportFixture).then(expectedExportLabwareDef => { - cy.window() - .its('__lastSavedFileBlob__') - .should('be.a', 'blob') - .should(async blob => { - const labwareDefText = await blob.text() - const savedDef = JSON.parse(labwareDefText) - - expectDeepEqual(assert, savedDef, expectedExportLabwareDef) + cy.fixture(fileHolder.expectedExportFixture).then( + expectedExportLabwareDef => { + cy.readFile(fileHolder.downloadPath).then(actualExportLabwareDef => { + expect(actualExportLabwareDef).to.deep.equal(expectedExportLabwareDef) }) - }) + } + ) - cy.window() - .its('__lastSavedFileName__') - .should('equal', `generic_1_tiprack_20ul.json`) // 'verify the too big, too small error cy.get('input[name="gridOffsetY"]').clear().type('24') cy.get('#CheckYourWork span') diff --git a/labware-library/cypress/e2e/labware-creator/tubesBlock.cy.js b/labware-library/cypress/e2e/labware-creator/tubesBlock.cy.js index 66ea8d0dedc..4240342390a 100644 --- a/labware-library/cypress/e2e/labware-creator/tubesBlock.cy.js +++ b/labware-library/cypress/e2e/labware-creator/tubesBlock.cy.js @@ -1,12 +1,13 @@ -// Scrolling seems wonky, so I disabled checking to see if -// an element is in view before clicking or checking with -// { force: true } +import { + navigateToUrl, + fileHelper, + wellBottomImageLocator, +} from '../../support/e2e' +const fileHolder = fileHelper('testpro_24_aluminumblock_10ul') context('Tubes and Block', () => { beforeEach(() => { - cy.visit('/#/create') - cy.viewport('macbook-15') - + navigateToUrl('/#/create') cy.get('label') .contains('What type of labware are you creating?') .children() @@ -106,21 +107,21 @@ context('Tubes and Block', () => { cy.get("input[name='wellBottomShape'][value='flat']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('exist') - cy.get("img[src*='_round.']").should('not.exist') - cy.get("img[src*='_v.']").should('not.exist') + cy.get(wellBottomImageLocator.flat).should('exist') + cy.get(wellBottomImageLocator.round).should('not.exist') + cy.get(wellBottomImageLocator.v).should('not.exist') cy.get("input[name='wellBottomShape'][value='u']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('not.exist') - cy.get("img[src*='_round.']").should('exist') - cy.get("img[src*='_v.']").should('not.exist') + cy.get(wellBottomImageLocator.flat).should('not.exist') + cy.get(wellBottomImageLocator.round).should('exist') + cy.get(wellBottomImageLocator.v).should('not.exist') cy.get("input[name='wellBottomShape'][value='v']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('not.exist') - cy.get("img[src*='_round.']").should('not.exist') - cy.get("img[src*='_v.']").should('exist') + cy.get(wellBottomImageLocator.flat).should('not.exist') + cy.get(wellBottomImageLocator.round).should('not.exist') + cy.get(wellBottomImageLocator.v).should('exist') cy.get("input[name='wellDepth']").focus().blur() cy.contains('Depth is a required field').should('exist') cy.get("input[name='wellDepth']").type('10').blur() @@ -232,21 +233,21 @@ context('Tubes and Block', () => { cy.get("input[name='wellBottomShape'][value='flat']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('exist') - cy.get("img[src*='_round.']").should('not.exist') - cy.get("img[src*='_v.']").should('not.exist') + cy.get(wellBottomImageLocator.flat).should('exist') + cy.get(wellBottomImageLocator.round).should('not.exist') + cy.get(wellBottomImageLocator.v).should('not.exist') cy.get("input[name='wellBottomShape'][value='u']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('not.exist') - cy.get("img[src*='_round.']").should('exist') - cy.get("img[src*='_v.']").should('not.exist') + cy.get(wellBottomImageLocator.flat).should('not.exist') + cy.get(wellBottomImageLocator.round).should('exist') + cy.get(wellBottomImageLocator.v).should('not.exist') cy.get("input[name='wellBottomShape'][value='v']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('not.exist') - cy.get("img[src*='_round.']").should('not.exist') - cy.get("img[src*='_v.']").should('exist') + cy.get(wellBottomImageLocator.flat).should('not.exist') + cy.get(wellBottomImageLocator.round).should('not.exist') + cy.get(wellBottomImageLocator.v).should('exist') cy.get("input[name='wellDepth']").focus().blur() cy.contains('Depth is a required field').should('exist') cy.get("input[name='wellDepth']").type('10').blur() @@ -383,21 +384,21 @@ context('Tubes and Block', () => { cy.get("input[name='wellBottomShape'][value='flat']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('exist') - cy.get("img[src*='_round.']").should('not.exist') - cy.get("img[src*='_v.']").should('not.exist') + cy.get(wellBottomImageLocator.flat).should('exist') + cy.get(wellBottomImageLocator.round).should('not.exist') + cy.get(wellBottomImageLocator.v).should('not.exist') cy.get("input[name='wellBottomShape'][value='u']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('not.exist') - cy.get("img[src*='_round.']").should('exist') - cy.get("img[src*='_v.']").should('not.exist') + cy.get(wellBottomImageLocator.flat).should('not.exist') + cy.get(wellBottomImageLocator.round).should('exist') + cy.get(wellBottomImageLocator.v).should('not.exist') cy.get("input[name='wellBottomShape'][value='v']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('not.exist') - cy.get("img[src*='_round.']").should('not.exist') - cy.get("img[src*='_v.']").should('exist') + cy.get(wellBottomImageLocator.flat).should('not.exist') + cy.get(wellBottomImageLocator.round).should('not.exist') + cy.get(wellBottomImageLocator.v).should('exist') cy.get("input[name='wellDepth']").focus().blur() cy.contains('Depth is a required field').should('exist') cy.get("input[name='wellDepth']").type('10').blur() @@ -445,8 +446,7 @@ context('Tubes and Block', () => { }) it('tests the whole form and file export', () => { - cy.visit('/#/create') - cy.viewport('macbook-15') + navigateToUrl('/#/create') cy.get('label') .contains('What type of labware are you creating?') .children() @@ -533,21 +533,21 @@ context('Tubes and Block', () => { cy.get("input[name='wellBottomShape'][value='flat']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('exist') - cy.get("img[src*='_round.']").should('not.exist') - cy.get("img[src*='_v.']").should('not.exist') + cy.get(wellBottomImageLocator.flat).should('exist') + cy.get(wellBottomImageLocator.round).should('not.exist') + cy.get(wellBottomImageLocator.v).should('not.exist') cy.get("input[name='wellBottomShape'][value='u']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('not.exist') - cy.get("img[src*='_round.']").should('exist') - cy.get("img[src*='_v.']").should('not.exist') + cy.get(wellBottomImageLocator.flat).should('not.exist') + cy.get(wellBottomImageLocator.round).should('exist') + cy.get(wellBottomImageLocator.v).should('not.exist') cy.get("input[name='wellBottomShape'][value='v']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('not.exist') - cy.get("img[src*='_round.']").should('not.exist') - cy.get("img[src*='_v.']").should('exist') + cy.get(wellBottomImageLocator.flat).should('not.exist') + cy.get(wellBottomImageLocator.round).should('not.exist') + cy.get(wellBottomImageLocator.v).should('exist') cy.get("input[name='wellDepth']").focus().blur() cy.contains('Depth is a required field').should('exist') cy.get("input[name='wellDepth']").type('10').blur() @@ -575,7 +575,7 @@ context('Tubes and Block', () => { cy.get("input[placeholder='TestPro 24 Aluminum Block 10 µL']").should( 'exist' ) - cy.get("input[placeholder='testpro_24_aluminumblock_10ul']").should( + cy.get(`input[placeholder='${fileHolder.downloadFileStem}']`).should( 'exist' ) @@ -584,6 +584,18 @@ context('Tubes and Block', () => { cy.contains( 'Please resolve all invalid fields in order to export the labware definition' ).should('not.exist') + + cy.fixture(fileHolder.expectedExportFixture).then( + expectedExportLabwareDef => { + cy.readFile(fileHolder.downloadPath).then( + actualExportLabwareDef => { + expect(actualExportLabwareDef).to.deep.equal( + expectedExportLabwareDef + ) + } + ) + } + ) }) }) }) diff --git a/labware-library/cypress/e2e/labware-creator/tubesRack.cy.js b/labware-library/cypress/e2e/labware-creator/tubesRack.cy.js index 4214f215dc0..0409221b6a4 100644 --- a/labware-library/cypress/e2e/labware-creator/tubesRack.cy.js +++ b/labware-library/cypress/e2e/labware-creator/tubesRack.cy.js @@ -1,12 +1,9 @@ -// Scrolling seems wonky, so I disabled checking to see if -// an element is in view before clicking or checking with -// { force: true } +import { navigateToUrl, wellBottomImageLocator } from '../../support/e2e' context('Tubes and Rack', () => { describe('Six tubes', () => { before(() => { - cy.visit('/#/create') - cy.viewport('macbook-15') + navigateToUrl('/#/create') cy.get('label') .contains('What type of labware are you creating?') .children() @@ -95,21 +92,21 @@ context('Tubes and Rack', () => { cy.get("input[name='wellBottomShape'][value='flat']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('exist') - cy.get("img[src*='_round.']").should('not.exist') - cy.get("img[src*='_v.']").should('not.exist') + cy.get(wellBottomImageLocator.flat).should('exist') + cy.get(wellBottomImageLocator.round).should('not.exist') + cy.get(wellBottomImageLocator.v).should('not.exist') cy.get("input[name='wellBottomShape'][value='u']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('not.exist') - cy.get("img[src*='_round.']").should('exist') - cy.get("img[src*='_v.']").should('not.exist') + cy.get(wellBottomImageLocator.flat).should('not.exist') + cy.get(wellBottomImageLocator.round).should('exist') + cy.get(wellBottomImageLocator.v).should('not.exist') cy.get("input[name='wellBottomShape'][value='v']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('not.exist') - cy.get("img[src*='_round.']").should('not.exist') - cy.get("img[src*='_v.']").should('exist') + cy.get(wellBottomImageLocator.flat).should('not.exist') + cy.get(wellBottomImageLocator.round).should('not.exist') + cy.get(wellBottomImageLocator.v).should('exist') cy.get("input[name='wellDepth']").focus().blur() cy.contains('Depth is a required field').should('exist') cy.get("input[name='wellDepth']").type('10').blur() @@ -137,9 +134,7 @@ context('Tubes and Rack', () => { describe('Fifteen tubes', () => { before(() => { - cy.visit('/#/create') - cy.viewport('macbook-15') - + navigateToUrl('#/create') cy.get('label') .contains('What type of labware are you creating?') .children() @@ -226,21 +221,21 @@ context('Tubes and Rack', () => { cy.get("input[name='wellBottomShape'][value='flat']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('exist') - cy.get("img[src*='_round.']").should('not.exist') - cy.get("img[src*='_v.']").should('not.exist') + cy.get(wellBottomImageLocator.flat).should('exist') + cy.get(wellBottomImageLocator.round).should('not.exist') + cy.get(wellBottomImageLocator.v).should('not.exist') cy.get("input[name='wellBottomShape'][value='u']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('not.exist') - cy.get("img[src*='_round.']").should('exist') - cy.get("img[src*='_v.']").should('not.exist') + cy.get(wellBottomImageLocator.flat).should('not.exist') + cy.get(wellBottomImageLocator.round).should('exist') + cy.get(wellBottomImageLocator.v).should('not.exist') cy.get("input[name='wellBottomShape'][value='v']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('not.exist') - cy.get("img[src*='_round.']").should('not.exist') - cy.get("img[src*='_v.']").should('exist') + cy.get(wellBottomImageLocator.flat).should('not.exist') + cy.get(wellBottomImageLocator.round).should('not.exist') + cy.get(wellBottomImageLocator.v).should('exist') cy.get("input[name='wellDepth']").focus().blur() cy.contains('Depth is a required field').should('exist') cy.get("input[name='wellDepth']").type('10').blur() @@ -268,9 +263,7 @@ context('Tubes and Rack', () => { describe('Twentyfour tubes', () => { before(() => { - cy.visit('/#/create') - cy.viewport('macbook-15') - + navigateToUrl('/#/create') cy.get('label') .contains('What type of labware are you creating?') .children() @@ -356,21 +349,21 @@ context('Tubes and Rack', () => { cy.get("input[name='wellBottomShape'][value='flat']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('exist') - cy.get("img[src*='_round.']").should('not.exist') - cy.get("img[src*='_v.']").should('not.exist') + cy.get(wellBottomImageLocator.flat).should('exist') + cy.get(wellBottomImageLocator.round).should('not.exist') + cy.get(wellBottomImageLocator.v).should('not.exist') cy.get("input[name='wellBottomShape'][value='u']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('not.exist') - cy.get("img[src*='_round.']").should('exist') - cy.get("img[src*='_v.']").should('not.exist') + cy.get(wellBottomImageLocator.flat).should('not.exist') + cy.get(wellBottomImageLocator.round).should('exist') + cy.get(wellBottomImageLocator.v).should('not.exist') cy.get("input[name='wellBottomShape'][value='v']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('not.exist') - cy.get("img[src*='_round.']").should('not.exist') - cy.get("img[src*='_v.']").should('exist') + cy.get(wellBottomImageLocator.flat).should('not.exist') + cy.get(wellBottomImageLocator.round).should('not.exist') + cy.get(wellBottomImageLocator.v).should('exist') cy.get("input[name='wellDepth']").focus().blur() cy.contains('Depth is a required field').should('exist') cy.get("input[name='wellDepth']").type('10').blur() diff --git a/labware-library/cypress/e2e/labware-creator/wellPlate.cy.js b/labware-library/cypress/e2e/labware-creator/wellPlate.cy.js index 5b27cfcfd72..df12cf153a5 100644 --- a/labware-library/cypress/e2e/labware-creator/wellPlate.cy.js +++ b/labware-library/cypress/e2e/labware-creator/wellPlate.cy.js @@ -2,14 +2,16 @@ // that cannot be imported. The creator probably shouldn't allow // a user to do this. -// Scrolling seems wonky, so I disabled checking to see if -// an element is in view before clicking or checking with -// { force: true } +import { + navigateToUrl, + fileHelper, + wellBottomImageLocator, +} from '../../support/e2e' +const fileHolder = fileHelper('testpro_80_wellplate_100ul') context('Well Plates', () => { before(() => { - cy.visit('/#/create') - cy.viewport('macbook-15') + navigateToUrl('/#/create') }) describe('Create a well plate', () => { @@ -145,21 +147,21 @@ context('Well Plates', () => { cy.get("input[name='wellBottomShape'][value='flat']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('exist') - cy.get("img[src*='_round.']").should('not.exist') - cy.get("img[src*='_v.']").should('not.exist') + cy.get(wellBottomImageLocator.flat).should('exist') + cy.get(wellBottomImageLocator.round).should('not.exist') + cy.get(wellBottomImageLocator.v).should('not.exist') cy.get("input[name='wellBottomShape'][value='u']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('not.exist') - cy.get("img[src*='_round.']").should('exist') - cy.get("img[src*='_v.']").should('not.exist') + cy.get(wellBottomImageLocator.flat).should('not.exist') + cy.get(wellBottomImageLocator.round).should('exist') + cy.get(wellBottomImageLocator.v).should('not.exist') cy.get("input[name='wellBottomShape'][value='v']").check({ force: true, }) - cy.get("img[src*='_flat.']").should('not.exist') - cy.get("img[src*='_round.']").should('not.exist') - cy.get("img[src*='_v.']").should('exist') + cy.get(wellBottomImageLocator.flat).should('not.exist') + cy.get(wellBottomImageLocator.round).should('not.exist') + cy.get(wellBottomImageLocator.v).should('exist') cy.get("input[name='wellDepth']").focus().blur() cy.contains('Depth is a required field').should('exist') cy.get("input[name='wellDepth']").type('10').blur() @@ -208,7 +210,9 @@ context('Well Plates', () => { cy.get("input[placeholder='TestPro 80 Well Plate 100 µL']").should( 'exist' ) - cy.get("input[placeholder='testpro_80_wellplate_100ul']").should('exist') + cy.get(`input[placeholder='${fileHolder.downloadFileStem}']`).should( + 'exist' + ) // All fields present cy.get('button[class*="_export_button_"]').click({ force: true }) @@ -216,7 +220,15 @@ context('Well Plates', () => { 'Please resolve all invalid fields in order to export the labware definition' ).should('not.exist') - // TODO IMMEDIATELY match against fixture ??? Is this not happening? + cy.fixture(fileHolder.expectedExportFixture).then( + expectedExportLabwareDef => { + cy.readFile(fileHolder.downloadPath).then(actualExportLabwareDef => { + expect(actualExportLabwareDef).to.deep.equal( + expectedExportLabwareDef + ) + }) + } + ) }) }) }) diff --git a/labware-library/cypress/e2e/navigation.cy.js b/labware-library/cypress/e2e/navigation.cy.js index 83ce2dd7369..0b4c3c14a40 100644 --- a/labware-library/cypress/e2e/navigation.cy.js +++ b/labware-library/cypress/e2e/navigation.cy.js @@ -1,7 +1,8 @@ +import { navigateToUrl } from '../support/e2e' + describe('Desktop Navigation', () => { beforeEach(() => { - cy.visit('/') - cy.viewport('macbook-15') + navigateToUrl('/') }) it('contains the subdomain nav bar', () => { diff --git a/labware-library/cypress/fixtures/testpro_10_reservoir_250ul.json b/labware-library/cypress/fixtures/testpro_10_reservoir_250ul.json new file mode 100644 index 00000000000..5941e1b3e5e --- /dev/null +++ b/labware-library/cypress/fixtures/testpro_10_reservoir_250ul.json @@ -0,0 +1,154 @@ +{ + "ordering": [ + ["A1"], + ["A2"], + ["A3"], + ["A4"], + ["A5"], + ["A6"], + ["A7"], + ["A8"], + ["A9"], + ["A10"] + ], + "brand": { + "brand": "TestPro", + "brandId": ["001"] + }, + "metadata": { + "displayName": "TestPro 10 Reservoir 250 µL", + "displayCategory": "reservoir", + "displayVolumeUnits": "µL", + "tags": [] + }, + "dimensions": { + "xDimension": 127, + "yDimension": 85, + "zDimension": 75 + }, + "wells": { + "A1": { + "depth": 70, + "totalLiquidVolume": 250, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 60, + "x": 10, + "y": 40, + "z": 5 + }, + "A2": { + "depth": 70, + "totalLiquidVolume": 250, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 60, + "x": 22, + "y": 40, + "z": 5 + }, + "A3": { + "depth": 70, + "totalLiquidVolume": 250, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 60, + "x": 34, + "y": 40, + "z": 5 + }, + "A4": { + "depth": 70, + "totalLiquidVolume": 250, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 60, + "x": 46, + "y": 40, + "z": 5 + }, + "A5": { + "depth": 70, + "totalLiquidVolume": 250, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 60, + "x": 58, + "y": 40, + "z": 5 + }, + "A6": { + "depth": 70, + "totalLiquidVolume": 250, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 60, + "x": 70, + "y": 40, + "z": 5 + }, + "A7": { + "depth": 70, + "totalLiquidVolume": 250, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 60, + "x": 82, + "y": 40, + "z": 5 + }, + "A8": { + "depth": 70, + "totalLiquidVolume": 250, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 60, + "x": 94, + "y": 40, + "z": 5 + }, + "A9": { + "depth": 70, + "totalLiquidVolume": 250, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 60, + "x": 106, + "y": 40, + "z": 5 + }, + "A10": { + "depth": 70, + "totalLiquidVolume": 250, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 60, + "x": 118, + "y": 40, + "z": 5 + } + }, + "groups": [ + { + "metadata": { + "wellBottomShape": "v" + }, + "wells": ["A1", "A2", "A3", "A4", "A5", "A6", "A7", "A8", "A9", "A10"] + } + ], + "parameters": { + "format": "irregular", + "quirks": [], + "isTiprack": false, + "isMagneticModuleCompatible": false, + "loadName": "testpro_10_reservoir_250ul" + }, + "namespace": "custom_beta", + "version": 1, + "schemaVersion": 2, + "cornerOffsetFromSlot": { + "x": 0, + "y": 0, + "z": 0 + } +} diff --git a/labware-library/cypress/fixtures/testpro_15_wellplate_5ul.json b/labware-library/cypress/fixtures/testpro_15_wellplate_5ul.json new file mode 100644 index 00000000000..6eac7bd5fc6 --- /dev/null +++ b/labware-library/cypress/fixtures/testpro_15_wellplate_5ul.json @@ -0,0 +1,200 @@ +{ + "ordering": [ + ["A1", "B1", "C1"], + ["A2", "B2", "C2"], + ["A3", "B3", "C3"], + ["A4", "B4", "C4"], + ["A5", "B5", "C5"] + ], + "brand": { + "brand": "TestPro", + "brandId": ["001"] + }, + "metadata": { + "displayName": "TestPro 15 Well Plate 5 µL", + "displayCategory": "wellPlate", + "displayVolumeUnits": "µL", + "tags": [] + }, + "dimensions": { + "xDimension": 127, + "yDimension": 85, + "zDimension": 5 + }, + "wells": { + "A1": { + "depth": 5, + "totalLiquidVolume": 5, + "shape": "circular", + "diameter": 5, + "x": 10, + "y": 75, + "z": 0 + }, + "B1": { + "depth": 5, + "totalLiquidVolume": 5, + "shape": "circular", + "diameter": 5, + "x": 10, + "y": 50, + "z": 0 + }, + "C1": { + "depth": 5, + "totalLiquidVolume": 5, + "shape": "circular", + "diameter": 5, + "x": 10, + "y": 25, + "z": 0 + }, + "A2": { + "depth": 5, + "totalLiquidVolume": 5, + "shape": "circular", + "diameter": 5, + "x": 35, + "y": 75, + "z": 0 + }, + "B2": { + "depth": 5, + "totalLiquidVolume": 5, + "shape": "circular", + "diameter": 5, + "x": 35, + "y": 50, + "z": 0 + }, + "C2": { + "depth": 5, + "totalLiquidVolume": 5, + "shape": "circular", + "diameter": 5, + "x": 35, + "y": 25, + "z": 0 + }, + "A3": { + "depth": 5, + "totalLiquidVolume": 5, + "shape": "circular", + "diameter": 5, + "x": 60, + "y": 75, + "z": 0 + }, + "B3": { + "depth": 5, + "totalLiquidVolume": 5, + "shape": "circular", + "diameter": 5, + "x": 60, + "y": 50, + "z": 0 + }, + "C3": { + "depth": 5, + "totalLiquidVolume": 5, + "shape": "circular", + "diameter": 5, + "x": 60, + "y": 25, + "z": 0 + }, + "A4": { + "depth": 5, + "totalLiquidVolume": 5, + "shape": "circular", + "diameter": 5, + "x": 85, + "y": 75, + "z": 0 + }, + "B4": { + "depth": 5, + "totalLiquidVolume": 5, + "shape": "circular", + "diameter": 5, + "x": 85, + "y": 50, + "z": 0 + }, + "C4": { + "depth": 5, + "totalLiquidVolume": 5, + "shape": "circular", + "diameter": 5, + "x": 85, + "y": 25, + "z": 0 + }, + "A5": { + "depth": 5, + "totalLiquidVolume": 5, + "shape": "circular", + "diameter": 5, + "x": 110, + "y": 75, + "z": 0 + }, + "B5": { + "depth": 5, + "totalLiquidVolume": 5, + "shape": "circular", + "diameter": 5, + "x": 110, + "y": 50, + "z": 0 + }, + "C5": { + "depth": 5, + "totalLiquidVolume": 5, + "shape": "circular", + "diameter": 5, + "x": 110, + "y": 25, + "z": 0 + } + }, + "groups": [ + { + "metadata": { + "wellBottomShape": "flat" + }, + "wells": [ + "A1", + "B1", + "C1", + "A2", + "B2", + "C2", + "A3", + "B3", + "C3", + "A4", + "B4", + "C4", + "A5", + "B5", + "C5" + ] + } + ], + "parameters": { + "format": "irregular", + "quirks": [], + "isTiprack": false, + "isMagneticModuleCompatible": false, + "loadName": "testpro_15_wellplate_5ul" + }, + "namespace": "custom_beta", + "version": 1, + "schemaVersion": 2, + "cornerOffsetFromSlot": { + "x": 0, + "y": 0, + "z": 0 + } +} diff --git a/labware-library/cypress/fixtures/testpro_24_aluminumblock_10ul.json b/labware-library/cypress/fixtures/testpro_24_aluminumblock_10ul.json new file mode 100644 index 00000000000..d653e918f90 --- /dev/null +++ b/labware-library/cypress/fixtures/testpro_24_aluminumblock_10ul.json @@ -0,0 +1,316 @@ +{ + "ordering": [ + ["A1", "B1", "C1", "D1"], + ["A2", "B2", "C2", "D2"], + ["A3", "B3", "C3", "D3"], + ["A4", "B4", "C4", "D4"], + ["A5", "B5", "C5", "D5"], + ["A6", "B6", "C6", "D6"] + ], + "brand": { + "brand": "TestPro", + "brandId": ["001"] + }, + "metadata": { + "displayName": "TestPro 24 Aluminum Block 10 µL", + "displayCategory": "aluminumBlock", + "displayVolumeUnits": "µL", + "tags": [] + }, + "dimensions": { + "xDimension": 127.75, + "yDimension": 85.5, + "zDimension": 75 + }, + "wells": { + "A1": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 20.75, + "y": 68.63, + "z": 65 + }, + "B1": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 20.75, + "y": 51.38, + "z": 65 + }, + "C1": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 20.75, + "y": 34.13, + "z": 65 + }, + "D1": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 20.75, + "y": 16.88, + "z": 65 + }, + "A2": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 38, + "y": 68.63, + "z": 65 + }, + "B2": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 38, + "y": 51.38, + "z": 65 + }, + "C2": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 38, + "y": 34.13, + "z": 65 + }, + "D2": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 38, + "y": 16.88, + "z": 65 + }, + "A3": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 55.25, + "y": 68.63, + "z": 65 + }, + "B3": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 55.25, + "y": 51.38, + "z": 65 + }, + "C3": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 55.25, + "y": 34.13, + "z": 65 + }, + "D3": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 55.25, + "y": 16.88, + "z": 65 + }, + "A4": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 72.5, + "y": 68.63, + "z": 65 + }, + "B4": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 72.5, + "y": 51.38, + "z": 65 + }, + "C4": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 72.5, + "y": 34.13, + "z": 65 + }, + "D4": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 72.5, + "y": 16.88, + "z": 65 + }, + "A5": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 89.75, + "y": 68.63, + "z": 65 + }, + "B5": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 89.75, + "y": 51.38, + "z": 65 + }, + "C5": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 89.75, + "y": 34.13, + "z": 65 + }, + "D5": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 89.75, + "y": 16.88, + "z": 65 + }, + "A6": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 107, + "y": 68.63, + "z": 65 + }, + "B6": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 107, + "y": 51.38, + "z": 65 + }, + "C6": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 107, + "y": 34.13, + "z": 65 + }, + "D6": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 107, + "y": 16.88, + "z": 65 + } + }, + "groups": [ + { + "metadata": { + "wellBottomShape": "v", + "displayCategory": "tubeRack" + }, + "wells": [ + "A1", + "B1", + "C1", + "D1", + "A2", + "B2", + "C2", + "D2", + "A3", + "B3", + "C3", + "D3", + "A4", + "B4", + "C4", + "D4", + "A5", + "B5", + "C5", + "D5", + "A6", + "B6", + "C6", + "D6" + ] + } + ], + "parameters": { + "format": "irregular", + "quirks": [], + "isTiprack": false, + "isMagneticModuleCompatible": false, + "loadName": "testpro_24_aluminumblock_10ul" + }, + "namespace": "custom_beta", + "version": 1, + "schemaVersion": 2, + "cornerOffsetFromSlot": { + "x": 0, + "y": 0, + "z": 0 + } +} diff --git a/labware-library/cypress/fixtures/testpro_80_wellplate_100ul.json b/labware-library/cypress/fixtures/testpro_80_wellplate_100ul.json new file mode 100644 index 00000000000..f51b575836a --- /dev/null +++ b/labware-library/cypress/fixtures/testpro_80_wellplate_100ul.json @@ -0,0 +1,935 @@ +{ + "ordering": [ + ["A1", "B1", "C1", "D1", "E1", "F1", "G1", "H1"], + ["A2", "B2", "C2", "D2", "E2", "F2", "G2", "H2"], + ["A3", "B3", "C3", "D3", "E3", "F3", "G3", "H3"], + ["A4", "B4", "C4", "D4", "E4", "F4", "G4", "H4"], + ["A5", "B5", "C5", "D5", "E5", "F5", "G5", "H5"], + ["A6", "B6", "C6", "D6", "E6", "F6", "G6", "H6"], + ["A7", "B7", "C7", "D7", "E7", "F7", "G7", "H7"], + ["A8", "B8", "C8", "D8", "E8", "F8", "G8", "H8"], + ["A9", "B9", "C9", "D9", "E9", "F9", "G9", "H9"], + ["A10", "B10", "C10", "D10", "E10", "F10", "G10", "H10"] + ], + "brand": { + "brand": "TestPro", + "brandId": ["001"] + }, + "metadata": { + "displayName": "TestPro 80 Well Plate 100 µL", + "displayCategory": "wellPlate", + "displayVolumeUnits": "µL", + "tags": [] + }, + "dimensions": { + "xDimension": 127, + "yDimension": 85, + "zDimension": 75 + }, + "wells": { + "A1": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 10, + "y": 77, + "z": 65 + }, + "B1": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 10, + "y": 67, + "z": 65 + }, + "C1": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 10, + "y": 57, + "z": 65 + }, + "D1": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 10, + "y": 47, + "z": 65 + }, + "E1": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 10, + "y": 37, + "z": 65 + }, + "F1": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 10, + "y": 27, + "z": 65 + }, + "G1": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 10, + "y": 17, + "z": 65 + }, + "H1": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 10, + "y": 7, + "z": 65 + }, + "A2": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 22, + "y": 77, + "z": 65 + }, + "B2": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 22, + "y": 67, + "z": 65 + }, + "C2": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 22, + "y": 57, + "z": 65 + }, + "D2": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 22, + "y": 47, + "z": 65 + }, + "E2": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 22, + "y": 37, + "z": 65 + }, + "F2": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 22, + "y": 27, + "z": 65 + }, + "G2": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 22, + "y": 17, + "z": 65 + }, + "H2": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 22, + "y": 7, + "z": 65 + }, + "A3": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 34, + "y": 77, + "z": 65 + }, + "B3": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 34, + "y": 67, + "z": 65 + }, + "C3": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 34, + "y": 57, + "z": 65 + }, + "D3": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 34, + "y": 47, + "z": 65 + }, + "E3": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 34, + "y": 37, + "z": 65 + }, + "F3": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 34, + "y": 27, + "z": 65 + }, + "G3": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 34, + "y": 17, + "z": 65 + }, + "H3": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 34, + "y": 7, + "z": 65 + }, + "A4": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 46, + "y": 77, + "z": 65 + }, + "B4": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 46, + "y": 67, + "z": 65 + }, + "C4": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 46, + "y": 57, + "z": 65 + }, + "D4": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 46, + "y": 47, + "z": 65 + }, + "E4": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 46, + "y": 37, + "z": 65 + }, + "F4": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 46, + "y": 27, + "z": 65 + }, + "G4": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 46, + "y": 17, + "z": 65 + }, + "H4": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 46, + "y": 7, + "z": 65 + }, + "A5": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 58, + "y": 77, + "z": 65 + }, + "B5": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 58, + "y": 67, + "z": 65 + }, + "C5": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 58, + "y": 57, + "z": 65 + }, + "D5": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 58, + "y": 47, + "z": 65 + }, + "E5": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 58, + "y": 37, + "z": 65 + }, + "F5": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 58, + "y": 27, + "z": 65 + }, + "G5": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 58, + "y": 17, + "z": 65 + }, + "H5": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 58, + "y": 7, + "z": 65 + }, + "A6": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 70, + "y": 77, + "z": 65 + }, + "B6": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 70, + "y": 67, + "z": 65 + }, + "C6": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 70, + "y": 57, + "z": 65 + }, + "D6": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 70, + "y": 47, + "z": 65 + }, + "E6": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 70, + "y": 37, + "z": 65 + }, + "F6": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 70, + "y": 27, + "z": 65 + }, + "G6": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 70, + "y": 17, + "z": 65 + }, + "H6": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 70, + "y": 7, + "z": 65 + }, + "A7": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 82, + "y": 77, + "z": 65 + }, + "B7": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 82, + "y": 67, + "z": 65 + }, + "C7": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 82, + "y": 57, + "z": 65 + }, + "D7": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 82, + "y": 47, + "z": 65 + }, + "E7": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 82, + "y": 37, + "z": 65 + }, + "F7": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 82, + "y": 27, + "z": 65 + }, + "G7": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 82, + "y": 17, + "z": 65 + }, + "H7": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 82, + "y": 7, + "z": 65 + }, + "A8": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 94, + "y": 77, + "z": 65 + }, + "B8": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 94, + "y": 67, + "z": 65 + }, + "C8": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 94, + "y": 57, + "z": 65 + }, + "D8": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 94, + "y": 47, + "z": 65 + }, + "E8": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 94, + "y": 37, + "z": 65 + }, + "F8": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 94, + "y": 27, + "z": 65 + }, + "G8": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 94, + "y": 17, + "z": 65 + }, + "H8": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 94, + "y": 7, + "z": 65 + }, + "A9": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 106, + "y": 77, + "z": 65 + }, + "B9": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 106, + "y": 67, + "z": 65 + }, + "C9": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 106, + "y": 57, + "z": 65 + }, + "D9": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 106, + "y": 47, + "z": 65 + }, + "E9": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 106, + "y": 37, + "z": 65 + }, + "F9": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 106, + "y": 27, + "z": 65 + }, + "G9": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 106, + "y": 17, + "z": 65 + }, + "H9": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 106, + "y": 7, + "z": 65 + }, + "A10": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 118, + "y": 77, + "z": 65 + }, + "B10": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 118, + "y": 67, + "z": 65 + }, + "C10": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 118, + "y": 57, + "z": 65 + }, + "D10": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 118, + "y": 47, + "z": 65 + }, + "E10": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 118, + "y": 37, + "z": 65 + }, + "F10": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 118, + "y": 27, + "z": 65 + }, + "G10": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 118, + "y": 17, + "z": 65 + }, + "H10": { + "depth": 10, + "totalLiquidVolume": 100, + "shape": "rectangular", + "xDimension": 8, + "yDimension": 8, + "x": 118, + "y": 7, + "z": 65 + } + }, + "groups": [ + { + "metadata": { + "wellBottomShape": "v" + }, + "wells": [ + "A1", + "B1", + "C1", + "D1", + "E1", + "F1", + "G1", + "H1", + "A2", + "B2", + "C2", + "D2", + "E2", + "F2", + "G2", + "H2", + "A3", + "B3", + "C3", + "D3", + "E3", + "F3", + "G3", + "H3", + "A4", + "B4", + "C4", + "D4", + "E4", + "F4", + "G4", + "H4", + "A5", + "B5", + "C5", + "D5", + "E5", + "F5", + "G5", + "H5", + "A6", + "B6", + "C6", + "D6", + "E6", + "F6", + "G6", + "H6", + "A7", + "B7", + "C7", + "D7", + "E7", + "F7", + "G7", + "H7", + "A8", + "B8", + "C8", + "D8", + "E8", + "F8", + "G8", + "H8", + "A9", + "B9", + "C9", + "D9", + "E9", + "F9", + "G9", + "H9", + "A10", + "B10", + "C10", + "D10", + "E10", + "F10", + "G10", + "H10" + ] + } + ], + "parameters": { + "format": "irregular", + "quirks": [], + "isTiprack": false, + "isMagneticModuleCompatible": false, + "loadName": "testpro_80_wellplate_100ul" + }, + "namespace": "custom_beta", + "version": 1, + "schemaVersion": 2, + "cornerOffsetFromSlot": { + "x": 0, + "y": 0, + "z": 0 + } +} diff --git a/labware-library/cypress/fixtures/testpro_96_aluminumblock_10ul.json b/labware-library/cypress/fixtures/testpro_96_aluminumblock_10ul.json new file mode 100644 index 00000000000..0b99d24def6 --- /dev/null +++ b/labware-library/cypress/fixtures/testpro_96_aluminumblock_10ul.json @@ -0,0 +1,1114 @@ +{ + "ordering": [ + ["A1", "B1", "C1", "D1", "E1", "F1", "G1", "H1"], + ["A2", "B2", "C2", "D2", "E2", "F2", "G2", "H2"], + ["A3", "B3", "C3", "D3", "E3", "F3", "G3", "H3"], + ["A4", "B4", "C4", "D4", "E4", "F4", "G4", "H4"], + ["A5", "B5", "C5", "D5", "E5", "F5", "G5", "H5"], + ["A6", "B6", "C6", "D6", "E6", "F6", "G6", "H6"], + ["A7", "B7", "C7", "D7", "E7", "F7", "G7", "H7"], + ["A8", "B8", "C8", "D8", "E8", "F8", "G8", "H8"], + ["A9", "B9", "C9", "D9", "E9", "F9", "G9", "H9"], + ["A10", "B10", "C10", "D10", "E10", "F10", "G10", "H10"], + ["A11", "B11", "C11", "D11", "E11", "F11", "G11", "H11"], + ["A12", "B12", "C12", "D12", "E12", "F12", "G12", "H12"] + ], + "brand": { + "brand": "TestPro", + "brandId": ["001"] + }, + "metadata": { + "displayName": "TestPro 96 Aluminum Block 10 µL", + "displayCategory": "aluminumBlock", + "displayVolumeUnits": "µL", + "tags": [] + }, + "dimensions": { + "xDimension": 127.75, + "yDimension": 85.5, + "zDimension": 75 + }, + "wells": { + "A1": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 14.38, + "y": 74.25, + "z": 65 + }, + "B1": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 14.38, + "y": 65.25, + "z": 65 + }, + "C1": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 14.38, + "y": 56.25, + "z": 65 + }, + "D1": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 14.38, + "y": 47.25, + "z": 65 + }, + "E1": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 14.38, + "y": 38.25, + "z": 65 + }, + "F1": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 14.38, + "y": 29.25, + "z": 65 + }, + "G1": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 14.38, + "y": 20.25, + "z": 65 + }, + "H1": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 14.38, + "y": 11.25, + "z": 65 + }, + "A2": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 23.38, + "y": 74.25, + "z": 65 + }, + "B2": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 23.38, + "y": 65.25, + "z": 65 + }, + "C2": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 23.38, + "y": 56.25, + "z": 65 + }, + "D2": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 23.38, + "y": 47.25, + "z": 65 + }, + "E2": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 23.38, + "y": 38.25, + "z": 65 + }, + "F2": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 23.38, + "y": 29.25, + "z": 65 + }, + "G2": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 23.38, + "y": 20.25, + "z": 65 + }, + "H2": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 23.38, + "y": 11.25, + "z": 65 + }, + "A3": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 32.38, + "y": 74.25, + "z": 65 + }, + "B3": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 32.38, + "y": 65.25, + "z": 65 + }, + "C3": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 32.38, + "y": 56.25, + "z": 65 + }, + "D3": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 32.38, + "y": 47.25, + "z": 65 + }, + "E3": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 32.38, + "y": 38.25, + "z": 65 + }, + "F3": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 32.38, + "y": 29.25, + "z": 65 + }, + "G3": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 32.38, + "y": 20.25, + "z": 65 + }, + "H3": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 32.38, + "y": 11.25, + "z": 65 + }, + "A4": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 41.38, + "y": 74.25, + "z": 65 + }, + "B4": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 41.38, + "y": 65.25, + "z": 65 + }, + "C4": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 41.38, + "y": 56.25, + "z": 65 + }, + "D4": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 41.38, + "y": 47.25, + "z": 65 + }, + "E4": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 41.38, + "y": 38.25, + "z": 65 + }, + "F4": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 41.38, + "y": 29.25, + "z": 65 + }, + "G4": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 41.38, + "y": 20.25, + "z": 65 + }, + "H4": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 41.38, + "y": 11.25, + "z": 65 + }, + "A5": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 50.38, + "y": 74.25, + "z": 65 + }, + "B5": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 50.38, + "y": 65.25, + "z": 65 + }, + "C5": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 50.38, + "y": 56.25, + "z": 65 + }, + "D5": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 50.38, + "y": 47.25, + "z": 65 + }, + "E5": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 50.38, + "y": 38.25, + "z": 65 + }, + "F5": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 50.38, + "y": 29.25, + "z": 65 + }, + "G5": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 50.38, + "y": 20.25, + "z": 65 + }, + "H5": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 50.38, + "y": 11.25, + "z": 65 + }, + "A6": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 59.38, + "y": 74.25, + "z": 65 + }, + "B6": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 59.38, + "y": 65.25, + "z": 65 + }, + "C6": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 59.38, + "y": 56.25, + "z": 65 + }, + "D6": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 59.38, + "y": 47.25, + "z": 65 + }, + "E6": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 59.38, + "y": 38.25, + "z": 65 + }, + "F6": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 59.38, + "y": 29.25, + "z": 65 + }, + "G6": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 59.38, + "y": 20.25, + "z": 65 + }, + "H6": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 59.38, + "y": 11.25, + "z": 65 + }, + "A7": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 68.38, + "y": 74.25, + "z": 65 + }, + "B7": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 68.38, + "y": 65.25, + "z": 65 + }, + "C7": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 68.38, + "y": 56.25, + "z": 65 + }, + "D7": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 68.38, + "y": 47.25, + "z": 65 + }, + "E7": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 68.38, + "y": 38.25, + "z": 65 + }, + "F7": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 68.38, + "y": 29.25, + "z": 65 + }, + "G7": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 68.38, + "y": 20.25, + "z": 65 + }, + "H7": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 68.38, + "y": 11.25, + "z": 65 + }, + "A8": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 77.38, + "y": 74.25, + "z": 65 + }, + "B8": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 77.38, + "y": 65.25, + "z": 65 + }, + "C8": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 77.38, + "y": 56.25, + "z": 65 + }, + "D8": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 77.38, + "y": 47.25, + "z": 65 + }, + "E8": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 77.38, + "y": 38.25, + "z": 65 + }, + "F8": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 77.38, + "y": 29.25, + "z": 65 + }, + "G8": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 77.38, + "y": 20.25, + "z": 65 + }, + "H8": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 77.38, + "y": 11.25, + "z": 65 + }, + "A9": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 86.38, + "y": 74.25, + "z": 65 + }, + "B9": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 86.38, + "y": 65.25, + "z": 65 + }, + "C9": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 86.38, + "y": 56.25, + "z": 65 + }, + "D9": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 86.38, + "y": 47.25, + "z": 65 + }, + "E9": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 86.38, + "y": 38.25, + "z": 65 + }, + "F9": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 86.38, + "y": 29.25, + "z": 65 + }, + "G9": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 86.38, + "y": 20.25, + "z": 65 + }, + "H9": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 86.38, + "y": 11.25, + "z": 65 + }, + "A10": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 95.38, + "y": 74.25, + "z": 65 + }, + "B10": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 95.38, + "y": 65.25, + "z": 65 + }, + "C10": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 95.38, + "y": 56.25, + "z": 65 + }, + "D10": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 95.38, + "y": 47.25, + "z": 65 + }, + "E10": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 95.38, + "y": 38.25, + "z": 65 + }, + "F10": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 95.38, + "y": 29.25, + "z": 65 + }, + "G10": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 95.38, + "y": 20.25, + "z": 65 + }, + "H10": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 95.38, + "y": 11.25, + "z": 65 + }, + "A11": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 104.38, + "y": 74.25, + "z": 65 + }, + "B11": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 104.38, + "y": 65.25, + "z": 65 + }, + "C11": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 104.38, + "y": 56.25, + "z": 65 + }, + "D11": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 104.38, + "y": 47.25, + "z": 65 + }, + "E11": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 104.38, + "y": 38.25, + "z": 65 + }, + "F11": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 104.38, + "y": 29.25, + "z": 65 + }, + "G11": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 104.38, + "y": 20.25, + "z": 65 + }, + "H11": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 104.38, + "y": 11.25, + "z": 65 + }, + "A12": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 113.38, + "y": 74.25, + "z": 65 + }, + "B12": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 113.38, + "y": 65.25, + "z": 65 + }, + "C12": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 113.38, + "y": 56.25, + "z": 65 + }, + "D12": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 113.38, + "y": 47.25, + "z": 65 + }, + "E12": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 113.38, + "y": 38.25, + "z": 65 + }, + "F12": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 113.38, + "y": 29.25, + "z": 65 + }, + "G12": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 113.38, + "y": 20.25, + "z": 65 + }, + "H12": { + "depth": 10, + "totalLiquidVolume": 10, + "shape": "rectangular", + "xDimension": 10, + "yDimension": 10, + "x": 113.38, + "y": 11.25, + "z": 65 + } + }, + "groups": [ + { + "metadata": { + "wellBottomShape": "v", + "displayCategory": "wellPlate" + }, + "wells": [ + "A1", + "B1", + "C1", + "D1", + "E1", + "F1", + "G1", + "H1", + "A2", + "B2", + "C2", + "D2", + "E2", + "F2", + "G2", + "H2", + "A3", + "B3", + "C3", + "D3", + "E3", + "F3", + "G3", + "H3", + "A4", + "B4", + "C4", + "D4", + "E4", + "F4", + "G4", + "H4", + "A5", + "B5", + "C5", + "D5", + "E5", + "F5", + "G5", + "H5", + "A6", + "B6", + "C6", + "D6", + "E6", + "F6", + "G6", + "H6", + "A7", + "B7", + "C7", + "D7", + "E7", + "F7", + "G7", + "H7", + "A8", + "B8", + "C8", + "D8", + "E8", + "F8", + "G8", + "H8", + "A9", + "B9", + "C9", + "D9", + "E9", + "F9", + "G9", + "H9", + "A10", + "B10", + "C10", + "D10", + "E10", + "F10", + "G10", + "H10", + "A11", + "B11", + "C11", + "D11", + "E11", + "F11", + "G11", + "H11", + "A12", + "B12", + "C12", + "D12", + "E12", + "F12", + "G12", + "H12" + ] + } + ], + "parameters": { + "format": "irregular", + "quirks": [], + "isTiprack": false, + "isMagneticModuleCompatible": false, + "loadName": "testpro_96_aluminumblock_10ul" + }, + "namespace": "custom_beta", + "version": 1, + "schemaVersion": 2, + "cornerOffsetFromSlot": { + "x": 0, + "y": 0, + "z": 0 + } +} diff --git a/labware-library/cypress/mocks/file-saver.js b/labware-library/cypress/mocks/file-saver.js deleted file mode 100644 index d4c7febe539..00000000000 --- a/labware-library/cypress/mocks/file-saver.js +++ /dev/null @@ -1,6 +0,0 @@ -// mock for 'file-saver' npm module - -export const saveAs = (blob, fileName) => { - global.__lastSavedFileBlob__ = blob - global.__lastSavedFileName__ = fileName -} diff --git a/labware-library/cypress/plugins/index.js b/labware-library/cypress/plugins/index.js deleted file mode 100644 index f392875c7d9..00000000000 --- a/labware-library/cypress/plugins/index.js +++ /dev/null @@ -1,23 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/triple-slash-reference -/// -// *********************************************************** -// This example plugins/index.js can be used to load plugins -// -// You can change the location of this file or turn off loading -// the plugins file with the 'pluginsFile' configuration option. -// -// You can read more here: -// https://on.cypress.io/plugins-guide -// *********************************************************** - -// This function is called when a project is opened or re-opened (e.g. due to -// the project's config changing) - -/** - * @type {Cypress.PluginConfig} - */ -// eslint-disable-next-line no-unused-vars -module.exports = (on, config) => { - // `on` is used to hook into various events Cypress emits - // `config` is the resolved Cypress config -} diff --git a/labware-library/cypress/support/e2e.js b/labware-library/cypress/support/e2e.js deleted file mode 100644 index d68db96df26..00000000000 --- a/labware-library/cypress/support/e2e.js +++ /dev/null @@ -1,20 +0,0 @@ -// *********************************************************** -// This example support/index.js is processed and -// loaded automatically before your test files. -// -// This is a great place to put global configuration and -// behavior that modifies Cypress. -// -// You can change the location of this file or turn off -// automatically serving support files with the -// 'supportFile' configuration option. -// -// You can read more here: -// https://on.cypress.io/configuration -// *********************************************************** - -// Import commands.js using ES2015 syntax: -import './commands' - -// Alternatively you can use CommonJS syntax: -// require('./commands') diff --git a/labware-library/cypress/support/e2e.ts b/labware-library/cypress/support/e2e.ts new file mode 100644 index 00000000000..85dcff19ba8 --- /dev/null +++ b/labware-library/cypress/support/e2e.ts @@ -0,0 +1,40 @@ +// *********************************************************** +// This file runs before every single spec file. +// We do this purely as a convenience mechanism so you don't have to import this file. +// https://docs.cypress.io/guides/core-concepts/writing-and-organizing-tests#Support-file +// *********************************************************** +import { join } from 'path' +import './commands' + +export const navigateToUrl = (url: string): void => { + cy.visit(url) + cy.viewport('macbook-15') +} + +export const wellBottomImageLocator: Record = { + flat: 'img[alt*="flat bottom"]', + round: 'img[alt*="u shaped"]', + v: 'img[alt*="v shaped"]', +} + +interface FileHelperResult { + downloadsFolder: string + downloadFileStem: string + downloadFilename: string + downloadPath: string + expectedExportFixture: string +} + +export const fileHelper = (fileStem: string): FileHelperResult => { + const downloadsFolder = Cypress.config('downloadsFolder') + const downloadFileStem = fileStem + const downloadFilename = `${downloadFileStem}.json` + const downloadPath = join(downloadsFolder, downloadFilename) + return { + downloadsFolder, + downloadFileStem, + downloadFilename, + downloadPath, + expectedExportFixture: `../fixtures/${downloadFilename}`, + } +} diff --git a/labware-library/src/components/labware-ui/labware-images.ts b/labware-library/src/components/labware-ui/labware-images.ts index 36fe2cb8dfb..8df00e07f2a 100644 --- a/labware-library/src/components/labware-ui/labware-images.ts +++ b/labware-library/src/components/labware-ui/labware-images.ts @@ -468,4 +468,13 @@ export const labwareImages: Record = { import.meta.url ).href, ], + opentrons_tough_pcr_auto_sealing_lid: [ + new URL( + '../../images/opentrons_tough_pcr_auto_sealing_lid.jpg', + import.meta.url + ).href, + ], + opentrons_flex_deck_riser: [ + new URL('../../images/opentrons_flex_deck_riser.jpg', import.meta.url).href, + ], } diff --git a/labware-library/src/images/opentrons_flex_deck_riser.jpg b/labware-library/src/images/opentrons_flex_deck_riser.jpg new file mode 100644 index 00000000000..b8576833538 Binary files /dev/null and b/labware-library/src/images/opentrons_flex_deck_riser.jpg differ diff --git a/labware-library/src/images/opentrons_tough_pcr_auto_sealing_lid.jpg b/labware-library/src/images/opentrons_tough_pcr_auto_sealing_lid.jpg new file mode 100644 index 00000000000..a81c42b2d2c Binary files /dev/null and b/labware-library/src/images/opentrons_tough_pcr_auto_sealing_lid.jpg differ diff --git a/labware-library/src/localization/en.ts b/labware-library/src/localization/en.ts index 07e7bda76d1..9745ed44fb2 100644 --- a/labware-library/src/localization/en.ts +++ b/labware-library/src/localization/en.ts @@ -10,6 +10,7 @@ export const CATEGORY_LABELS_BY_CATEGORY = { trash: 'Trash', other: 'Other', adapter: 'Adapter', + lid: 'Lid', } export const PLURAL_CATEGORY_LABELS_BY_CATEGORY = { @@ -20,6 +21,7 @@ export const PLURAL_CATEGORY_LABELS_BY_CATEGORY = { wellPlate: 'Well Plates', reservoir: 'Reservoirs', aluminumBlock: 'Aluminum Blocks', + lid: 'Lid', trash: 'Trashes', other: 'Other', } diff --git a/labware-library/vite.config.mts b/labware-library/vite.config.mts index 43d5065c011..0c05338af06 100644 --- a/labware-library/vite.config.mts +++ b/labware-library/vite.config.mts @@ -8,14 +8,6 @@ import postCssPresetEnv from 'postcss-preset-env' import lostCss from 'lost' import { cssModuleSideEffect } from './cssModuleSideEffect' -const testAliases: {} | { 'file-saver': string } = - process.env.CYPRESS === '1' - ? { - 'file-saver': - path.resolve(__dirname, 'cypress/mocks/file-saver.js') ?? '', - } - : {} - export default defineConfig({ // this makes imports relative rather than absolute base: '', @@ -70,7 +62,6 @@ export default defineConfig({ '@opentrons/step-generation': path.resolve( '../step-generation/src/index.ts' ), - ...testAliases, }, }, server: { diff --git a/opentrons-ai-client/src/assets/localization/en/protocol_generator.json b/opentrons-ai-client/src/assets/localization/en/protocol_generator.json index f44eff34e73..1d69984c345 100644 --- a/opentrons-ai-client/src/assets/localization/en/protocol_generator.json +++ b/opentrons-ai-client/src/assets/localization/en/protocol_generator.json @@ -1,7 +1,9 @@ { + "ai": "AI", "api": "API: An API level is 2.15", "application": "Application: Your protocol's name, describing what it does.", "commands": "Commands: List the protocol's steps, specifying quantities in microliters (uL) and giving exact source and destination locations.", + "copyright": "Copyright © 2024 Opentrons", "copy_code": "Copy code", "disclaimer": "OpentronsAI can make mistakes. Review your protocol before running it on an Opentrons robot.", "example": "For example prompts, click the buttons in the left panel.", @@ -15,11 +17,13 @@ "make_sure_your_prompt": "Write a prompt in a natural language for OpentronsAI to generate a protocol using the Opentrons Python Protocol API v2. The better the prompt, the better the quality of the protocol produced by OpentronsAI.", "modules_and_adapters": "Modules and adapters: Specify the modules and labware adapters required by your protocol.", "notes": "A few important things to note:", + "opentrons": "Opentrons", "opentronsai": "OpentronsAI", "ot2_pipettes": "OT-2 pipettes: Include volume, number of channels, and generation.", "pcr_flex": "PCR (Flex)", "pcr": "PCR", "pipettes": "Pipettes: Specify your pipettes, including the volume, number of channels, and whether they’re mounted on the left or right.", + "privacy_policy": "By continuing, you agree to the Opentrons Privacy Policy and End user license agreement", "reagent_transfer_flex": "Reagent Transfer (Flex)", "reagent_transfer": "Reagent Transfer", "reload_page": "To start over and create a new protocol, simply reload the page.", @@ -34,5 +38,7 @@ "well_allocations": "Well allocations: Describe where liquids should go in labware.", "what_if_you": "What if you don’t provide all of those pieces of information? OpentronsAI asks you to provide it!", "what_typeof_protocol": "What type of protocol do you need?", - "you": "You" + "you": "You", + "prompt_preview_submit_button": "Submit prompt", + "prompt_preview_placeholder_message": "As you complete the sections on the left, your prompt will be built here. When all requirements are met you will be able to generate the protocol." } diff --git a/opentrons-ai-client/src/molecules/Accordion/Accordion.stories.tsx b/opentrons-ai-client/src/molecules/Accordion/Accordion.stories.tsx new file mode 100644 index 00000000000..388267061b0 --- /dev/null +++ b/opentrons-ai-client/src/molecules/Accordion/Accordion.stories.tsx @@ -0,0 +1,74 @@ +import { I18nextProvider } from 'react-i18next' +import { COLORS, Flex, SPACING } from '@opentrons/components' +import { i18n } from '../../i18n' +import { Accordion } from './index' + +import type { Meta, StoryObj } from '@storybook/react' + +const contentExample: React.ReactNode = ( +
+

What's your scientific application?

+

Describe what you are trying to do

+

+ Example: “The protocol performs automated liquid handling for Pierce BCA + Protein Assay Kit to determine protein concentrations in various sample + types, such as cell lysates and eluates of purification process." +

+
+) + +const meta: Meta = { + title: 'AI/molecules/Accordion', + component: Accordion, + decorators: [ + Story => ( + + + + + + ), + ], +} +export default meta +type Story = StoryObj + +export const AccordionCollapsed: Story = { + args: { + id: 'accordion', + handleClick: () => { + alert('Accordion clicked') + }, + heading: 'Application', + children: contentExample, + }, +} + +export const AccordionCompleted: Story = { + args: { + id: 'accordion', + isCompleted: true, + heading: 'Application', + }, +} + +export const AccordionExpanded: Story = { + args: { + id: 'accordion2', + isOpen: true, + heading: 'Application', + children: contentExample, + }, +} + +export const AccordionDisabled: Story = { + args: { + id: 'accordion3', + handleClick: () => { + alert('Accordion clicked') + }, + disabled: true, + heading: 'Application', + children: contentExample, + }, +} diff --git a/opentrons-ai-client/src/molecules/Accordion/__tests__/Accordion.test.tsx b/opentrons-ai-client/src/molecules/Accordion/__tests__/Accordion.test.tsx new file mode 100644 index 00000000000..4be089d8398 --- /dev/null +++ b/opentrons-ai-client/src/molecules/Accordion/__tests__/Accordion.test.tsx @@ -0,0 +1,68 @@ +import type * as React from 'react' +import { describe, it, vi, beforeEach, expect } from 'vitest' +import { fireEvent, screen } from '@testing-library/react' +import { renderWithProviders } from '../../../__testing-utils__' + +import { Accordion } from '../index' + +const mockHandleClick = vi.fn() +const render = (props: React.ComponentProps) => { + return renderWithProviders() +} + +describe('Accordion', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + id: 'accordion-test', + handleClick: mockHandleClick, + isOpen: false, + isCompleted: false, + heading: 'Accordion heading', + children:
Accordion content
, + } + }) + + it('should render an accordion with heading', () => { + render(props) + const accordion = screen.getByRole('button', { name: 'Accordion heading' }) + expect(accordion).toBeInTheDocument() + }) + + it('should display content if isOpen is true', () => { + props.isOpen = true + render(props) + const accordionContent = screen.getByText('Accordion content') + expect(accordionContent).toBeVisible() + }) + + it('should not display content if isOpen is false', () => { + render(props) + const accordionContent = screen.queryByText('Accordion content') + expect(accordionContent).not.toBeVisible() + }) + + it("should call handleClick when the accordion's header is clicked", () => { + render(props) + const accordionHeader = screen.getByRole('button', { + name: 'Accordion heading', + }) + fireEvent.click(accordionHeader) + expect(mockHandleClick).toHaveBeenCalled() + }) + + it('should display a check icon if isCompleted is true', () => { + props.isCompleted = true + render(props) + const checkIcon = screen.getByTestId('accordion-test-ot-check') + expect(checkIcon).toBeInTheDocument() + }) + + it('should not display a check icon if isCompleted is false', () => { + props.isCompleted = false + render(props) + const checkIcon = screen.queryByTestId('accordion-test-ot-check') + expect(checkIcon).not.toBeInTheDocument() + }) +}) diff --git a/opentrons-ai-client/src/molecules/Accordion/index.tsx b/opentrons-ai-client/src/molecules/Accordion/index.tsx new file mode 100644 index 00000000000..885f6af1745 --- /dev/null +++ b/opentrons-ai-client/src/molecules/Accordion/index.tsx @@ -0,0 +1,158 @@ +import { useRef, useState, useEffect } from 'react' +import styled from 'styled-components' +import { + Flex, + Icon, + StyledText, + COLORS, + BORDERS, + DIRECTION_COLUMN, + SIZE_AUTO, + SPACING, + JUSTIFY_SPACE_BETWEEN, + ALIGN_CENTER, + CURSOR_POINTER, + TEXT_ALIGN_LEFT, + DISPLAY_FLEX, + OVERFLOW_HIDDEN, + CURSOR_DEFAULT, +} from '@opentrons/components' + +interface AccordionProps { + id?: string + handleClick: () => void + heading: string + isOpen?: boolean + isCompleted?: boolean + disabled?: boolean + children: React.ReactNode +} + +const ACCORDION = 'accordion' +const BUTTON = 'button' +const CONTENT = 'content' +const OT_CHECK = 'ot-check' + +const AccordionContainer = styled(Flex)<{ + isOpen: boolean + disabled: boolean +}>` + flex-direction: ${DIRECTION_COLUMN}; + width: 100%; + height: ${SIZE_AUTO}; + padding: ${SPACING.spacing24} ${SPACING.spacing32}; + border-radius: ${BORDERS.borderRadius16}; + background-color: ${COLORS.white}; + cursor: ${props => + props.isOpen || props.disabled ? `${CURSOR_DEFAULT}` : `${CURSOR_POINTER}`}; +` + +const AccordionButton = styled.button<{ isOpen: boolean; disabled: boolean }>` + display: ${DISPLAY_FLEX}; + justify-content: ${JUSTIFY_SPACE_BETWEEN}; + align-items: ${ALIGN_CENTER}; + width: 100%; + background: none; + border: none; + cursor: ${props => + props.isOpen || props.disabled ? `${CURSOR_DEFAULT}` : `${CURSOR_POINTER}`}; + text-align: ${TEXT_ALIGN_LEFT}; + + &:focus-visible { + outline: 2px solid ${COLORS.blue50}; + } +` + +const HeadingText = styled(StyledText)` + flex: 1; + margin-right: ${SPACING.spacing8}; +` + +const AccordionContent = styled.div<{ + id: string + isOpen: boolean + contentHeight: number +}>` + transition: height 0.3s ease, margin-top 0.3s ease, visibility 0.3s ease; + overflow: ${OVERFLOW_HIDDEN}; + height: ${props => (props.isOpen ? `${props.contentHeight}px` : '0')}; + margin-top: ${props => (props.isOpen ? `${SPACING.spacing16}` : '0')}; + pointer-events: ${props => (props.isOpen ? 'auto' : 'none')}; + visibility: ${props => (props.isOpen ? 'unset' : 'hidden')}; +` + +export function Accordion({ + id = ACCORDION, + handleClick, + isOpen = false, + isCompleted = false, + disabled = false, + heading = '', + children, +}: AccordionProps): JSX.Element { + const contentRef = useRef(null) + const [contentHeight, setContentHeight] = useState(0) + + useEffect(() => { + if (contentRef.current != null) { + setContentHeight(contentRef.current.scrollHeight) + } + }, [isOpen]) + + const handleContainerClick = (e: React.MouseEvent): void => { + // Prevent the click event from propagating to the button + if ( + (e.target as HTMLElement).tagName !== 'BUTTON' && + !disabled && + !isOpen + ) { + handleClick() + } + } + + const handleButtonClick = (e: React.MouseEvent): void => { + // Stop the event from propagating to the container + if (!isOpen && !disabled) { + e.stopPropagation() + handleClick() + } + } + + return ( + + + {heading} + {isCompleted && ( + + )} + + + {children} + + + ) +} diff --git a/opentrons-ai-client/src/molecules/Footer/Footer.stories.tsx b/opentrons-ai-client/src/molecules/Footer/Footer.stories.tsx new file mode 100644 index 00000000000..78fdf2d42bc --- /dev/null +++ b/opentrons-ai-client/src/molecules/Footer/Footer.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { Footer } from '.' +import { COLORS, Flex } from '@opentrons/components' + +const meta: Meta = { + title: 'AI/Molecules/Footer', + component: Footer, + decorators: [ + Story => ( + + + + ), + ], +} +export default meta + +type Story = StoryObj + +export const FooterExample: Story = {} diff --git a/opentrons-ai-client/src/molecules/Footer/__tests__/Footer.test.tsx b/opentrons-ai-client/src/molecules/Footer/__tests__/Footer.test.tsx new file mode 100644 index 00000000000..704855096ed --- /dev/null +++ b/opentrons-ai-client/src/molecules/Footer/__tests__/Footer.test.tsx @@ -0,0 +1,38 @@ +import { Footer } from '..' +import { renderWithProviders } from '../../../__testing-utils__' +import { screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { i18n } from '../../../i18n' + +const render = (): ReturnType => { + return renderWithProviders(