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 0415367f1e6..860c0848ff8 100644 --- a/api-client/src/runs/types.ts +++ b/api-client/src/runs/types.ts @@ -60,6 +60,7 @@ export interface LegacyGoodRunData { export interface KnownGoodRunData extends LegacyGoodRunData { ok: true runTimeParameters: RunTimeParameter[] + outputFileIds: string[] } export interface KnownInvalidRunData extends LegacyGoodRunData { 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/api.py b/api/src/opentrons/hardware_control/api.py index 636ad165983..909a50a3d8c 100644 --- a/api/src/opentrons/hardware_control/api.py +++ b/api/src/opentrons/hardware_control/api.py @@ -1285,7 +1285,7 @@ async def drop_tip(self, mount: top_types.Mount, home_after: bool = True) -> Non instrument.set_current_volume(0) self.set_current_tiprack_diameter(mount, 0.0) - await self.remove_tip(mount) + self.remove_tip(mount) async def create_simulating_module( self, diff --git a/api/src/opentrons/hardware_control/backends/flex_protocol.py b/api/src/opentrons/hardware_control/backends/flex_protocol.py index 6f3299cf92d..466e7890026 100644 --- a/api/src/opentrons/hardware_control/backends/flex_protocol.py +++ b/api/src/opentrons/hardware_control/backends/flex_protocol.py @@ -15,7 +15,7 @@ from opentrons_shared_data.pipette.types import ( PipetteName, ) -from opentrons.config.types import GantryLoad, OutputOptions +from opentrons.config.types import GantryLoad from opentrons.hardware_control.types import ( BoardRevision, Axis, @@ -38,6 +38,8 @@ StatusBarState, ) from opentrons.hardware_control.module_control import AttachedModulesControl +from opentrons_hardware.firmware_bindings.constants import SensorId +from opentrons_hardware.sensors.types import SensorDataType from ..dev_types import OT3AttachedInstruments from .types import HWStopCondition @@ -152,10 +154,11 @@ async def liquid_probe( threshold_pascals: float, plunger_impulse_time: float, num_baseline_reads: int, - output_format: OutputOptions = OutputOptions.can_bus_only, - data_files: Optional[Dict[InstrumentProbeType, str]] = None, probe: InstrumentProbeType = InstrumentProbeType.PRIMARY, force_both_sensors: bool = False, + response_queue: Optional[ + asyncio.Queue[Dict[SensorId, List[SensorDataType]]] + ] = None, ) -> float: ... @@ -371,8 +374,6 @@ async def capacitive_probe( speed_mm_per_s: float, sensor_threshold_pf: float, probe: InstrumentProbeType = InstrumentProbeType.PRIMARY, - output_format: OutputOptions = OutputOptions.sync_only, - data_files: Optional[Dict[InstrumentProbeType, str]] = None, ) -> bool: ... diff --git a/api/src/opentrons/hardware_control/backends/ot3controller.py b/api/src/opentrons/hardware_control/backends/ot3controller.py index 84c95c8fbc4..48787e86933 100644 --- a/api/src/opentrons/hardware_control/backends/ot3controller.py +++ b/api/src/opentrons/hardware_control/backends/ot3controller.py @@ -25,7 +25,7 @@ Union, Mapping, ) -from opentrons.config.types import OT3Config, GantryLoad, OutputOptions +from opentrons.config.types import OT3Config, GantryLoad from opentrons.config import gripper_config from .ot3utils import ( axis_convert, @@ -102,7 +102,9 @@ NodeId, PipetteName as FirmwarePipetteName, ErrorCode, + SensorId, ) +from opentrons_hardware.sensors.types import SensorDataType from opentrons_hardware.firmware_bindings.messages.message_definitions import ( StopRequest, ) @@ -1368,28 +1370,14 @@ async def liquid_probe( threshold_pascals: float, plunger_impulse_time: float, num_baseline_reads: int, - output_option: OutputOptions = OutputOptions.can_bus_only, - data_files: Optional[Dict[InstrumentProbeType, str]] = None, probe: InstrumentProbeType = InstrumentProbeType.PRIMARY, force_both_sensors: bool = False, + response_queue: Optional[ + asyncio.Queue[Dict[SensorId, List[SensorDataType]]] + ] = None, ) -> float: head_node = axis_to_node(Axis.by_mount(mount)) tool = sensor_node_for_pipette(OT3Mount(mount.value)) - csv_output = bool(output_option.value & OutputOptions.stream_to_csv.value) - sync_buffer_output = bool( - output_option.value & OutputOptions.sync_buffer_to_csv.value - ) - can_bus_only_output = bool( - output_option.value & OutputOptions.can_bus_only.value - ) - data_files_transposed = ( - None - if data_files is None - else { - sensor_id_for_instrument(probe): data_files[probe] - for probe in data_files.keys() - } - ) positions = await liquid_probe( messenger=self._messenger, tool=tool, @@ -1400,12 +1388,9 @@ async def liquid_probe( threshold_pascals=threshold_pascals, plunger_impulse_time=plunger_impulse_time, num_baseline_reads=num_baseline_reads, - csv_output=csv_output, - sync_buffer_output=sync_buffer_output, - can_bus_only_output=can_bus_only_output, - data_files=data_files_transposed, sensor_id=sensor_id_for_instrument(probe), force_both_sensors=force_both_sensors, + response_queue=response_queue, ) for node, point in positions.items(): self._position.update({node: point.motor_position}) @@ -1432,41 +1417,13 @@ async def capacitive_probe( speed_mm_per_s: float, sensor_threshold_pf: float, probe: InstrumentProbeType = InstrumentProbeType.PRIMARY, - output_option: OutputOptions = OutputOptions.sync_only, - data_files: Optional[Dict[InstrumentProbeType, str]] = None, ) -> bool: - if output_option == OutputOptions.sync_buffer_to_csv: - assert ( - self._subsystem_manager.device_info[ - SubSystem.of_mount(mount) - ].revision.tertiary - == "1" - ) - csv_output = bool(output_option.value & OutputOptions.stream_to_csv.value) - sync_buffer_output = bool( - output_option.value & OutputOptions.sync_buffer_to_csv.value - ) - can_bus_only_output = bool( - output_option.value & OutputOptions.can_bus_only.value - ) - data_files_transposed = ( - None - if data_files is None - else { - sensor_id_for_instrument(probe): data_files[probe] - for probe in data_files.keys() - } - ) status = await capacitive_probe( messenger=self._messenger, tool=sensor_node_for_mount(mount), mover=axis_to_node(moving), distance=distance_mm, mount_speed=speed_mm_per_s, - csv_output=csv_output, - sync_buffer_output=sync_buffer_output, - can_bus_only_output=can_bus_only_output, - data_files=data_files_transposed, sensor_id=sensor_id_for_instrument(probe), relative_threshold_pf=sensor_threshold_pf, ) diff --git a/api/src/opentrons/hardware_control/backends/ot3simulator.py b/api/src/opentrons/hardware_control/backends/ot3simulator.py index 034531892d8..017c90c45b3 100644 --- a/api/src/opentrons/hardware_control/backends/ot3simulator.py +++ b/api/src/opentrons/hardware_control/backends/ot3simulator.py @@ -17,7 +17,7 @@ Mapping, ) -from opentrons.config.types import OT3Config, GantryLoad, OutputOptions +from opentrons.config.types import OT3Config, GantryLoad from opentrons.config import gripper_config from opentrons.hardware_control.module_control import AttachedModulesControl @@ -63,7 +63,8 @@ from opentrons.util.async_helpers import ensure_yield from .types import HWStopCondition from .flex_protocol import FlexBackend - +from opentrons_hardware.firmware_bindings.constants import SensorId +from opentrons_hardware.sensors.types import SensorDataType log = logging.getLogger(__name__) @@ -347,10 +348,11 @@ async def liquid_probe( threshold_pascals: float, plunger_impulse_time: float, num_baseline_reads: int, - output_format: OutputOptions = OutputOptions.can_bus_only, - data_files: Optional[Dict[InstrumentProbeType, str]] = None, probe: InstrumentProbeType = InstrumentProbeType.PRIMARY, force_both_sensors: bool = False, + response_queue: Optional[ + asyncio.Queue[Dict[SensorId, List[SensorDataType]]] + ] = None, ) -> float: z_axis = Axis.by_mount(mount) pos = self._position @@ -750,8 +752,6 @@ async def capacitive_probe( speed_mm_per_s: float, sensor_threshold_pf: float, probe: InstrumentProbeType = InstrumentProbeType.PRIMARY, - output_format: OutputOptions = OutputOptions.sync_only, - data_files: Optional[Dict[InstrumentProbeType, str]] = None, ) -> bool: self._position[moving] += distance_mm return True diff --git a/api/src/opentrons/hardware_control/instruments/ot2/pipette_handler.py b/api/src/opentrons/hardware_control/instruments/ot2/pipette_handler.py index 152c06f66ac..907788d6dda 100644 --- a/api/src/opentrons/hardware_control/instruments/ot2/pipette_handler.py +++ b/api/src/opentrons/hardware_control/instruments/ot2/pipette_handler.py @@ -415,7 +415,7 @@ async def reset_nozzle_configuration(self, mount: MountType) -> None: if instr: instr.reset_nozzle_configuration() - async def add_tip(self, mount: MountType, tip_length: float) -> None: + def add_tip(self, mount: MountType, tip_length: float) -> None: instr = self._attached_instruments[mount] attached = self.attached_instruments instr_dict = attached[mount] @@ -430,7 +430,7 @@ async def add_tip(self, mount: MountType, tip_length: float) -> None: f"attach tip called while tip already attached to {instr}" ) - async def remove_tip(self, mount: MountType) -> None: + def remove_tip(self, mount: MountType) -> None: instr = self._attached_instruments[mount] attached = self.attached_instruments instr_dict = attached[mount] diff --git a/api/src/opentrons/hardware_control/instruments/ot3/pipette_handler.py b/api/src/opentrons/hardware_control/instruments/ot3/pipette_handler.py index 4f24b19c51b..9f44f7b0ab8 100644 --- a/api/src/opentrons/hardware_control/instruments/ot3/pipette_handler.py +++ b/api/src/opentrons/hardware_control/instruments/ot3/pipette_handler.py @@ -425,7 +425,7 @@ async def reset_nozzle_configuration(self, mount: OT3Mount) -> None: if instr: instr.reset_nozzle_configuration() - async def add_tip(self, mount: OT3Mount, tip_length: float) -> None: + def add_tip(self, mount: OT3Mount, tip_length: float) -> None: instr = self._attached_instruments[mount] attached = self.attached_instruments instr_dict = attached[mount] @@ -440,7 +440,7 @@ async def add_tip(self, mount: OT3Mount, tip_length: float) -> None: "attach tip called while tip already attached to {instr}" ) - async def remove_tip(self, mount: OT3Mount) -> None: + def remove_tip(self, mount: OT3Mount) -> None: instr = self._attached_instruments[mount] attached = self.attached_instruments instr_dict = attached[mount] diff --git a/api/src/opentrons/hardware_control/ot3_calibration.py b/api/src/opentrons/hardware_control/ot3_calibration.py index e49b4de171f..b0ebcd027ce 100644 --- a/api/src/opentrons/hardware_control/ot3_calibration.py +++ b/api/src/opentrons/hardware_control/ot3_calibration.py @@ -819,13 +819,13 @@ async def find_pipette_offset( try: if reset_instrument_offset: await hcapi.reset_instrument_offset(mount) - await hcapi.add_tip(mount, hcapi.config.calibration.probe_length) + hcapi.add_tip(mount, hcapi.config.calibration.probe_length) offset = await _calibrate_mount( hcapi, mount, slot, method, raise_verify_error, probe=probe ) return offset finally: - await hcapi.remove_tip(mount) + hcapi.remove_tip(mount) async def calibrate_pipette( @@ -877,7 +877,7 @@ async def calibrate_module( if mount == OT3Mount.GRIPPER: hcapi.add_gripper_probe(GripperProbe.FRONT) else: - await hcapi.add_tip(mount, hcapi.config.calibration.probe_length) + hcapi.add_tip(mount, hcapi.config.calibration.probe_length) LOG.info( f"Starting module calibration for {module_id} at {nominal_position} using {mount}" @@ -903,7 +903,7 @@ async def calibrate_module( hcapi.remove_gripper_probe() await hcapi.ungrip() else: - await hcapi.remove_tip(mount) + hcapi.remove_tip(mount) async def calibrate_belts( @@ -927,7 +927,7 @@ async def calibrate_belts( raise RuntimeError("Must use pipette mount, not gripper") try: hcapi.reset_deck_calibration() - await hcapi.add_tip(mount, hcapi.config.calibration.probe_length) + hcapi.add_tip(mount, hcapi.config.calibration.probe_length) belt_attitude, alignment_details = await _determine_transform_matrix( hcapi, mount ) @@ -935,7 +935,7 @@ async def calibrate_belts( return belt_attitude, alignment_details finally: hcapi.load_deck_calibration() - await hcapi.remove_tip(mount) + hcapi.remove_tip(mount) def apply_machine_transform( diff --git a/api/src/opentrons/hardware_control/ot3api.py b/api/src/opentrons/hardware_control/ot3api.py index 54c776d39a3..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__) @@ -2338,7 +2339,7 @@ async def drop_tip( instrument.set_current_volume(0) self.set_current_tiprack_diameter(mount, 0.0) - await self.remove_tip(mount) + self.remove_tip(mount) async def clean_up(self) -> None: """Get the API ready to stop cleanly.""" @@ -2607,13 +2608,13 @@ async def update_nozzle_configuration_for_mount( starting_nozzle, ) - async def add_tip( + def add_tip( self, mount: Union[top_types.Mount, OT3Mount], tip_length: float ) -> None: - await self._pipette_handler.add_tip(OT3Mount.from_mount(mount), tip_length) + self._pipette_handler.add_tip(OT3Mount.from_mount(mount), tip_length) - async def remove_tip(self, mount: Union[top_types.Mount, OT3Mount]) -> None: - await self._pipette_handler.remove_tip(OT3Mount.from_mount(mount)) + def remove_tip(self, mount: Union[top_types.Mount, OT3Mount]) -> None: + self._pipette_handler.remove_tip(OT3Mount.from_mount(mount)) def add_gripper_probe(self, probe: GripperProbe) -> None: self._gripper_handler.add_probe(probe) @@ -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/hardware_control/protocols/__init__.py b/api/src/opentrons/hardware_control/protocols/__init__.py index 1f3442ded3a..13266ac731c 100644 --- a/api/src/opentrons/hardware_control/protocols/__init__.py +++ b/api/src/opentrons/hardware_control/protocols/__init__.py @@ -58,11 +58,6 @@ class HardwareControlInterface( def get_robot_type(self) -> Type[OT2RobotType]: return OT2RobotType - # todo(mm, 2024-10-17): This probably belongs in InstrumentConfigurer, alongside - # add_tip() and remove_tip(). - def cache_tip(self, mount: MountArgType, tip_length: float) -> None: - ... - class FlexHardwareControlInterface( PositionEstimator, @@ -89,14 +84,9 @@ class FlexHardwareControlInterface( def get_robot_type(self) -> Type[FlexRobotType]: return FlexRobotType - # todo(mm, 2024-10-17): This probably belongs in InstrumentConfigurer, alongside - # add_tip() and remove_tip(). - def cache_tip(self, mount: MountArgType, tip_length: float) -> None: - ... - __all__ = [ - "HardwareControlAPI", + "HardwareControlInterface", "FlexHardwareControlInterface", "Simulatable", "Stoppable", diff --git a/api/src/opentrons/hardware_control/protocols/instrument_configurer.py b/api/src/opentrons/hardware_control/protocols/instrument_configurer.py index a4ba63c8e56..c1292620b74 100644 --- a/api/src/opentrons/hardware_control/protocols/instrument_configurer.py +++ b/api/src/opentrons/hardware_control/protocols/instrument_configurer.py @@ -142,10 +142,9 @@ def get_instrument_max_height( """ ... - # todo(mm, 2024-10-17): Can this be made non-async? # todo(mm, 2024-10-17): Consider deleting this in favor of cache_tip(), which is # the same except for `assert`s, if we can do so without breaking anything. - async def add_tip(self, mount: MountArgType, tip_length: float) -> None: + def add_tip(self, mount: MountArgType, tip_length: float) -> None: """Inform the hardware that a tip is now attached to a pipette. This changes the critical point of the pipette to make sure that @@ -153,8 +152,10 @@ async def add_tip(self, mount: MountArgType, tip_length: float) -> None: """ ... - # todo(mm, 2024-10-17): Can this be made non-async? - async def remove_tip(self, mount: MountArgType) -> None: + def cache_tip(self, mount: MountArgType, tip_length: float) -> None: + ... + + def remove_tip(self, mount: MountArgType) -> None: """Inform the hardware that a tip is no longer attached to a pipette. This changes the critical point of the system to the end of the 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/execution/tip_handler.py b/api/src/opentrons/protocol_engine/execution/tip_handler.py index 6e496246b8f..0fe2462ee5e 100644 --- a/api/src/opentrons/protocol_engine/execution/tip_handler.py +++ b/api/src/opentrons/protocol_engine/execution/tip_handler.py @@ -276,14 +276,14 @@ async def drop_tip(self, pipette_id: str, home_after: Optional[bool]) -> None: # Allow TipNotAttachedError to propagate. await self.verify_tip_presence(pipette_id, TipPresenceStatus.ABSENT) - await self._hardware_api.remove_tip(hw_mount) + self._hardware_api.remove_tip(hw_mount) self._hardware_api.set_current_tiprack_diameter(hw_mount, 0) async def add_tip(self, pipette_id: str, tip: TipGeometry) -> None: """See documentation on abstract base class.""" hw_mount = self._state_view.pipettes.get_mount(pipette_id).to_hw_mount() - await self._hardware_api.add_tip(mount=hw_mount, tip_length=tip.length) + self._hardware_api.add_tip(mount=hw_mount, tip_length=tip.length) self._hardware_api.set_current_tiprack_diameter( mount=hw_mount, 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/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/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_moves.py b/api/tests/opentrons/hardware_control/test_moves.py index 32bab459f88..cca9e166324 100644 --- a/api/tests/opentrons/hardware_control/test_moves.py +++ b/api/tests/opentrons/hardware_control/test_moves.py @@ -491,7 +491,7 @@ async def test_shake_during_drop( }, } await hardware_api.cache_instruments() - await hardware_api.add_tip(types.Mount.RIGHT, 50.0) + hardware_api.add_tip(types.Mount.RIGHT, 50.0) hardware_api.set_current_tiprack_diameter(types.Mount.RIGHT, 2.0 * 4) shake_tips_drop = mock.Mock(side_effect=hardware_api._shake_off_tips_drop) @@ -515,7 +515,7 @@ async def test_shake_during_drop( # Test drop tip shake with 25% of tiprack well diameter # over upper (2.25 mm) limit - await hardware_api.add_tip(types.Mount.RIGHT, 20) + hardware_api.add_tip(types.Mount.RIGHT, 20) hardware_api.set_current_tiprack_diameter(types.Mount.RIGHT, 2.3 * 4) shake_tips_drop.reset_mock() move_rel.reset_move() @@ -530,7 +530,7 @@ async def test_shake_during_drop( # Test drop tip shake with 25% of tiprack well diameter # below lower (1.0 mm) limit - await hardware_api.add_tip(types.Mount.RIGHT, 50) + hardware_api.add_tip(types.Mount.RIGHT, 50) hardware_api.set_current_tiprack_diameter(types.Mount.RIGHT, 0.9 * 4) shake_tips_drop.reset_mock() move_rel.reset_mock() diff --git a/api/tests/opentrons/hardware_control/test_ot3_api.py b/api/tests/opentrons/hardware_control/test_ot3_api.py index 08ecb3afa43..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 @@ -812,7 +809,7 @@ async def test_liquid_probe( pipette = ot3_hardware.hardware_pipettes[mount.to_mount()] assert pipette - await ot3_hardware.add_tip(mount, 100) + ot3_hardware.add_tip(mount, 100) await ot3_hardware.home() mock_move_to.return_value = None @@ -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( @@ -905,7 +899,7 @@ async def test_liquid_probe_plunger_moves( pipette = ot3_hardware.hardware_pipettes[mount.to_mount()] assert pipette - await ot3_hardware.add_tip(mount, 100) + ot3_hardware.add_tip(mount, 100) await ot3_hardware.home() mock_move_to.return_value = None @@ -1012,7 +1006,7 @@ async def test_liquid_probe_mount_moves( pipette = ot3_hardware.hardware_pipettes[mount.to_mount()] assert pipette - await ot3_hardware.add_tip(mount, 100) + ot3_hardware.add_tip(mount, 100) await ot3_hardware.home() mock_move_to.return_value = None @@ -1073,7 +1067,7 @@ async def test_multi_liquid_probe( await ot3_hardware.cache_pipette(OT3Mount.LEFT, instr_data, None) pipette = ot3_hardware.hardware_pipettes[OT3Mount.LEFT.to_mount()] assert pipette - await ot3_hardware.add_tip(OT3Mount.LEFT, 100) + ot3_hardware.add_tip(OT3Mount.LEFT, 100) await ot3_hardware.home() mock_move_to.return_value = None @@ -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 @@ -1142,7 +1133,7 @@ async def test_liquid_not_found( await ot3_hardware.cache_pipette(OT3Mount.LEFT, instr_data, None) pipette = ot3_hardware.hardware_pipettes[OT3Mount.LEFT.to_mount()] assert pipette - await ot3_hardware.add_tip(OT3Mount.LEFT, 100) + ot3_hardware.add_tip(OT3Mount.LEFT, 100) await ot3_hardware.home() await ot3_hardware.move_to(OT3Mount.LEFT, Point(10, 10, 10)) @@ -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) @@ -1633,7 +1621,7 @@ async def test_prepare_for_aspirate( await ot3_hardware.cache_pipette(mount, instr_data, None) assert ot3_hardware.hardware_pipettes[mount.to_mount()] - await ot3_hardware.add_tip(mount, 100) + ot3_hardware.add_tip(mount, 100) await ot3_hardware.prepare_for_aspirate(OT3Mount.LEFT) mock_move_to_plunger_bottom.assert_called_once_with(OT3Mount.LEFT, 1.0) @@ -1668,7 +1656,7 @@ async def test_plunger_ready_to_aspirate_after_dispense( await ot3_hardware.cache_pipette(mount, instr_data, None) assert ot3_hardware.hardware_pipettes[mount.to_mount()] - await ot3_hardware.add_tip(mount, 100) + ot3_hardware.add_tip(mount, 100) await ot3_hardware.prepare_for_aspirate(OT3Mount.LEFT) assert ot3_hardware.hardware_pipettes[mount.to_mount()].ready_to_aspirate @@ -1729,7 +1717,7 @@ async def test_move_to_plunger_bottom( # tip attached, moving DOWN towards "bottom" position await ot3_hardware.home() - await ot3_hardware.add_tip(mount, 100) + ot3_hardware.add_tip(mount, 100) mock_move.reset_mock() await ot3_hardware.prepare_for_aspirate(mount) # make sure we've done the backlash compensation 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/api/tests/opentrons/protocol_engine/execution/test_tip_handler.py b/api/tests/opentrons/protocol_engine/execution/test_tip_handler.py index b80e11241e6..af5c49faf6a 100644 --- a/api/tests/opentrons/protocol_engine/execution/test_tip_handler.py +++ b/api/tests/opentrons/protocol_engine/execution/test_tip_handler.py @@ -258,7 +258,7 @@ async def test_drop_tip( decoy.verify( await mock_hardware_api.tip_drop_moves(mount=Mount.RIGHT, home_after=True) ) - decoy.verify(await mock_hardware_api.remove_tip(mount=Mount.RIGHT)) + decoy.verify(mock_hardware_api.remove_tip(mount=Mount.RIGHT)) decoy.verify( mock_hardware_api.set_current_tiprack_diameter( mount=Mount.RIGHT, tiprack_diameter=0 @@ -292,7 +292,7 @@ async def test_add_tip( await subject.add_tip(pipette_id="pipette-id", tip=tip) decoy.verify( - await mock_hardware_api.add_tip(mount=Mount.LEFT, tip_length=50), + mock_hardware_api.add_tip(mount=Mount.LEFT, tip_length=50), mock_hardware_api.set_current_tiprack_diameter( mount=Mount.LEFT, tiprack_diameter=5, 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/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/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/index.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/index.tsx index b9641fcc96b..c6d33879be9 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/index.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/index.tsx @@ -103,6 +103,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} /> { + 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..a00335f6475 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 { getLabwareDefinitionsFromCommands } from '/app/local-resources/labware' import { renderWithProviders } from '/app/__testing-utils__' import { i18n } from '/app/i18n' @@ -33,7 +33,7 @@ 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('/app/local-resources/labware') vi.mock('react-redux', async () => { const actual = await vi.importActual('react-redux') return { diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.tsx similarity index 80% rename from app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.ts rename to app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.tsx index a98818b6efd..ab12a0e7280 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 } 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.currentLoc}`}
+
{`New Loc: ${displayLocation.newLoc}`}
+ + ) +} + +const render = (props: ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + describe('useRelevantFailedLwLocations', () => { const mockProtocolAnalysis = {} as any - const mockAllRunDefs = [] as any const mockFailedLabware = { - location: { slot: 'D1' }, + location: { slotName: 'D1' }, } as any it('should return current location for non-moveLabware commands', () => { @@ -172,41 +191,31 @@ describe('useRelevantFailedLwLocations', () => { commandType: 'aspirate', } as any - const { result } = renderHook(() => - useRelevantFailedLwLocations({ - failedLabware: mockFailedLabware, - failedCommandByRunRecord: mockFailedCommand, - protocolAnalysis: mockProtocolAnalysis, - allRunDefs: mockAllRunDefs, - }) - ) - - expect(result.current).toEqual({ - currentLoc: '', - newLoc: null, + render({ + failedLabware: mockFailedLabware, + failedCommandByRunRecord: mockFailedCommand, + protocolAnalysis: mockProtocolAnalysis, }) + + screen.getByText('Current Loc: Slot D1') + screen.getByText('New Loc: null') }) it('should return current and new location for moveLabware commands', () => { const mockFailedCommand = { commandType: 'moveLabware', params: { - newLocation: { slot: 'C2' }, + newLocation: { slotName: 'C2' }, }, } as any - const { result } = renderHook(() => - useRelevantFailedLwLocations({ - failedLabware: mockFailedLabware, - failedCommandByRunRecord: mockFailedCommand, - protocolAnalysis: mockProtocolAnalysis, - allRunDefs: mockAllRunDefs, - }) - ) - - expect(result.current).toEqual({ - currentLoc: '', - newLoc: '', + render({ + failedLabware: mockFailedLabware, + failedCommandByRunRecord: mockFailedCommand, + protocolAnalysis: mockProtocolAnalysis, }) + + screen.getByText('Current Loc: Slot D1') + screen.getByText('New Loc: Slot 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/useERUtils.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts index 365bf01de36..ee8c4de6b83 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts @@ -151,7 +151,6 @@ export function useERUtils({ failedPipetteInfo, runRecord, runCommands, - allRunDefs, }) const recoveryCommands = useRecoveryCommands({ diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts index ba86e77c553..239cb6f9e3d 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 { @@ -25,8 +26,8 @@ import type { DispenseRunTimeCommand, LiquidProbeRunTimeCommand, MoveLabwareRunTimeCommand, - LabwareLocation, } from '@opentrons/shared-data' +import type { LabwareDisplayLocationSlotOnly } from '/app/local-resources/labware' import type { ErrorRecoveryFlowsProps } from '..' import type { ERUtilsProps } from './useERUtils' @@ -34,7 +35,6 @@ interface UseFailedLabwareUtilsProps { failedCommandByRunRecord: ERUtilsProps['failedCommandByRunRecord'] protocolAnalysis: ErrorRecoveryFlowsProps['protocolAnalysis'] failedPipetteInfo: PipetteData | null - allRunDefs: LabwareDefinition2[] runCommands?: CommandsData runRecord?: Run } @@ -68,7 +68,6 @@ export function useFailedLabwareUtils({ failedPipetteInfo, runCommands, runRecord, - allRunDefs, }: UseFailedLabwareUtilsProps): UseFailedLabwareUtilsResult { const recentRelevantFailedLabwareCmd = useMemo( () => @@ -105,7 +104,6 @@ export function useFailedLabwareUtils({ failedLabware, failedCommandByRunRecord, protocolAnalysis, - allRunDefs, }) return { @@ -281,7 +279,7 @@ export function getFailedCmdRelevantLabware( const labwareNickname = protocolAnalysis != null ? getLoadedLabware( - protocolAnalysis, + protocolAnalysis.labware, recentRelevantFailedLabwareCmd?.params.labwareId || '' )?.displayName ?? null : null @@ -336,9 +334,9 @@ export function getRelevantWellName( } } -type GetRelevantLwLocationsParams = Pick< +export type GetRelevantLwLocationsParams = Pick< UseFailedLabwareUtilsProps, - 'protocolAnalysis' | 'failedCommandByRunRecord' | 'allRunDefs' + 'protocolAnalysis' | 'failedCommandByRunRecord' > & { failedLabware: UseFailedLabwareUtilsResult['failedLabware'] } @@ -347,42 +345,40 @@ export function useRelevantFailedLwLocations({ failedLabware, failedCommandByRunRecord, protocolAnalysis, - allRunDefs, }: 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: protocolAnalysis?.labware ?? [], + loadedModules: protocolAnalysis?.modules ?? [], + robotType: FLEX_ROBOT_TYPE, + t, + detailLevel: 'slot-only', + isOnDevice: false, // Always return the "slot XYZ" copy, which is the desktop copy. + } + + const currentLocation = getLabwareDisplayLocation({ + ...BASE_DISPLAY_PARAMS, + location: failedLabware?.location ?? null, + }) - const newLocation = useMemo(() => { + const getNewLocation = (): string | null => { switch (failedCommandByRunRecord?.commandType) { case 'moveLabware': - return buildLocationCopy(failedCommandByRunRecord.params.newLocation) + return getLabwareDisplayLocation({ + ...BASE_DISPLAY_PARAMS, + location: failedCommandByRunRecord.params.newLocation, + }) default: return null } - }, [canGetDisplayLocation, failedCommandByRunRecord?.key]) + } return { currentLoc: currentLocation, - newLoc: newLocation, + newLoc: 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..2bd26beb747 100644 --- a/app/src/organisms/ErrorRecoveryFlows/index.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/index.tsx @@ -17,6 +17,7 @@ import { OT2_ROBOT_TYPE } from '@opentrons/shared-data' import { useHost } from '@opentrons/react-api-client' import { getIsOnDevice } from '/app/redux/config' +import { getLabwareDefinitionsFromCommands } from '/app/local-resources/labware' import { ErrorRecoveryWizard, useERWizard } from './ErrorRecoveryWizard' import { RecoverySplash, useRecoverySplash } from './RecoverySplash' import { RecoveryTakeover } from './RecoveryTakeover' @@ -30,7 +31,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, diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/RecoveryInterventionModal.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/RecoveryInterventionModal.tsx index 631c7c0c962..ccfaa8376ba 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/RecoveryInterventionModal.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/RecoveryInterventionModal.tsx @@ -4,6 +4,7 @@ import { css } from 'styled-components' import { Flex, + OVERFLOW_AUTO, OVERFLOW_HIDDEN, RESPONSIVENESS, SPACING, @@ -56,19 +57,21 @@ const SMALL_MODAL_STYLE = css` height: 22rem; padding: ${SPACING.spacing32}; width: 100%; - overflow: ${OVERFLOW_HIDDEN}; + overflow-y: ${OVERFLOW_AUTO}; @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { padding: ${SPACING.spacing32}; height: 100%; + overflow: ${OVERFLOW_HIDDEN}; } ` const LARGE_MODAL_STYLE = css` height: 26.75rem; width: 100%; - overflow: ${OVERFLOW_HIDDEN}; + overflow-y: ${OVERFLOW_AUTO}; @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { height: 100%; + overflow: ${OVERFLOW_HIDDEN}; } ` 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/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/ODD/RunningProtocol/index.tsx b/app/src/pages/ODD/RunningProtocol/index.tsx index b75284386b8..4c63302564e 100644 --- a/app/src/pages/ODD/RunningProtocol/index.tsx +++ b/app/src/pages/ODD/RunningProtocol/index.tsx @@ -57,7 +57,7 @@ import { useErrorRecoveryFlows, ErrorRecoveryFlows, } from '/app/organisms/ErrorRecoveryFlows' -import { getLabwareDefinitionsFromCommands } from '/app/molecules/Command' +import { getLabwareDefinitionsFromCommands } from '/app/local-resources/labware' import type { OnDeviceRouteParams } from '/app/App/types' 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/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/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/hardware-testing/hardware_testing/examples/capacitive_probe_ot3.py b/hardware-testing/hardware_testing/examples/capacitive_probe_ot3.py index 241309de41a..e0306a25779 100644 --- a/hardware-testing/hardware_testing/examples/capacitive_probe_ot3.py +++ b/hardware-testing/hardware_testing/examples/capacitive_probe_ot3.py @@ -2,7 +2,7 @@ import argparse import asyncio -from opentrons.config.types import CapacitivePassSettings, OutputOptions +from opentrons.config.types import CapacitivePassSettings from opentrons.hardware_control.ot3api import OT3API from hardware_testing.opentrons_api import types @@ -44,14 +44,12 @@ max_overrun_distance_mm=3, speed_mm_per_s=1, sensor_threshold_pf=STABLE_CAP_PF, - output_option=OutputOptions.sync_only, ) PROBE_SETTINGS_XY_AXIS = CapacitivePassSettings( prep_distance_mm=CUTOUT_SIZE / 2, max_overrun_distance_mm=3, speed_mm_per_s=1, sensor_threshold_pf=STABLE_CAP_PF, - output_option=OutputOptions.sync_only, ) @@ -143,7 +141,7 @@ async def _main(is_simulating: bool, cycles: int, stable: bool) -> None: raise RuntimeError("No pipette attached") # add length to the pipette, to account for the attached probe - await api.add_tip(mount, PROBE_LENGTH) + api.add_tip(mount, PROBE_LENGTH) await helpers_ot3.home_ot3(api) for c in range(cycles): @@ -154,7 +152,7 @@ async def _main(is_simulating: bool, cycles: int, stable: bool) -> None: z_ax = types.Axis.by_mount(mount) top_z = helpers_ot3.get_endstop_position_ot3(api, mount)[z_ax] await api.move_to(mount, ASSUMED_XY_LOCATION._replace(z=top_z)) - await api.remove_tip(mount) + api.remove_tip(mount) await api.disengage_axes([types.Axis.X, types.Axis.Y]) diff --git a/hardware-testing/hardware_testing/examples/capacitive_probe_ot3_tunable.py b/hardware-testing/hardware_testing/examples/capacitive_probe_ot3_tunable.py index 2b4496e0eb2..0fe1f693246 100644 --- a/hardware-testing/hardware_testing/examples/capacitive_probe_ot3_tunable.py +++ b/hardware-testing/hardware_testing/examples/capacitive_probe_ot3_tunable.py @@ -2,9 +2,8 @@ import argparse import asyncio -from opentrons.config.types import CapacitivePassSettings, OutputOptions +from opentrons.config.types import CapacitivePassSettings from opentrons.hardware_control.ot3api import OT3API -from opentrons.hardware_control.types import InstrumentProbeType from hardware_testing.opentrons_api import types from hardware_testing.opentrons_api import helpers_ot3 @@ -46,15 +45,12 @@ max_overrun_distance_mm=3, speed_mm_per_s=1, sensor_threshold_pf=CAP_REL_THRESHOLD_PF, - output_option=OutputOptions.sync_only, ) PROBE_SETTINGS_Z_AXIS_OUTPUT = CapacitivePassSettings( prep_distance_mm=10, max_overrun_distance_mm=3, speed_mm_per_s=1, sensor_threshold_pf=CAP_REL_THRESHOLD_PF, - output_option=OutputOptions.sync_buffer_to_csv, - data_files={InstrumentProbeType.PRIMARY: "/data/capacitive_sensor_data.csv"}, ) @@ -85,7 +81,7 @@ async def _main(is_simulating: bool, cycles: int, stable: bool) -> None: raise RuntimeError("No pipette attached") # add length to the pipette, to account for the attached probe - await api.add_tip(mount, PROBE_LENGTH) + api.add_tip(mount, PROBE_LENGTH) await helpers_ot3.home_ot3(api) for c in range(cycles): @@ -96,7 +92,7 @@ async def _main(is_simulating: bool, cycles: int, stable: bool) -> None: z_ax = types.Axis.by_mount(mount) top_z = helpers_ot3.get_endstop_position_ot3(api, mount)[z_ax] await api.move_to(mount, ASSUMED_XY_LOCATION._replace(z=top_z)) - await api.remove_tip(mount) + api.remove_tip(mount) await api.disengage_axes([types.Axis.X, types.Axis.Y]) diff --git a/hardware-testing/hardware_testing/examples/plunger_ot3.py b/hardware-testing/hardware_testing/examples/plunger_ot3.py index da66a50fb76..bbae8725fad 100644 --- a/hardware-testing/hardware_testing/examples/plunger_ot3.py +++ b/hardware-testing/hardware_testing/examples/plunger_ot3.py @@ -19,14 +19,14 @@ async def _main(is_simulating: bool) -> None: await api.home_plunger(mount) # move the plunger based on volume (aspirate/dispense) - await api.add_tip(mount, tip_length=10) + api.add_tip(mount, tip_length=10) await api.prepare_for_aspirate(mount) max_vol = pipette.working_volume for vol in [max_vol, max_vol / 2, max_vol / 10]: await api.aspirate(mount, volume=vol) await api.dispense(mount, volume=vol) await api.prepare_for_aspirate(mount) - await api.remove_tip(mount) + api.remove_tip(mount) # move the plunger based on position (millimeters) plunger_poses = helpers_ot3.get_plunger_positions_ot3(api, mount) diff --git a/hardware-testing/hardware_testing/gravimetric/config.py b/hardware-testing/hardware_testing/gravimetric/config.py index b783908d5e6..304087748d1 100644 --- a/hardware-testing/hardware_testing/gravimetric/config.py +++ b/hardware-testing/hardware_testing/gravimetric/config.py @@ -3,9 +3,8 @@ from typing import List, Dict, Tuple from typing_extensions import Final from enum import Enum -from opentrons.config.types import LiquidProbeSettings, OutputOptions +from opentrons.config.types import LiquidProbeSettings from opentrons.protocol_api.labware import Well -from opentrons.hardware_control.types import InstrumentProbeType class ConfigType(Enum): @@ -170,13 +169,11 @@ def _get_liquid_probe_settings( plunger_speed=lqid_cfg["plunger_speed"], plunger_impulse_time=0.2, sensor_threshold_pascals=lqid_cfg["sensor_threshold_pascals"], - output_option=OutputOptions.sync_only, aspirate_while_sensing=False, z_overlap_between_passes_mm=0.1, plunger_reset_offset=2.0, samples_for_baselining=20, sample_time_sec=0.004, - data_files={InstrumentProbeType.PRIMARY: "/data/testing_data/pressure.csv"}, ) diff --git a/hardware-testing/hardware_testing/gravimetric/helpers.py b/hardware-testing/hardware_testing/gravimetric/helpers.py index b3533a002b0..fdeb8fa636e 100644 --- a/hardware-testing/hardware_testing/gravimetric/helpers.py +++ b/hardware-testing/hardware_testing/gravimetric/helpers.py @@ -36,11 +36,7 @@ import opentrons.protocol_engine.execution.pipetting as PE_pipetting from opentrons.protocol_engine.notes import CommandNoteAdder -from opentrons.protocol_engine import ( - StateView, - WellLocation, - DropTipWellLocation, -) +from opentrons.protocol_engine import StateView from opentrons.protocol_api.core.engine import pipette_movement_conflict @@ -267,7 +263,7 @@ def _override_check_safe_for_pipette_movement( pipette_id: str, labware_id: str, well_name: str, - well_location: Union[WellLocation, DropTipWellLocation], + well_location: object, ) -> None: pass diff --git a/hardware-testing/hardware_testing/liquid_sense/execute.py b/hardware-testing/hardware_testing/liquid_sense/execute.py index 01cb0d27375..001abdaa82f 100644 --- a/hardware-testing/hardware_testing/liquid_sense/execute.py +++ b/hardware-testing/hardware_testing/liquid_sense/execute.py @@ -4,7 +4,7 @@ from enum import Enum from typing import Dict, Any, List, Tuple, Optional from .report import store_tip_results, store_trial, store_baseline_trial -from opentrons.config.types import LiquidProbeSettings, OutputOptions +from opentrons.config.types import LiquidProbeSettings from .__main__ import RunArgs from hardware_testing.gravimetric.workarounds import get_sync_hw_api from hardware_testing.gravimetric.helpers import ( @@ -445,13 +445,11 @@ def _run_trial( plunger_speed=plunger_speed, plunger_impulse_time=0.2, sensor_threshold_pascals=lqid_cfg["sensor_threshold_pascals"], - output_option=OutputOptions.sync_buffer_to_csv, aspirate_while_sensing=run_args.aspirate, z_overlap_between_passes_mm=0.1, plunger_reset_offset=2.0, samples_for_baselining=20, sample_time_sec=0.004, - data_files=data_files, ) hw_mount = OT3Mount.LEFT if run_args.pipette.mount == "left" else OT3Mount.RIGHT diff --git a/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/test_capacitance.py b/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/test_capacitance.py index f3146d54f74..795d78863be 100644 --- a/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/test_capacitance.py +++ b/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/test_capacitance.py @@ -145,7 +145,7 @@ async def run(api: OT3API, report: CSVReport, section: str) -> None: # ATTACHED-pF if not api.is_simulator: ui.get_user_ready(f"ATTACH probe to {probe.name} channel") - await api.add_tip(OT3Mount.LEFT, api.config.calibration.probe_length) + api.add_tip(OT3Mount.LEFT, api.config.calibration.probe_length) attached_pf = await _read_from_sensor(api, sensor_id, 10) if not attached_pf: ui.print_error(f"{probe} cap sensor not working, skipping") @@ -229,4 +229,4 @@ async def _probe(distance: float, speed: float) -> float: await api.home_z(OT3Mount.LEFT) if not api.is_simulator: ui.get_user_ready("REMOVE probe") - await api.remove_tip(OT3Mount.LEFT) + api.remove_tip(OT3Mount.LEFT) diff --git a/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/test_droplets.py b/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/test_droplets.py index a61b1b1e2f6..dc81f62eeb9 100644 --- a/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/test_droplets.py +++ b/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/test_droplets.py @@ -144,7 +144,7 @@ async def _drop_tip(api: OT3API, trash: Point) -> None: # NOTE: a FW bug (as of v14) will sometimes not fully drop tips. # so here we ask if the operator needs to try again while not api.is_simulator and ui.get_user_answer("try dropping again"): - await api.add_tip(OT3Mount.LEFT, helpers_ot3.get_default_tip_length(TIP_VOLUME)) + api.add_tip(OT3Mount.LEFT, helpers_ot3.get_default_tip_length(TIP_VOLUME)) await api.drop_tip(OT3Mount.LEFT) await api.home_z(OT3Mount.LEFT) @@ -172,7 +172,7 @@ async def _partial_pick_up(api: OT3API, position: Point, current: float) -> None safe_height=position.z + 10, ) await _partial_pick_up_z_motion(api, current=current, distance=13, speed=5) - await api.add_tip(OT3Mount.LEFT, helpers_ot3.get_default_tip_length(TIP_VOLUME)) + api.add_tip(OT3Mount.LEFT, helpers_ot3.get_default_tip_length(TIP_VOLUME)) await api.prepare_for_aspirate(OT3Mount.LEFT) await api.home_z(OT3Mount.LEFT) diff --git a/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/test_pressure.py b/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/test_pressure.py index cca8ab3a42d..a73f64ef729 100644 --- a/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/test_pressure.py +++ b/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/test_pressure.py @@ -119,7 +119,7 @@ async def run(api: OT3API, report: CSVReport, section: str) -> None: # SEALED-Pa sealed_pa = 0.0 - await api.add_tip(OT3Mount.LEFT, helpers_ot3.get_default_tip_length(TIP_VOLUME)) + api.add_tip(OT3Mount.LEFT, helpers_ot3.get_default_tip_length(TIP_VOLUME)) await api.prepare_for_aspirate(OT3Mount.LEFT) if not api.is_simulator: ui.get_user_ready(f"attach {TIP_VOLUME} uL TIP to {probe.name} sensor") @@ -171,4 +171,4 @@ async def run(api: OT3API, report: CSVReport, section: str) -> None: if not api.is_simulator: ui.get_user_ready("REMOVE tip") - await api.remove_tip(OT3Mount.LEFT) + api.remove_tip(OT3Mount.LEFT) diff --git a/hardware-testing/hardware_testing/production_qc/pipette_assembly_qc_ot3/__main__.py b/hardware-testing/hardware_testing/production_qc/pipette_assembly_qc_ot3/__main__.py index 28c86520d15..90637e81540 100644 --- a/hardware-testing/hardware_testing/production_qc/pipette_assembly_qc_ot3/__main__.py +++ b/hardware-testing/hardware_testing/production_qc/pipette_assembly_qc_ot3/__main__.py @@ -18,7 +18,7 @@ from opentrons_hardware.firmware_bindings.messages.messages import MessageDefinition from opentrons_hardware.firmware_bindings.constants import SensorType, SensorId -from opentrons.config.types import LiquidProbeSettings, OutputOptions +from opentrons.config.types import LiquidProbeSettings from opentrons.hardware_control.types import ( TipStateType, FailedTipStateCheck, @@ -981,7 +981,7 @@ async def _read_cap(_sensor_id: SensorId) -> float: probe_pos += Point(13, 13, 0) if sensor_id == SensorId.S1: probe_pos += Point(x=0, y=9 * 7, z=0) - await api.add_tip(mount, api.config.calibration.probe_length) + api.add_tip(mount, api.config.calibration.probe_length) print(f"Moving to: {probe_pos}") # start probe 5mm above deck _probe_start_mm = probe_pos.z + 5 @@ -1013,7 +1013,7 @@ async def _read_cap(_sensor_id: SensorId) -> float: await api.home_z(mount) if not api.is_simulator: _get_operator_answer_to_question('REMOVE the probe, enter "y" when removed') - await api.remove_tip(mount) + api.remove_tip(mount) return all(results) @@ -1029,9 +1029,9 @@ async def _test_diagnostics_pressure( sensor_ids = [SensorId.S0] if pip.channels == 8: sensor_ids.append(SensorId.S1) - await api.add_tip(mount, 0.1) + api.add_tip(mount, 0.1) await api.prepare_for_aspirate(mount) - await api.remove_tip(mount) + api.remove_tip(mount) async def _read_pressure(_sensor_id: SensorId) -> float: return await _read_pipette_sensor_repeatedly_and_average( @@ -1378,13 +1378,11 @@ async def _test_liquid_probe( plunger_speed=probe_cfg.plunger_speed, plunger_impulse_time=0.2, sensor_threshold_pascals=probe_cfg.sensor_threshold_pascals, - output_option=OutputOptions.can_bus_only, # FIXME: remove aspirate_while_sensing=False, z_overlap_between_passes_mm=0.1, plunger_reset_offset=2.0, samples_for_baselining=20, sample_time_sec=0.004, - data_files=None, ) end_z = await api.liquid_probe( mount, max_z_distance_machine_coords, probe_settings, probe=probe diff --git a/hardware-testing/hardware_testing/production_qc/robot_assembly_qc_ot3/test_instruments.py b/hardware-testing/hardware_testing/production_qc/robot_assembly_qc_ot3/test_instruments.py index 3301c1d7ab0..45c1a7cc9c3 100644 --- a/hardware-testing/hardware_testing/production_qc/robot_assembly_qc_ot3/test_instruments.py +++ b/hardware-testing/hardware_testing/production_qc/robot_assembly_qc_ot3/test_instruments.py @@ -1,7 +1,7 @@ """Test Instruments.""" from typing import List, Tuple, Optional, Union -from opentrons.config.types import CapacitivePassSettings, OutputOptions +from opentrons.config.types import CapacitivePassSettings from opentrons.hardware_control.ot3api import OT3API from hardware_testing.data.csv_report import ( @@ -30,7 +30,6 @@ max_overrun_distance_mm=0, speed_mm_per_s=Z_PROBE_DISTANCE_MM / Z_PROBE_TIME_SECONDS, sensor_threshold_pf=1.0, - output_option=OutputOptions.can_bus_only, ) RELATIVE_MOVE_FROM_HOME_DELTA = Point(x=-500, y=-300) @@ -101,7 +100,7 @@ async def _probe_mount_and_record_result( ui.get_user_ready(f"attach {probe.name} calibration probe") api.add_gripper_probe(probe) else: - await api.add_tip(mount, 0.1) + api.add_tip(mount, 0.1) # probe downwards pos = await api.gantry_position(mount) @@ -131,7 +130,7 @@ async def _probe_mount_and_record_result( api.remove_gripper_probe() await api.ungrip() else: - await api.remove_tip(mount) + api.remove_tip(mount) async def _test_pipette( diff --git a/hardware-testing/hardware_testing/scripts/ABRAsairScript.py b/hardware-testing/hardware_testing/scripts/ABRAsairScript.py index 2324e330dc7..8eea871b9a3 100644 --- a/hardware-testing/hardware_testing/scripts/ABRAsairScript.py +++ b/hardware-testing/hardware_testing/scripts/ABRAsairScript.py @@ -3,7 +3,7 @@ import paramiko as pmk import time import multiprocessing -from typing import Optional, List +from typing import Optional, List, Any def execute(client: pmk.SSHClient, command: str, args: list) -> Optional[int]: @@ -15,19 +15,8 @@ def execute(client: pmk.SSHClient, command: str, args: list) -> Optional[int]: stdin, stdout, stderr = client.exec_command(command, get_pty=True) stdout_lines: List[str] = [] stderr_lines: List[str] = [] - time.sleep(15) + time.sleep(25) - # check stdout, stderr - - # Check the exit status of the command. - # while not stdout.channel.exit_status_ready(): - # if stdout.channel.recv_ready(): - # stdout_lines = stdout.readlines() - # print(f"{args[0]} output:", "".join(stdout_lines)) - # if stderr.channel.recv_ready(): - # stderr_lines = stderr.readlines() - # print(f"{args[0]} ERROR:", "".join(stdout_lines)) - # return 1 if stderr.channel.recv_ready: stderr_lines = stderr.readlines() if stderr_lines != []: @@ -58,24 +47,9 @@ def connect_ssh(ip: str) -> pmk.SSHClient: return client -# Load Robot IPs -file_name = sys.argv[1] -robot_ips = [] -robot_names = [] - -with open(file_name) as file: - for line in file.readlines(): - info = line.split(",") - if "Y" in info[2]: - robot_ips.append(info[0]) - robot_names.append(info[1]) - -cmd = "nohup python3 -m hardware_testing.scripts.abr_asair_sensor {name} {duration} {frequency}" -cd = "cd /opt/opentrons-robot-server && " -print("Executing Script on All Robots:") - - -def run_command_on_ip(index: int) -> None: +def run_command_on_ip( + index: int, robot_ips: List[str], robot_names: List[str], cd: str, cmd: str +) -> None: """Execute ssh command and start abr_asair script on the specified robot.""" curr_ip = robot_ips[index] try: @@ -87,15 +61,35 @@ def run_command_on_ip(index: int) -> None: print(f"Error running command on {curr_ip}: {e}") -# Launch the processes for each robot. -processes = [] -for index in range(len(robot_ips)): - process = multiprocessing.Process(target=run_command_on_ip, args=(index,)) - processes.append(process) +def run(file_name: str) -> List[Any]: + """Run asair script module.""" + # Load Robot IPs + cmd = "nohup python3 -m hardware_testing.scripts.abr_asair_sensor {name} {duration} {frequency}" + cd = "cd /opt/opentrons-robot-server && " + robot_ips = [] + robot_names = [] + with open(file_name) as file: + for line in file.readlines(): + info = line.split(",") + if "Y" in info[2]: + robot_ips.append(info[0]) + robot_names.append(info[1]) + print("Executing Script on All Robots:") + # Launch the processes for each robot. + processes = [] + for index in range(len(robot_ips)): + process = multiprocessing.Process( + target=run_command_on_ip, args=(index, robot_ips, robot_names, cd, cmd) + ) + processes.append(process) + return processes if __name__ == "__main__": # Wait for all processes to finish. + file_name = sys.argv[1] + processes = run(file_name) + for process in processes: process.start() time.sleep(20) diff --git a/hardware-testing/hardware_testing/scripts/abr_asair_sensor.py b/hardware-testing/hardware_testing/scripts/abr_asair_sensor.py index 1e8fca0358c..ba41f9399f1 100644 --- a/hardware-testing/hardware_testing/scripts/abr_asair_sensor.py +++ b/hardware-testing/hardware_testing/scripts/abr_asair_sensor.py @@ -80,7 +80,7 @@ def __init__(self, robot: str, duration: int, frequency: int) -> None: break # write to google sheet try: - if google_sheet.creditals.access_token_expired: + if google_sheet.credentials.access_token_expired: google_sheet.gc.login() google_sheet.write_header(header) google_sheet.update_row_index() diff --git a/hardware-testing/hardware_testing/scripts/gripper_ot3.py b/hardware-testing/hardware_testing/scripts/gripper_ot3.py index 6c64d84105d..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), @@ -287,7 +286,7 @@ async def _probe_labware_corners( ) -> List[float]: nominal_corners = _calculate_probe_positions(slot, labware_key, deck_item) await api.home([types.Axis.by_mount(PROBE_MOUNT)]) - await api.add_tip(PROBE_MOUNT, api.config.calibration.probe_length) + api.add_tip(PROBE_MOUNT, api.config.calibration.probe_length) found_heights: List[float] = list() for corner in nominal_corners: current_pos = await api.gantry_position(PROBE_MOUNT) @@ -300,7 +299,7 @@ async def _probe_labware_corners( ) found_heights.append(found_z) await api.home([types.Axis.by_mount(PROBE_MOUNT)]) - await api.remove_tip(PROBE_MOUNT) + api.remove_tip(PROBE_MOUNT) print(f'\tLabware Corners ("{deck_item}" at slot {slot})') print(f"\t\tTop-Left = {found_heights[0]}") print(f"\t\tTop-Right = {found_heights[1]}") diff --git a/hardware-testing/hardware_testing/scripts/manual_calibration_ot3.py b/hardware-testing/hardware_testing/scripts/manual_calibration_ot3.py index 0e5373be720..d3052bcbed9 100644 --- a/hardware-testing/hardware_testing/scripts/manual_calibration_ot3.py +++ b/hardware-testing/hardware_testing/scripts/manual_calibration_ot3.py @@ -374,9 +374,9 @@ async def _init_deck_and_pipette_coordinates( if mount != OT3Mount.GRIPPER: # do this early on, so that all coordinates are using the probe's length if short_probe: - await api.add_tip(mount, helpers_ot3.CALIBRATION_PROBE_EVT.length - 10) + api.add_tip(mount, helpers_ot3.CALIBRATION_PROBE_EVT.length - 10) else: - await api.add_tip(mount, helpers_ot3.CALIBRATION_PROBE_EVT.length) + api.add_tip(mount, helpers_ot3.CALIBRATION_PROBE_EVT.length) return calibration_square_pos 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/opentrons-ai-client/src/assets/localization/en/protocol_generator.json b/opentrons-ai-client/src/assets/localization/en/protocol_generator.json index f44eff34e73..2088e495482 100644 --- a/opentrons-ai-client/src/assets/localization/en/protocol_generator.json +++ b/opentrons-ai-client/src/assets/localization/en/protocol_generator.json @@ -1,4 +1,5 @@ { + "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.", @@ -15,6 +16,7 @@ "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)", @@ -34,5 +36,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/Header/Header.stories.tsx b/opentrons-ai-client/src/molecules/Header/Header.stories.tsx new file mode 100644 index 00000000000..d451ee2d355 --- /dev/null +++ b/opentrons-ai-client/src/molecules/Header/Header.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { Header as HeaderComponent } from '.' +import { COLORS, Flex, SPACING } from '@opentrons/components' + +const meta: Meta = { + title: 'AI/Molecules/Header', + component: HeaderComponent, + decorators: [ + Story => ( + + + + ), + ], +} +export default meta + +type Story = StoryObj + +export const ChatHeaderExample: Story = {} diff --git a/opentrons-ai-client/src/molecules/Header/__tests__/Header.test.tsx b/opentrons-ai-client/src/molecules/Header/__tests__/Header.test.tsx new file mode 100644 index 00000000000..31f3b01e629 --- /dev/null +++ b/opentrons-ai-client/src/molecules/Header/__tests__/Header.test.tsx @@ -0,0 +1,23 @@ +import { renderWithProviders } from '../../../__testing-utils__' +import { i18n } from '../../../i18n' +import { Header } from '../index' +import { describe, it } from 'vitest' +import { screen } from '@testing-library/react' + +const render = (): ReturnType => { + return renderWithProviders(
, { + i18nInstance: i18n, + }) +} + +describe('Header', () => { + it('should render Header component', () => { + render() + screen.getByText('Opentrons') + }) + + it('should render log out button', () => { + render() + screen.getByText('Logout') + }) +}) diff --git a/opentrons-ai-client/src/molecules/Header/index.tsx b/opentrons-ai-client/src/molecules/Header/index.tsx new file mode 100644 index 00000000000..e909aeaf691 --- /dev/null +++ b/opentrons-ai-client/src/molecules/Header/index.tsx @@ -0,0 +1,63 @@ +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +import { + Flex, + StyledText, + Link as LinkButton, + POSITION_ABSOLUTE, + TYPOGRAPHY, + COLORS, + POSITION_RELATIVE, + ALIGN_CENTER, + JUSTIFY_SPACE_BETWEEN, +} from '@opentrons/components' +import { useAuth0 } from '@auth0/auth0-react' + +const HeaderBar = styled(Flex)` + position: ${POSITION_RELATIVE}; + background-color: ${COLORS.white}; + width: 100%; + align-items: ${ALIGN_CENTER}; + height: 60px; +` + +const HeaderBarContent = styled(Flex)` + position: ${POSITION_ABSOLUTE}; + padding: 18px 32px; + justify-content: ${JUSTIFY_SPACE_BETWEEN}; + width: 100%; +` + +const HeaderGradientTitle = styled(StyledText)` + background: linear-gradient(90deg, #562566 0%, #893ba4 47.5%, #c189d4 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + font-size: 16px; +` + +const HeaderTitle = styled(StyledText)` + font-size: 16px; +` + +const LogoutButton = styled(LinkButton)` + color: ${COLORS.grey50}; + font-size: ${TYPOGRAPHY.fontSizeH3}; +` + +export function Header(): JSX.Element { + const { t } = useTranslation('protocol_generator') + const { logout } = useAuth0() + + return ( + + + + {t('opentrons')} + {t('ai')} + + logout()}>{t('logout')} + + + ) +} diff --git a/opentrons-ai-client/src/molecules/PromptPreview/PromptPreview.stories.tsx b/opentrons-ai-client/src/molecules/PromptPreview/PromptPreview.stories.tsx new file mode 100644 index 00000000000..79e7b822dcc --- /dev/null +++ b/opentrons-ai-client/src/molecules/PromptPreview/PromptPreview.stories.tsx @@ -0,0 +1,84 @@ +import { I18nextProvider } from 'react-i18next' +import { COLORS, Flex, SPACING } from '@opentrons/components' +import { i18n } from '../../i18n' +import type { Meta, StoryObj } from '@storybook/react' +import { PromptPreview } from '.' + +const meta: Meta = { + title: 'AI/molecules/PromptPreview', + component: PromptPreview, + decorators: [ + Story => ( + + + + + + ), + ], +} +export default meta +type Story = StoryObj + +export const PromptPreviewExample: Story = { + args: { + isSubmitButtonEnabled: false, + handleSubmit: () => { + alert('Submit button clicked') + }, + promptPreviewData: [ + { + title: 'Application', + items: [ + 'Cherrypicking', + 'I have a Chlorine Reagent Set (Total), Ultra Low Range', + ], + }, + { + title: 'Instruments', + items: [ + 'Opentrons Flex', + 'Flex 1-Channel 50 uL', + 'Flex 8-Channel 1000 uL', + ], + }, + { + title: 'Modules', + items: [ + 'Thermocycler GEN2', + 'Heater-Shaker with Universal Flat Adaptor', + ], + }, + { + title: 'Labware and Liquids', + items: [ + 'Opentrons 96 Well Plate', + 'Thermocycler GEN2', + 'Opentrons 96 Deep Well Plate', + 'Liquid 1: In commodo lectus nec erat commodo blandit. Etiam leo dui, porttitor vel imperdiet sed, tristique nec nisl. Maecenas pulvinar sapien quis sodales imperdiet.', + 'Liquid 2: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + ], + }, + { + title: 'Steps', + items: [ + 'Fill the first column of a Elisa plate with 100 uL of Liquid 1', + 'Fill the second column of a Elisa plate with 100 uL of Liquid 2', + ], + }, + ], + }, +} + +export const PromptPreviewPlaceholderMessage: Story = { + args: { + isSubmitButtonEnabled: false, + handleSubmit: () => { + alert('Submit button clicked') + }, + }, +} diff --git a/opentrons-ai-client/src/molecules/PromptPreview/__tests__/PromptPreview.test.tsx b/opentrons-ai-client/src/molecules/PromptPreview/__tests__/PromptPreview.test.tsx new file mode 100644 index 00000000000..ab7d69543ba --- /dev/null +++ b/opentrons-ai-client/src/molecules/PromptPreview/__tests__/PromptPreview.test.tsx @@ -0,0 +1,109 @@ +import { screen } from '@testing-library/react' +import { describe, it, vi, beforeEach, expect } from 'vitest' +import { renderWithProviders } from '../../../__testing-utils__' +import { i18n } from '../../../i18n' +import { PromptPreview } from '..' + +const 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.' + +const mockHandleClick = vi.fn() + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('PromptPreview', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + isSubmitButtonEnabled: false, + handleSubmit: () => { + mockHandleClick() + }, + promptPreviewData: [ + { + title: 'Test Section 1', + items: ['item1', 'item2'], + }, + { + title: 'Test Section 2', + items: ['item3', 'item4'], + }, + ], + } + }) + + it('should render the PromptPreview component', () => { + render(props) + + expect(screen.getByText('Prompt')).toBeInTheDocument() + }) + + it('should render the submit button', () => { + render(props) + + expect(screen.getByText('Submit prompt')).toBeInTheDocument() + }) + + it('should render the placeholder message when all sections are empty', () => { + props.promptPreviewData = [ + { + title: 'Test Section 1', + items: [], + }, + { + title: 'Test Section 2', + items: [], + }, + ] + render(props) + + expect( + screen.getByText(PROMPT_PREVIEW_PLACEHOLDER_MESSAGE) + ).toBeInTheDocument() + }) + + it('should not render the placeholder message when at least one section has items', () => { + render(props) + + expect( + screen.queryByText(PROMPT_PREVIEW_PLACEHOLDER_MESSAGE) + ).not.toBeInTheDocument() + }) + + it('should render the sections with items', () => { + render(props) + + expect(screen.getByText('Test Section 1')).toBeInTheDocument() + expect(screen.getByText('Test Section 2')).toBeInTheDocument() + }) + + it('should display submit button disabled when isSubmitButtonEnabled is false', () => { + render(props) + + expect(screen.getByRole('button', { name: 'Submit prompt' })).toBeDisabled() + }) + + it('should display submit button enabled when isSubmitButtonEnabled is true', () => { + props.isSubmitButtonEnabled = true + render(props) + + expect( + screen.getByRole('button', { name: 'Submit prompt' }) + ).not.toBeDisabled() + }) + + it('should call handleSubmit when the submit button is clicked', () => { + props.isSubmitButtonEnabled = true + render(props) + + const submitButton = screen.getByRole('button', { name: 'Submit prompt' }) + submitButton.click() + + expect(mockHandleClick).toHaveBeenCalled() + }) +}) diff --git a/opentrons-ai-client/src/molecules/PromptPreview/index.tsx b/opentrons-ai-client/src/molecules/PromptPreview/index.tsx new file mode 100644 index 00000000000..b789cfbb4c7 --- /dev/null +++ b/opentrons-ai-client/src/molecules/PromptPreview/index.tsx @@ -0,0 +1,86 @@ +import styled from 'styled-components' +import { + Flex, + StyledText, + LargeButton, + COLORS, + JUSTIFY_SPACE_BETWEEN, + DIRECTION_COLUMN, + SIZE_AUTO, + DIRECTION_ROW, + ALIGN_CENTER, + SPACING, +} from '@opentrons/components' +import { PromptPreviewSection } from '../PromptPreviewSection' +import type { PromptPreviewSectionProps } from '../PromptPreviewSection' +import { useTranslation } from 'react-i18next' + +interface PromptPreviewProps { + isSubmitButtonEnabled?: boolean + handleSubmit: () => void + promptPreviewData: PromptPreviewSectionProps[] +} + +const PromptPreviewContainer = styled(Flex)` + flex-direction: ${DIRECTION_COLUMN}; + width: 100%; + height: ${SIZE_AUTO}; + padding-top: ${SPACING.spacing8}; + background-color: ${COLORS.transparent}; +` + +const PromptPreviewHeading = styled(Flex)` + flex-direction: ${DIRECTION_ROW}; + justify-content: ${JUSTIFY_SPACE_BETWEEN}; + align-items: ${ALIGN_CENTER}; + margin-bottom: ${SPACING.spacing16}; +` + +const PromptPreviewPlaceholderMessage = styled(StyledText)` + padding: 82px 73px; + color: ${COLORS.grey60}; + text-align: ${ALIGN_CENTER}; +` + +export function PromptPreview({ + isSubmitButtonEnabled = false, + handleSubmit, + promptPreviewData = [], +}: PromptPreviewProps): JSX.Element { + const { t } = useTranslation('protocol_generator') + + const areAllSectionsEmpty = (): boolean => { + return promptPreviewData.every(section => section.items.length === 0) + } + + return ( + + + Prompt + + + + {areAllSectionsEmpty() && ( + + {t('prompt_preview_placeholder_message')} + + )} + + {Object.values(promptPreviewData).map( + (section, index) => + section.items.length > 0 && ( + + ) + )} + + ) +} diff --git a/opentrons-ai-client/src/molecules/PromptPreviewSection/__tests__/PromptPreviewSection.test.tsx b/opentrons-ai-client/src/molecules/PromptPreviewSection/__tests__/PromptPreviewSection.test.tsx new file mode 100644 index 00000000000..e194bae5a8e --- /dev/null +++ b/opentrons-ai-client/src/molecules/PromptPreviewSection/__tests__/PromptPreviewSection.test.tsx @@ -0,0 +1,60 @@ +import type * as React from 'react' +import { screen } from '@testing-library/react' +import { describe, it, beforeEach, expect } from 'vitest' +import { renderWithProviders } from '../../../__testing-utils__' +import { i18n } from '../../../i18n' + +import { PromptPreviewSection } from '../index' + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('PromptPreviewSection', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + title: 'Test Section', + items: ['test item 1', 'test item 2'], + } + }) + + it('should render the PromptPreviewSection component', () => { + render(props) + + expect(screen.getByText('Test Section')).toBeInTheDocument() + }) + + it('should render the section title', () => { + render(props) + + expect(screen.getByText('Test Section')).toBeInTheDocument() + }) + + it('should render the items', () => { + render(props) + + expect(screen.getByText('test item 1')).toBeInTheDocument() + expect(screen.getByText('test item 2')).toBeInTheDocument() + }) + + it("should not render the item tag if it's an empty string", () => { + props.items = ['test item 1', ''] + render(props) + + const items = screen.getAllByTestId('Tag_default') + expect(items).toHaveLength(1) + }) + + it('should render the item with the correct max item width', () => { + props.items = ['test item 1 long text long text long text long text'] + props.itemMaxWidth = '23%' + render(props) + + const item = screen.getByTestId('item-tag-wrapper-0') + expect(item).toHaveStyle({ maxWidth: '23%' }) + }) +}) diff --git a/opentrons-ai-client/src/molecules/PromptPreviewSection/index.tsx b/opentrons-ai-client/src/molecules/PromptPreviewSection/index.tsx new file mode 100644 index 00000000000..c781e0308d7 --- /dev/null +++ b/opentrons-ai-client/src/molecules/PromptPreviewSection/index.tsx @@ -0,0 +1,74 @@ +import styled from 'styled-components' +import { + Flex, + StyledText, + Tag, + DIRECTION_COLUMN, + WRAP, + SPACING, +} from '@opentrons/components' + +export interface PromptPreviewSectionProps { + title: string + items: string[] + itemMaxWidth?: string +} + +const PromptPreviewSectionContainer = styled(Flex)` + flex-direction: ${DIRECTION_COLUMN}; + margin-top: ${SPACING.spacing32}; +` + +const SectionHeading = styled(StyledText)` + margin-bottom: ${SPACING.spacing8}; +` + +const TagsContainer = styled(Flex)` + grid-gap: ${SPACING.spacing4}; + flex-wrap: ${WRAP}; + justify-content: flex-start; + width: 100%; +` + +const TagItemWrapper = styled.div<{ itemMaxWidth: string }>` + display: flex; + width: auto; + white-space: nowrap; + overflow: hidden; + max-width: ${props => props.itemMaxWidth}; + + & > div { + overflow: hidden; + + > p { + overflow: hidden; + text-overflow: ellipsis; + } + } +` + +export function PromptPreviewSection({ + title, + items, + itemMaxWidth = '35%', +}: PromptPreviewSectionProps): JSX.Element { + return ( + + {title} + + {items.map( + (item: string, index: number) => + item.trim() !== '' && ( + + + + ) + )} + + + ) +} diff --git a/protocol-designer/cypress/e2e/batchEdit.cy.js b/protocol-designer/cypress/e2e/batchEdit.cy.js index 300983ad9b0..8bd7d284287 100644 --- a/protocol-designer/cypress/e2e/batchEdit.cy.js +++ b/protocol-designer/cypress/e2e/batchEdit.cy.js @@ -76,7 +76,7 @@ describe('Batch Edit Transform', () => { // Delete the duplicated steps cy.get('#ClickableIcon_delete').click() - cy.get('button').contains('delete steps').click() + cy.get('button').contains('Delete steps').click() cy.get('#StepSelectionBannerComponent_numberStepsSelected') .contains('1 steps selected') .should('exist') diff --git a/protocol-designer/src/assets/localization/en/alert.json b/protocol-designer/src/assets/localization/en/alert.json index e07431bf188..b8f73cc290b 100644 --- a/protocol-designer/src/assets/localization/en/alert.json +++ b/protocol-designer/src/assets/localization/en/alert.json @@ -259,7 +259,11 @@ "no_commands": { "heading": "Your protocol has no steps", "body1": "This protocol has no steps in it- there's nothing for the robot to do! Before trying to run this on your robot add at least one step between your Starting Deck State and Final Deck State.", - "body2": "Learn more about building steps " + "body2": "Learn more about building steps ", + "redesign": { + "heading": "Protocol has no steps", + "body": "This protocol has no steps. Before trying to run this protocol on your robot, add at least one step." + } }, "unused_pipette_and_module": { "heading": "Unused pipette and module", diff --git a/protocol-designer/src/assets/localization/en/feature_flags.json b/protocol-designer/src/assets/localization/en/feature_flags.json index f83f09e345c..a70e23931c9 100644 --- a/protocol-designer/src/assets/localization/en/feature_flags.json +++ b/protocol-designer/src/assets/localization/en/feature_flags.json @@ -20,10 +20,6 @@ "title": "Enable redesign", "description": "A whole new world." }, - "OT_PD_ENABLE_MOAM": { - "title": "Enable multiple modules", - "description": "Enable multiple heater-shakers and magnetic blocks for Flex only." - }, "OT_PD_ENABLE_COMMENT": { "title": "Enable comment step", "description": "You can add comments anywhere between timeline steps." diff --git a/protocol-designer/src/assets/localization/en/modal.json b/protocol-designer/src/assets/localization/en/modal.json index 1115b90a18b..ebf4e0d9b80 100644 --- a/protocol-designer/src/assets/localization/en/modal.json +++ b/protocol-designer/src/assets/localization/en/modal.json @@ -255,7 +255,14 @@ "body2": "Build a pause later if you want your protocol to proceed to the next steps while the temperature module ramps up to {{temperature}}°C.", "heater_shaker_pause_later": "Build a pause later if you want your protocol to proceed to the next steps while the Heater-Shaker module goes to {{temperature}}°C", "now_button": "Pause protocol now", - "later_button": "I will build a pause later" + "later_button": "I will build a pause later", + "redesign": { + "title": "Pause protocol until {{module}} is at {{temp}}˚C", + "body1": "Build a pause step to wait until {{module}} reaches {{temp}}˚C before continuing to the next step.", + "body2": "Build a pause step later if you want your protocol to proceed to the next step while the {{module}} goes to {{temp}}˚C", + "build_pause_later": "Build pause later", + "pause_protocol": "Pause protocol" + } }, "step_notes": { "title": "Step Notes" diff --git a/protocol-designer/src/assets/localization/en/protocol_steps.json b/protocol-designer/src/assets/localization/en/protocol_steps.json index dfc3e0c6345..42ccaa9c15b 100644 --- a/protocol-designer/src/assets/localization/en/protocol_steps.json +++ b/protocol-designer/src/assets/localization/en/protocol_steps.json @@ -27,6 +27,7 @@ "from": "from", "heater_shaker": { "active": { + "ambient": "ambient", "latch": "Latch", "shake": "Shaking at", "temperature": "{{module}}set to", @@ -49,7 +50,7 @@ "mix_times": "Mix repititions", "mix_volume": "Mix volume", "mix": "Mix", - "mix_step": "Mixing{{times}} times in{{labware}}", + "mix_step": "Mixing{{times}} times in{{wells}} of {{labware}}", "mix_repetitions": "Mix repetitions", "module": "Module", "move_labware": { @@ -57,9 +58,9 @@ "no_gripper": "Move{{labware}}to" }, "move_liquid": { - "consolidate": "Consolidatingfrom{{source}}to{{destination}}", - "distribute": "Distributingfrom{{source}}to{{destination}}", - "transfer": "Transferringfrom{{source}}to{{destination}}" + "consolidate": "Consolidatingfrom{{sourceWells}} of {{source}}to{{destinationWells}} of {{destination}}", + "distribute": "Distributingfrom{{sourceWells}} of {{source}}to{{destinationWells}} of {{destination}}", + "transfer": "Transferringfrom{{sourceWells}} of {{source}}to{{destinationWells}} of {{destination}}" }, "multi_dispense_options": "Distribute options", "multiAspirate": "Consolidate path", diff --git a/protocol-designer/src/assets/localization/en/shared.json b/protocol-designer/src/assets/localization/en/shared.json index 51525de47d6..737d92cc952 100644 --- a/protocol-designer/src/assets/localization/en/shared.json +++ b/protocol-designer/src/assets/localization/en/shared.json @@ -125,7 +125,7 @@ "warning": "WARNING:", "wasteChute": "Waste chute", "wasteChuteAndStagingArea": "Waste chute and staging area slot", - "we_are_improving": "We’re working to improve Protocol Designer. Part of the process involves watching real user sessions to understand which parts of the interface are working and which could use improvement. We never share sessions outside of Opentrons.", + "we_are_improving": "In order to improve our products, Opentrons would like to collect data related to your use of Protocol Designer. With your consent, Opentrons will collect and store analytics and session data, including through the use of cookies and similar technologies, solely for the purpose enhancing our products. Find detailed information in our privacy policy. By using Protocol Designer, you consent to the Opentrons EULA.", "welcome": "Welcome to Protocol Designer!", "yes": "Yes" } diff --git a/protocol-designer/src/pages/CreateNewProtocolWizard/HandleEnter.tsx b/protocol-designer/src/atoms/HandleEnter/index.tsx similarity index 87% rename from protocol-designer/src/pages/CreateNewProtocolWizard/HandleEnter.tsx rename to protocol-designer/src/atoms/HandleEnter/index.tsx index 5729f8ceb05..4fc99026ca7 100644 --- a/protocol-designer/src/pages/CreateNewProtocolWizard/HandleEnter.tsx +++ b/protocol-designer/src/atoms/HandleEnter/index.tsx @@ -1,8 +1,8 @@ -import type * as React from 'react' import { HandleKeypress } from '@opentrons/components' +import type { ReactNode } from 'react' interface HandleEnterProps { - children: React.ReactNode + children: ReactNode onEnter: () => void } diff --git a/protocol-designer/src/components/EditModules.tsx b/protocol-designer/src/components/EditModules.tsx index 6ed7c8a6054..31b392c29a5 100644 --- a/protocol-designer/src/components/EditModules.tsx +++ b/protocol-designer/src/components/EditModules.tsx @@ -12,7 +12,6 @@ import { } from '../step-forms' import { moveDeckItem } from '../labware-ingred/actions/actions' import { getRobotType } from '../file-data/selectors' -import { getEnableMoam } from '../feature-flags/selectors' import { EditMultipleModulesModal } from './modals/EditModulesModal/EditMultipleModulesModal' import { useBlockingHint } from './Hints/useBlockingHint' import { MagneticModuleWarningModalContent } from './modals/EditModulesModal/MagneticModuleWarningModalContent' @@ -34,14 +33,15 @@ export interface ModelModuleInfo { export const EditModules = (props: EditModulesProps): JSX.Element => { const { onCloseClick, moduleToEdit } = props - const enableMoam = useSelector(getEnableMoam) const { moduleId, moduleType } = moduleToEdit const _initialDeckSetup = useSelector(stepFormSelectors.getInitialDeckSetup) const robotType = useSelector(getRobotType) - const MOAM_MODULE_TYPES: ModuleType[] = enableMoam - ? [TEMPERATURE_MODULE_TYPE, HEATERSHAKER_MODULE_TYPE, MAGNETIC_BLOCK_TYPE] - : [TEMPERATURE_MODULE_TYPE] + const MOAM_MODULE_TYPES: ModuleType[] = [ + TEMPERATURE_MODULE_TYPE, + HEATERSHAKER_MODULE_TYPE, + MAGNETIC_BLOCK_TYPE, + ] const showMultipleModuleModal = robotType === FLEX_ROBOT_TYPE && MOAM_MODULE_TYPES.includes(moduleType) diff --git a/protocol-designer/src/components/StepEditForm/index.tsx b/protocol-designer/src/components/StepEditForm/index.tsx index 738d86a2ed8..8b54c56c891 100644 --- a/protocol-designer/src/components/StepEditForm/index.tsx +++ b/protocol-designer/src/components/StepEditForm/index.tsx @@ -2,6 +2,12 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import { connect } from 'react-redux' import { useConditionalConfirm } from '@opentrons/components' +import { + getModuleDisplayName, + HEATERSHAKER_MODULE_TYPE, + TEMPERATURE_MODULE_TYPE, +} from '@opentrons/shared-data' + import { actions } from '../../steplist' import { actions as stepsActions } from '../../ui/steps' import { resetScrollElements } from '../../ui/steps/utils' @@ -12,7 +18,6 @@ import { import { maskField } from '../../steplist/fieldLevel' import { getInvariantContext } from '../../step-forms/selectors' import { AutoAddPauseUntilTempStepModal } from '../modals/AutoAddPauseUntilTempStepModal' -import { AutoAddPauseUntilHeaterShakerTempStepModal } from '../modals/AutoAddPauseUntilHeaterShakerTempStepModal' import { ConfirmDeleteModal, DELETE_STEP_FORM, @@ -166,20 +171,31 @@ const StepEditFormManager = ( onContinueClick={confirmClose} /> )} - {showAddPauseUntilTempStepModal && ( + {showAddPauseUntilTempStepModal || + showAddPauseUntilHeaterShakerTempStepModal ? ( - )} - {showAddPauseUntilHeaterShakerTempStepModal && ( - - )} + ) : null} { labware: {}, additionalEquipmentOnDeck: {}, }) - vi.mocked(getEnableMoam).mockReturnValue(true) vi.mocked(EditModulesModal).mockReturnValue(
mock EditModulesModal
) diff --git a/protocol-designer/src/components/modals/AutoAddPauseUntilHeaterShakerTempStepModal.tsx b/protocol-designer/src/components/modals/AutoAddPauseUntilHeaterShakerTempStepModal.tsx deleted file mode 100644 index c630f7be3e9..00000000000 --- a/protocol-designer/src/components/modals/AutoAddPauseUntilHeaterShakerTempStepModal.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { useTranslation } from 'react-i18next' -import { - AlertModal, - OutlineButton, - DeprecatedPrimaryButton, -} from '@opentrons/components' -import modalStyles from './modal.module.css' -import styles from './AutoAddPauseUntilTempStepModal.module.css' - -interface Props { - displayTemperature: string - handleCancelClick: () => unknown - handleContinueClick: () => unknown -} - -export const AutoAddPauseUntilHeaterShakerTempStepModal = ( - props: Props -): JSX.Element => { - const { t } = useTranslation('modal') - return ( - -
- {t('auto_add_pause_until_temp_step.heater_shaker_title', { - temperature: props.displayTemperature, - })} -
-

- {t('auto_add_pause_until_temp_step.body1', { - temperature: props.displayTemperature, - })} -

-

- {t('auto_add_pause_until_temp_step.heater_shaker_pause_later', { - temperature: props.displayTemperature, - })} -

-
- - {t('auto_add_pause_until_temp_step.later_button')} - - - {t('auto_add_pause_until_temp_step.now_button')} - -
-
- ) -} diff --git a/protocol-designer/src/components/modals/AutoAddPauseUntilTempStepModal.tsx b/protocol-designer/src/components/modals/AutoAddPauseUntilTempStepModal.tsx index 399e4c76d05..f15af16d347 100644 --- a/protocol-designer/src/components/modals/AutoAddPauseUntilTempStepModal.tsx +++ b/protocol-designer/src/components/modals/AutoAddPauseUntilTempStepModal.tsx @@ -1,55 +1,165 @@ import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' import { AlertModal, - OutlineButton, + ALIGN_FLEX_END, + COLORS, DeprecatedPrimaryButton, + DIRECTION_COLUMN, + Flex, + Icon, + Modal, + OutlineButton, + PrimaryButton, + SecondaryButton, + SPACING, + StyledText, } from '@opentrons/components' +import { TEMPERATURE_MODULE_TYPE } from '@opentrons/shared-data' + +import { getEnableRedesign } from '../../feature-flags/selectors' import modalStyles from './modal.module.css' import styles from './AutoAddPauseUntilTempStepModal.module.css' +import type { ModuleType } from '@opentrons/shared-data' + interface Props { displayTemperature: string - handleCancelClick: () => unknown - handleContinueClick: () => unknown + handleCancelClick: () => void + handleContinueClick: () => void + moduleType: ModuleType + displayModule?: string } export const AutoAddPauseUntilTempStepModal = (props: Props): JSX.Element => { + const { + displayTemperature, + handleCancelClick, + handleContinueClick, + moduleType, + displayModule, + } = props const { t } = useTranslation('modal') - return ( - -
- {t('auto_add_pause_until_temp_step.title', { - temperature: props.displayTemperature, - })} -
-

- {t('auto_add_pause_until_temp_step.body1', { - temperature: props.displayTemperature, - })} -

-

- {t('auto_add_pause_until_temp_step.body2', { - temperature: props.displayTemperature, + const enableRedesign = useSelector(getEnableRedesign) + if (enableRedesign) { + return ( + -

- - {t('auto_add_pause_until_temp_step.later_button')} - - - {t('auto_add_pause_until_temp_step.now_button')} - -
-
- ) + titleElement1={ + + } + childrenPadding={SPACING.spacing24} + footer={ + + + + {t('auto_add_pause_until_temp_step.redesign.build_pause_later')} + + + + + {t('auto_add_pause_until_temp_step.redesign.pause_protocol')} + + + + } + > + + + {t('auto_add_pause_until_temp_step.redesign.body1', { + module: displayModule, + temp: displayTemperature, + })} + + + {t('auto_add_pause_until_temp_step.redesign.body2', { + module: displayModule, + temp: displayTemperature, + })} + + + + ) + } else { + return moduleType === TEMPERATURE_MODULE_TYPE ? ( + +
+ {t('auto_add_pause_until_temp_step.title', { + temperature: displayTemperature, + })} +
+

+ {t('auto_add_pause_until_temp_step.body1', { + temperature: displayTemperature, + })} +

+

+ {t('auto_add_pause_until_temp_step.body2', { + temperature: displayTemperature, + })} +

+
+ + {t('auto_add_pause_until_temp_step.later_button')} + + + {t('auto_add_pause_until_temp_step.now_button')} + +
+
+ ) : ( + +
+ {t('auto_add_pause_until_temp_step.heater_shaker_title', { + temperature: displayTemperature, + })} +
+

+ {t('auto_add_pause_until_temp_step.body1', { + temperature: displayTemperature, + })} +

+

+ {t('auto_add_pause_until_temp_step.heater_shaker_pause_later', { + temperature: displayTemperature, + })} +

+
+ + {t('auto_add_pause_until_temp_step.later_button')} + + + {t('auto_add_pause_until_temp_step.now_button')} + +
+
+ ) + } } diff --git a/protocol-designer/src/components/modals/ConfirmDeleteModal.tsx b/protocol-designer/src/components/modals/ConfirmDeleteModal.tsx index ad614e5ba64..98800e21219 100644 --- a/protocol-designer/src/components/modals/ConfirmDeleteModal.tsx +++ b/protocol-designer/src/components/modals/ConfirmDeleteModal.tsx @@ -1,8 +1,22 @@ import type * as React from 'react' import { createPortal } from 'react-dom' import { useTranslation } from 'react-i18next' -import { AlertModal } from '@opentrons/components' +import { useSelector } from 'react-redux' +import { + ALIGN_FLEX_END, + AlertModal, + AlertPrimaryButton, + COLORS, + Flex, + Icon, + Modal, + SPACING, + SecondaryButton, + StyledText, +} from '@opentrons/components' +import { getEnableRedesign } from '../../feature-flags/selectors' import { getMainPagePortalEl } from '../portals/MainPageModalPortal' +import { getTopPortalEl } from '../portals/TopPortal' import modalStyles from './modal.module.css' export const DELETE_PROFILE_CYCLE: 'deleteProfileCycle' = 'deleteProfileCycle' @@ -31,12 +45,12 @@ interface Props { } export function ConfirmDeleteModal(props: Props): JSX.Element { - const { t } = useTranslation(['modal', 'button']) + const { i18n, t } = useTranslation(['modal', 'button']) const { modalType, onCancelClick, onContinueClick } = props - const cancelCopy = t('button:cancel') - const continueCopy = t( - `confirm_delete_modal.${modalType}.confirm_button`, - t('button:continue') + const cancelCopy = i18n.format(t('button:cancel'), 'capitalize') + const continueCopy = i18n.format( + t(`confirm_delete_modal.${modalType}.confirm_button`, t('button:continue')), + 'capitalize' ) const buttons = [ { title: cancelCopy, children: cancelCopy, onClick: onCancelClick }, @@ -47,17 +61,50 @@ export function ConfirmDeleteModal(props: Props): JSX.Element { onClick: onContinueClick, }, ] - return createPortal( - -

{t(`confirm_delete_modal.${modalType}.body`)}

-
, - getMainPagePortalEl() - ) + const enableRedesign = useSelector(getEnableRedesign) + return enableRedesign + ? createPortal( + + } + footer={ + + + + {cancelCopy} + + + + + {continueCopy} + + + + } + > + + {t(`confirm_delete_modal.${modalType}.body`)} + + , + getTopPortalEl() + ) + : createPortal( + +

{t(`confirm_delete_modal.${modalType}.body`)}

+
, + getMainPagePortalEl() + ) } diff --git a/protocol-designer/src/components/modals/CreateFileWizard/ModulesAndOtherTile.tsx b/protocol-designer/src/components/modals/CreateFileWizard/ModulesAndOtherTile.tsx index 2f63d6667bb..5409217dfd3 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/ModulesAndOtherTile.tsx +++ b/protocol-designer/src/components/modals/CreateFileWizard/ModulesAndOtherTile.tsx @@ -37,7 +37,6 @@ import gripperImage from '../../../assets/images/flex_gripper.png' import wasteChuteImage from '../../../assets/images/waste_chute.png' import trashBinImage from '../../../assets/images/flex_trash_bin.png' import { uuid } from '../../../utils' -import { getEnableMoam } from '../../../feature-flags/selectors' import { selectors as featureFlagSelectors } from '../../../feature-flags' import { CrashInfoBox, ModuleDiagram } from '../../modules' import { ModuleFields } from '../FilePipettesModal/ModuleFields' @@ -202,15 +201,17 @@ export function ModulesAndOtherTile(props: WizardTileProps): JSX.Element { function FlexModuleFields(props: WizardTileProps): JSX.Element { const { watch, setValue } = props - const enableMoam = useSelector(getEnableMoam) const modules = watch('modules') const additionalEquipment = watch('additionalEquipment') const enableAbsorbanceReader = useSelector( featureFlagSelectors.getEnableAbsorbanceReader ) - const MOAM_MODULE_TYPES: ModuleType[] = enableMoam - ? [TEMPERATURE_MODULE_TYPE, HEATERSHAKER_MODULE_TYPE, MAGNETIC_BLOCK_TYPE] - : [TEMPERATURE_MODULE_TYPE] + const MOAM_MODULE_TYPES: ModuleType[] = [ + TEMPERATURE_MODULE_TYPE, + HEATERSHAKER_MODULE_TYPE, + MAGNETIC_BLOCK_TYPE, + ] + const moduleTypesOnDeck = modules != null ? Object.values(modules).map(module => module.type) : [] diff --git a/protocol-designer/src/components/modals/CreateFileWizard/__tests__/ModulesAndOtherTile.test.tsx b/protocol-designer/src/components/modals/CreateFileWizard/__tests__/ModulesAndOtherTile.test.tsx index fdc4e9b86e5..0b4e99952a6 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/__tests__/ModulesAndOtherTile.test.tsx +++ b/protocol-designer/src/components/modals/CreateFileWizard/__tests__/ModulesAndOtherTile.test.tsx @@ -5,10 +5,7 @@ import { fireEvent, screen, cleanup } from '@testing-library/react' import { FLEX_ROBOT_TYPE, OT2_ROBOT_TYPE } from '@opentrons/shared-data' import { renderWithProviders } from '../../../../__testing-utils__' import { i18n } from '../../../../assets/localization' -import { - getDisableModuleRestrictions, - getEnableMoam, -} from '../../../../feature-flags/selectors' +import { getDisableModuleRestrictions } from '../../../../feature-flags/selectors' import { CrashInfoBox } from '../../../modules' import { ModuleFields } from '../../FilePipettesModal/ModuleFields' import { ModulesAndOtherTile } from '../ModulesAndOtherTile' @@ -61,7 +58,6 @@ describe('ModulesAndOtherTile', () => { ...props, ...mockWizardTileProps, } as WizardTileProps - vi.mocked(getEnableMoam).mockReturnValue(true) vi.mocked(CrashInfoBox).mockReturnValue(
mock CrashInfoBox
) vi.mocked(EquipmentOption).mockReturnValue(
mock EquipmentOption
) vi.mocked(getDisableModuleRestrictions).mockReturnValue(false) diff --git a/protocol-designer/src/components/modals/__tests__/AutoAddPauseUntilHeaterShakerTempStepModal.test.tsx b/protocol-designer/src/components/modals/__tests__/AutoAddPauseUntilHeaterShakerTempStepModal.test.tsx deleted file mode 100644 index 41b2becffbc..00000000000 --- a/protocol-designer/src/components/modals/__tests__/AutoAddPauseUntilHeaterShakerTempStepModal.test.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import type * as React from 'react' -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { fireEvent, screen } from '@testing-library/react' -import { renderWithProviders } from '../../../__testing-utils__' -import { i18n } from '../../../assets/localization' -import { AutoAddPauseUntilHeaterShakerTempStepModal } from '../AutoAddPauseUntilHeaterShakerTempStepModal' - -const render = ( - props: React.ComponentProps -) => { - return renderWithProviders( - , - { - i18nInstance: i18n, - } - )[0] -} - -describe('AutoAddPauseUntilHeaterShakerTempStepModal ', () => { - let props: React.ComponentProps< - typeof AutoAddPauseUntilHeaterShakerTempStepModal - > - beforeEach(() => { - props = { - displayTemperature: '10', - handleCancelClick: vi.fn(), - handleContinueClick: vi.fn(), - } - }) - - it('should render the correct text with 10 C temp and buttons are clickable', () => { - render(props) - screen.getByText('Pause protocol until Heater-Shaker module is at 10°C?') - screen.getByText( - 'Pause protocol now to wait until module reaches 10°C before continuing on to the next step.' - ) - screen.getByText( - 'Build a pause later if you want your protocol to proceed to the next steps while the Heater-Shaker module goes to 10°C' - ) - const cancelBtn = screen.getByRole('button', { - name: 'I will build a pause later', - }) - const contBtn = screen.getByRole('button', { name: 'Pause protocol now' }) - fireEvent.click(cancelBtn) - expect(props.handleCancelClick).toHaveBeenCalled() - fireEvent.click(contBtn) - expect(props.handleContinueClick).toHaveBeenCalled() - }) -}) diff --git a/protocol-designer/src/components/modals/__tests__/AutoAddPauseUntilTempStepModal.test.tsx b/protocol-designer/src/components/modals/__tests__/AutoAddPauseUntilTempStepModal.test.tsx index aa8d4601996..8fb3d84c1c0 100644 --- a/protocol-designer/src/components/modals/__tests__/AutoAddPauseUntilTempStepModal.test.tsx +++ b/protocol-designer/src/components/modals/__tests__/AutoAddPauseUntilTempStepModal.test.tsx @@ -4,6 +4,9 @@ import { fireEvent, screen } from '@testing-library/react' import { renderWithProviders } from '../../../__testing-utils__' import { i18n } from '../../../assets/localization' import { AutoAddPauseUntilTempStepModal } from '../AutoAddPauseUntilTempStepModal' +import { TEMPERATURE_MODULE_TYPE } from '@opentrons/shared-data' + +vi.mock('../../../feature-flags/selectors') const render = ( props: React.ComponentProps @@ -20,6 +23,7 @@ describe('AutoAddPauseUntilTempStepModal ', () => { displayTemperature: '10', handleCancelClick: vi.fn(), handleContinueClick: vi.fn(), + moduleType: TEMPERATURE_MODULE_TYPE, } }) it('should render the correct text with 10 C temp and buttons are clickable', () => { diff --git a/protocol-designer/src/feature-flags/reducers.ts b/protocol-designer/src/feature-flags/reducers.ts index bcb586acf9a..1f9999be001 100644 --- a/protocol-designer/src/feature-flags/reducers.ts +++ b/protocol-designer/src/feature-flags/reducers.ts @@ -26,7 +26,6 @@ const initialFlags: Flags = { OT_PD_ENABLE_ABSORBANCE_READER: process.env.OT_PD_ENABLE_ABSORBANCE_READER === '1' || false, OT_PD_ENABLE_REDESIGN: process.env.OT_PD_ENABLE_REDESIGN === '1' || false, - OT_PD_ENABLE_MOAM: process.env.OT_PD_ENABLE_MOAM === '1' || false, OT_PD_ENABLE_COMMENT: process.env.OT_PD_ENABLE_COMMENT === '1' || false, OT_PD_ENABLE_RETURN_TIP: process.env.OT_PD_ENABLE_RETURN_TIP === '1' || false, OT_PD_ENABLE_HOT_KEYS_DISPLAY: diff --git a/protocol-designer/src/feature-flags/selectors.ts b/protocol-designer/src/feature-flags/selectors.ts index a4c3baf05be..896eb5db254 100644 --- a/protocol-designer/src/feature-flags/selectors.ts +++ b/protocol-designer/src/feature-flags/selectors.ts @@ -33,10 +33,6 @@ export const getEnableRedesign: Selector = createSelector( getFeatureFlagData, flags => flags.OT_PD_ENABLE_REDESIGN ?? false ) -export const getEnableMoam: Selector = createSelector( - getFeatureFlagData, - flags => flags.OT_PD_ENABLE_MOAM ?? false -) export const getEnableComment: Selector = createSelector( getFeatureFlagData, flags => flags.OT_PD_ENABLE_COMMENT ?? false diff --git a/protocol-designer/src/feature-flags/types.ts b/protocol-designer/src/feature-flags/types.ts index f37d77bc814..774d2c5fa5e 100644 --- a/protocol-designer/src/feature-flags/types.ts +++ b/protocol-designer/src/feature-flags/types.ts @@ -31,7 +31,6 @@ export type FlagTypes = | 'OT_PD_ALLOW_ALL_TIPRACKS' | 'OT_PD_ENABLE_ABSORBANCE_READER' | 'OT_PD_ENABLE_REDESIGN' - | 'OT_PD_ENABLE_MOAM' | 'OT_PD_ENABLE_COMMENT' | 'OT_PD_ENABLE_RETURN_TIP' | 'OT_PD_ENABLE_HOT_KEYS_DISPLAY' @@ -46,7 +45,6 @@ export const allFlags: FlagTypes[] = [ 'PRERELEASE_MODE', 'OT_PD_ENABLE_ABSORBANCE_READER', 'OT_PD_ENABLE_REDESIGN', - 'OT_PD_ENABLE_MOAM', 'OT_PD_ENABLE_COMMENT', 'OT_PD_ENABLE_RETURN_TIP', ] diff --git a/protocol-designer/src/organisms/DefineLiquidsModal/index.tsx b/protocol-designer/src/organisms/DefineLiquidsModal/index.tsx index b2e0c76bac1..e9943676c35 100644 --- a/protocol-designer/src/organisms/DefineLiquidsModal/index.tsx +++ b/protocol-designer/src/organisms/DefineLiquidsModal/index.tsx @@ -33,6 +33,7 @@ import * as labwareIngredActions from '../../labware-ingred/actions' import { selectors as labwareIngredSelectors } from '../../labware-ingred/selectors' import { swatchColors } from '../../components/swatchColors' import { checkColor } from './utils' +import { HandleEnter } from '../../atoms/HandleEnter' import type { ColorResult, RGBColor } from 'react-color' import type { ThunkDispatch } from 'redux-thunk' @@ -47,7 +48,10 @@ interface LiquidEditFormValues { [key: string]: unknown } -const INVALID_DISPLAY_COLORS = ['#000000', '#ffffff', DEPRECATED_WHALE_GREY] +const BLACK = '#000000' +const WHITE = '#ffffff' + +const INVALID_DISPLAY_COLORS = [BLACK, WHITE, DEPRECATED_WHALE_GREY] const liquidEditFormSchema: any = Yup.object().shape({ name: Yup.string().required('liquid name is required'), @@ -92,8 +96,7 @@ export function DefineLiquidsModal( const allIngredientGroupFields = useSelector( labwareIngredSelectors.allIngredientGroupFields ) - const liquidGroupId = - selectedLiquidGroupState && selectedLiquidGroupState.liquidGroupId + const liquidGroupId = selectedLiquidGroupState.liquidGroupId const deleteLiquidGroup = (): void => { if (liquidGroupId != null) { dispatch(labwareIngredActions.deleteLiquidGroup(liquidGroupId)) @@ -110,7 +113,7 @@ export function DefineLiquidsModal( dispatch( labwareIngredActions.editLiquidGroup({ ...formData, - liquidGroupId: liquidGroupId, + liquidGroupId, }) ) onClose() @@ -159,105 +162,116 @@ export function DefineLiquidsModal( } return ( - - - - {initialValues.name} - - - ) : ( - t('define_liquid') - ) - } - type="info" - onClose={onClose} + { + void handleSubmit(handleLiquidEdits)() + }} > -
- <> - {showColorPicker ? ( - - ( - { - const hex = rgbaToHex(color.rgb) - setValue('displayColor', hex) - field.onChange(hex) - }} - /> - )} - /> + + + + {initialValues.name} + - ) : null} - - - + ) : ( + t('define_liquid') + ) + } + type="info" + onClose={onClose} + > + { + void handleSubmit(handleLiquidEdits)() + }} + > + <> + {showColorPicker ? ( - - {t('name')} - ( - { + const hex = rgbaToHex(color.rgb) + setValue('displayColor', hex) + field.onChange(hex) + }} /> )} /> - - - {t('description')} - - - - - - {t('display_color')} - + ) : null} - { - setShowColorPicker(prev => !prev) - }} - color={color} - size="medium" - /> - - {/* NOTE: this is for serialization if we decide to add it back */} - {/* + + + + {t('name')} + + ( + + )} + /> + + + + {t('description')} + + + + + + {t('display_color')} + + + { + setShowColorPicker(prev => !prev) + }} + color={color} + size="medium" + /> + + {/* NOTE: this is for serialization if we decide to add it back */} + {/* ( @@ -271,44 +285,45 @@ export function DefineLiquidsModal( /> )} /> */} - - - {selectedIngredFields != null ? ( - - - {t('delete_liquid')} - - - ) : ( - - {t('shared:close')} - - )} - + - {t('shared:save')} - + {selectedIngredFields != null ? ( + + + {t('delete_liquid')} + + + ) : ( + + {t('shared:close')} + + )} + + {t('shared:save')} + + - - -
-
+ + + + ) } diff --git a/protocol-designer/src/organisms/EditInstrumentsModal/index.tsx b/protocol-designer/src/organisms/EditInstrumentsModal/index.tsx index 90ff62c327d..3d131e6305c 100644 --- a/protocol-designer/src/organisms/EditInstrumentsModal/index.tsx +++ b/protocol-designer/src/organisms/EditInstrumentsModal/index.tsx @@ -69,6 +69,7 @@ import { selectors as stepFormSelectors } from '../../step-forms' import { BUTTON_LINK_STYLE } from '../../atoms' import { getSectionsFromPipetteName, getShouldShowPipetteType } from './utils' import { editPipettes } from './editPipettes' +import { HandleEnter } from '../../atoms/HandleEnter' import type { PipetteMount, PipetteName } from '@opentrons/shared-data' import type { @@ -151,422 +152,435 @@ export function EditInstrumentsModal( // if a user removes all pipettes, left mount is the first target. const targetPipetteMount = leftPipette == null ? 'left' : 'right' + const handleOnSave = (): void => { + if (page === 'overview') { + onClose() + } else { + setPage('overview') + editPipettes( + labware, + pipettes, + orderedStepIds, + dispatch, + mount, + selectedPip as PipetteName, + selectedTips, + leftPipette, + rightPipette + ) + } + } + return createPortal( - { - resetFields() - onClose() - }} - footer={ - - { - if (page === 'overview') { - onClose() - } else { - setPage('overview') - resetFields() - } - }} + + { + resetFields() + onClose() + }} + footer={ + - {page === 'overview' ? t('shared:cancel') : t('shared:back')} - - { - if (page === 'overview') { - onClose() - } else { - setPage('overview') - editPipettes( - labware, - pipettes, - orderedStepIds, - dispatch, - mount, - selectedPip as PipetteName, - selectedTips, - leftPipette, - rightPipette - ) + { + if (page === 'overview') { + onClose() + } else { + setPage('overview') + resetFields() + } + }} + > + {page === 'overview' ? t('shared:cancel') : t('shared:back')} + + - {t('shared:save')} - - - } - > - {page === 'overview' ? ( - - - - - {t('your_pipettes')} - - {has96Channel || - (leftPipette == null && rightPipette == null) ? null : ( - - dispatch( - changeSavedStepForm({ - stepId: INITIAL_DECK_SETUP_STEP_ID, - update: { - pipetteLocationUpdate: swapPipetteUpdate, - }, - }) - ) - } - > - - - - {t('swap')} - - - - )} - - - {leftPipette?.tiprackDefURI != null && leftInfo != null ? ( - { - setPage('add') - setMount('left') - setPipetteType(leftInfo.type) - setPipetteGen(leftInfo.gen) - setPipetteVolume(leftInfo.volume) - setSelectedTips(leftPipette.tiprackDefURI as string[]) - }} - cleanForm={() => { - dispatch(deletePipettes([leftPipette.id as string])) - previousLeftPipetteTipracks.forEach(tip => - dispatch(deleteContainer({ labwareId: tip.id })) - ) - }} - /> - ) : null} - {rightPipette?.tiprackDefURI != null && rightInfo != null ? ( - { - setPage('add') - setMount('right') - setPipetteType(rightInfo.type) - setPipetteGen(rightInfo.gen) - setPipetteVolume(rightInfo.volume) - setSelectedTips(rightPipette.tiprackDefURI as string[]) - }} - cleanForm={() => { - dispatch(deletePipettes([rightPipette.id as string])) - previousRightPipetteTipracks.forEach(tip => - dispatch(deleteContainer({ labwareId: tip.id })) - ) - }} - /> - ) : null} - {has96Channel || - (leftPipette != null && rightPipette != null) ? null : ( - { - setPage('add') - setMount(targetPipetteMount) - }} - text={t('add_pipette')} - textAlignment="left" - iconName="plus" - /> - )} - + {t('shared:save')} + - {robotType === FLEX_ROBOT_TYPE ? ( + } + > + {page === 'overview' ? ( + - {t('protocol_overview:your_gripper')} + {t('your_pipettes')} + {has96Channel || + (leftPipette == null && rightPipette == null) ? null : ( + + dispatch( + changeSavedStepForm({ + stepId: INITIAL_DECK_SETUP_STEP_ID, + update: { + pipetteLocationUpdate: swapPipetteUpdate, + }, + }) + ) + } + > + + + + {t('swap')} + + + + )} - {gripper != null ? ( - - - - - {t('protocol_overview:extension')} - - - {t('gripper')} - - - { - dispatch(toggleIsGripperRequired()) - }} - > - - {t('remove')} - - - - - ) : ( + {leftPipette?.tiprackDefURI != null && leftInfo != null ? ( + { + setPage('add') + setMount('left') + setPipetteType(leftInfo.type) + setPipetteGen(leftInfo.gen) + setPipetteVolume(leftInfo.volume) + setSelectedTips(leftPipette.tiprackDefURI as string[]) + }} + cleanForm={() => { + dispatch(deletePipettes([leftPipette.id as string])) + previousLeftPipetteTipracks.forEach(tip => + dispatch(deleteContainer({ labwareId: tip.id })) + ) + }} + /> + ) : null} + {rightPipette?.tiprackDefURI != null && rightInfo != null ? ( + { + setPage('add') + setMount('right') + setPipetteType(rightInfo.type) + setPipetteGen(rightInfo.gen) + setPipetteVolume(rightInfo.volume) + setSelectedTips(rightPipette.tiprackDefURI as string[]) + }} + cleanForm={() => { + dispatch(deletePipettes([rightPipette.id as string])) + previousRightPipetteTipracks.forEach(tip => + dispatch(deleteContainer({ labwareId: tip.id })) + ) + }} + /> + ) : null} + {has96Channel || + (leftPipette != null && rightPipette != null) ? null : ( { - dispatch(toggleIsGripperRequired()) + setPage('add') + setMount(targetPipetteMount) }} - text={t('protocol_overview:add_gripper')} + text={t('add_pipette')} textAlignment="left" iconName="plus" /> )} - ) : null} - - ) : ( - - - - {t('pipette_type')} - - - {PIPETTE_TYPES[robotType].map(type => { - return getShouldShowPipetteType( - type.value as PipetteType, - has96Channel, - leftPipette, - rightPipette, - mount - ) ? ( - { - setPipetteType(type.value) - setPipetteGen('flex') - setPipetteVolume(null) - setSelectedTips([]) - }} - buttonLabel={t(`shared:${type.label}`)} - buttonValue="single" - isSelected={pipetteType === type.value} - /> - ) : null - })} - + {robotType === FLEX_ROBOT_TYPE ? ( + + + + {t('protocol_overview:your_gripper')} + + + + {gripper != null ? ( + + + + + {t('protocol_overview:extension')} + + + {t('gripper')} + + + { + dispatch(toggleIsGripperRequired()) + }} + > + + {t('remove')} + + + + + ) : ( + { + dispatch(toggleIsGripperRequired()) + }} + text={t('protocol_overview:add_gripper')} + textAlignment="left" + iconName="plus" + /> + )} + + + ) : null} - {pipetteType != null && robotType === OT2_ROBOT_TYPE ? ( + ) : ( + - {t('pipette_gen')} + {t('pipette_type')} - {PIPETTE_GENS.map(gen => ( - { - setPipetteGen(gen) - setPipetteVolume(null) - setSelectedTips([]) - }} - buttonLabel={gen} - buttonValue={gen} - isSelected={pipetteGen === gen} - /> - ))} + {PIPETTE_TYPES[robotType].map(type => { + return getShouldShowPipetteType( + type.value as PipetteType, + has96Channel, + leftPipette, + rightPipette, + mount + ) ? ( + { + setPipetteType(type.value) + setPipetteGen('flex') + setPipetteVolume(null) + setSelectedTips([]) + }} + buttonLabel={t(`shared:${type.label}`)} + buttonValue="single" + isSelected={pipetteType === type.value} + /> + ) : null + })} - ) : null} - {(pipetteType != null && robotType === FLEX_ROBOT_TYPE) || - (pipetteGen !== 'flex' && - pipetteType != null && - robotType === OT2_ROBOT_TYPE) ? ( - - - {t('pipette_vol')} - - - {PIPETTE_VOLUMES[robotType]?.map(volume => { - if (robotType === FLEX_ROBOT_TYPE && pipetteType != null) { - const flexVolume = volume as PipetteInfoByType - const flexPipetteInfo = flexVolume[pipetteType] - - return flexPipetteInfo?.map(type => ( - { - setPipetteVolume(type.value) - setSelectedTips([]) - }} - buttonLabel={t('vol_label', { volume: type.label })} - buttonValue={type.value} - isSelected={pipetteVolume === type.value} - /> - )) - } else { - const ot2Volume = volume as PipetteInfoByGen - const gen = pipetteGen as Gen + {pipetteType != null && robotType === OT2_ROBOT_TYPE ? ( + + + {t('pipette_gen')} + + + {PIPETTE_GENS.map(gen => ( + { + setPipetteGen(gen) + setPipetteVolume(null) + setSelectedTips([]) + }} + buttonLabel={gen} + buttonValue={gen} + isSelected={pipetteGen === gen} + /> + ))} + + + ) : null} + {(pipetteType != null && robotType === FLEX_ROBOT_TYPE) || + (pipetteGen !== 'flex' && + pipetteType != null && + robotType === OT2_ROBOT_TYPE) ? ( + + + {t('pipette_vol')} + + + {PIPETTE_VOLUMES[robotType]?.map(volume => { + if (robotType === FLEX_ROBOT_TYPE && pipetteType != null) { + const flexVolume = volume as PipetteInfoByType + const flexPipetteInfo = flexVolume[pipetteType] - return ot2Volume[gen].map(info => { - return info[pipetteType]?.map(type => ( + return flexPipetteInfo?.map(type => ( { setPipetteVolume(type.value) + setSelectedTips([]) }} - buttonLabel={t('vol_label', { - volume: type.label, - })} + buttonLabel={t('vol_label', { volume: type.label })} buttonValue={type.value} isSelected={pipetteVolume === type.value} /> )) - }) - } - })} + } else { + const ot2Volume = volume as PipetteInfoByGen + const gen = pipetteGen as Gen + + return ot2Volume[gen].map(info => { + return info[pipetteType]?.map(type => ( + { + setPipetteVolume(type.value) + }} + buttonLabel={t('vol_label', { + volume: type.label, + })} + buttonValue={type.value} + isSelected={pipetteVolume === type.value} + /> + )) + }) + } + })} + - - ) : null} - {allPipetteOptions.includes(selectedPip as PipetteName) - ? (() => { - const tiprackOptions = getTiprackOptions({ - allLabware, - allowAllTipracks, - selectedPipetteName: selectedPip, - }) - return ( - - - {t('pipette_tips')} - - { + const tiprackOptions = getTiprackOptions({ + allLabware, + allowAllTipracks, + selectedPipetteName: selectedPip, + }) + return ( + - {tiprackOptions.map(option => ( - { - const updatedTips = selectedTips.includes( - option.value - ) - ? selectedTips.filter(v => v !== option.value) - : [...selectedTips, option.value] - setSelectedTips(updatedTips) - }} - /> - ))} - + {t('pipette_tips')} + + - - - {t('add_custom_tips')} - - dispatch(createCustomTiprackDef(e))} - /> - - {pipetteVolume === 'p1000' && - robotType === FLEX_ROBOT_TYPE ? null : ( - ( + { - dispatch( - setFeatureFlags({ - OT_PD_ALLOW_ALL_TIPRACKS: !allowAllTipracks, - }) + const updatedTips = selectedTips.includes( + option.value ) + ? selectedTips.filter(v => v !== option.value) + : [...selectedTips, option.value] + setSelectedTips(updatedTips) }} - textDecoration={TYPOGRAPHY.textDecorationUnderline} - > + /> + ))} + + - {allowAllTipracks - ? t('show_default_tips') - : t('show_all_tips')} + {t('add_custom_tips')} - - )} - - - - ) - })() - : null} - - )} - , + + dispatch(createCustomTiprackDef(e)) + } + /> + + {pipetteVolume === 'p1000' && + robotType === FLEX_ROBOT_TYPE ? null : ( + { + dispatch( + setFeatureFlags({ + OT_PD_ALLOW_ALL_TIPRACKS: !allowAllTipracks, + }) + ) + }} + textDecoration={ + TYPOGRAPHY.textDecorationUnderline + } + > + + {allowAllTipracks + ? t('show_default_tips') + : t('show_all_tips')} + + + )} + + + + ) + })() + : null} + + )} + + , getTopPortalEl() ) } diff --git a/protocol-designer/src/organisms/EditNickNameModal/index.tsx b/protocol-designer/src/organisms/EditNickNameModal/index.tsx index bfead26d352..4b8934ff6c8 100644 --- a/protocol-designer/src/organisms/EditNickNameModal/index.tsx +++ b/protocol-designer/src/organisms/EditNickNameModal/index.tsx @@ -17,6 +17,7 @@ import { import { selectors as uiLabwareSelectors } from '../../ui/labware' import { getTopPortalEl } from '../../components/portals/TopPortal' import { renameLabware } from '../../labware-ingred/actions' +import { HandleEnter } from '../../atoms/HandleEnter' import type { ThunkDispatch } from '../../types' const MAX_NICK_NAME_LENGTH = 115 @@ -37,57 +38,59 @@ export function EditNickNameModal(props: EditNickNameModalProps): JSX.Element { } return createPortal( - + + { + onClose() + }} + > + {t('shared:cancel')} + + = MAX_NICK_NAME_LENGTH} + > + {t('shared:save')} + + + } + > - { - onClose() + + + {t('labware_name')} + + + = MAX_NICK_NAME_LENGTH ? t('rename_error') : null + } + data-testid="renameLabware_inputField" + name="renameLabware" + onChange={e => { + setNickName(e.target.value) }} - > - {t('shared:cancel')} - - = MAX_NICK_NAME_LENGTH} - > - {t('shared:save')} - - - } - > - - - - {t('labware_name')} - + value={nickName} + type="text" + autoFocus + /> - = MAX_NICK_NAME_LENGTH ? t('rename_error') : null - } - data-testid="renameLabware_inputField" - name="renameLabware" - onChange={e => { - setNickName(e.target.value) - }} - value={nickName} - type="text" - autoFocus - /> - - , + + , getTopPortalEl() ) } diff --git a/protocol-designer/src/organisms/IncompatibleTipsModal/index.tsx b/protocol-designer/src/organisms/IncompatibleTipsModal/index.tsx index b509e47ffb0..57dee3964ad 100644 --- a/protocol-designer/src/organisms/IncompatibleTipsModal/index.tsx +++ b/protocol-designer/src/organisms/IncompatibleTipsModal/index.tsx @@ -10,6 +10,8 @@ import { StyledText, } from '@opentrons/components' import { setFeatureFlags } from '../../feature-flags/actions' +import { HandleEnter } from '../../atoms/HandleEnter' + import type { ThunkDispatch } from 'redux-thunk' import type { BaseState } from '../../types' @@ -23,37 +25,41 @@ export function IncompatibleTipsModal( const dispatch = useDispatch>() const { t } = useTranslation(['create_new_protocol', 'shared']) + const handleShowAllTips = (): void => { + onClose() + dispatch( + setFeatureFlags({ + OT_PD_ALLOW_ALL_TIPRACKS: true, + }) + ) + } + return ( - - { - onClose() - dispatch( - setFeatureFlags({ - OT_PD_ALLOW_ALL_TIPRACKS: true, - }) - ) - }} + + - {t('show_tips')} - - {t('shared:cancel')} - - } - > - - {t('incompatible_tip_body')} - - + + {t('show_tips')} + + + {t('shared:cancel')} + + + } + > + + {t('incompatible_tip_body')} + + + ) } diff --git a/protocol-designer/src/organisms/MaterialsListModal/index.tsx b/protocol-designer/src/organisms/MaterialsListModal/index.tsx index 71ca8b5fb2c..0d5c90c1f4b 100644 --- a/protocol-designer/src/organisms/MaterialsListModal/index.tsx +++ b/protocol-designer/src/organisms/MaterialsListModal/index.tsx @@ -31,6 +31,7 @@ import { getRobotType } from '../../file-data/selectors' import { getInitialDeckSetup } from '../../step-forms/selectors' import { getTopPortalEl } from '../../components/portals/TopPortal' import { selectors as labwareIngredSelectors } from '../../labware-ingred/selectors' +import { HandleEnter } from '../../atoms/HandleEnter' import type { AdditionalEquipmentName } from '@opentrons/step-generation' import type { LabwareOnDeck, ModuleOnDeck } from '../../step-forms' @@ -69,212 +70,218 @@ export function MaterialsListModal({ ) const tCSlot = robotType === FLEX_ROBOT_TYPE ? 'A1, B1' : '7,8,10,11' + const handleClose = (): void => { + setShowMaterialsListModal(false) + } + return createPortal( - { - setShowMaterialsListModal(false) - }} - closeOnOutsideClick - title={t('materials_list')} - marginLeft="0rem" - minWidth={MODAL_MIN_WIDTH} - > - - - - {t('deck_hardware')} - - - {fixtures.length > 0 - ? fixtures.map(fixture => ( - - - ) : ( - '' - ) - } - content={ - - - {t(`shared:${fixture.name}`)} - - - } - /> - - )) - : null} - {hardware.length > 0 ? ( - hardware.map((hw, id) => { - const formatLocation = (slot: string): string => { - if (hw.type === THERMOCYCLER_MODULE_TYPE) { - return tCSlot + + + + + + {t('deck_hardware')} + + + {fixtures.length > 0 + ? fixtures.map(fixture => ( + + + ) : ( + '' + ) + } + content={ + + + {t(`shared:${fixture.name}`)} + + + } + /> + + )) + : null} + {hardware.length > 0 ? ( + hardware.map((hw, id) => { + const formatLocation = (slot: string): string => { + if (hw.type === THERMOCYCLER_MODULE_TYPE) { + return tCSlot + } + return slot.replace('cutout', '') } - return slot.replace('cutout', '') - } - return ( - - - } - content={ - - - - {getModuleDisplayName(hw.model)} - - - } - /> - - ) - }) - ) : ( - - )} + return ( + + + } + content={ + + + + {getModuleDisplayName(hw.model)} + + + } + /> + + ) + }) + ) : ( + + )} + - - - - {t('labware')} - - - {labware.length > 0 ? ( - labware.map(lw => { - const labwareOnModuleEntity = Object.values(modulesOnDeck).find( - mod => mod.id === lw.slot - ) - const labwareOnLabwareEntity = Object.values( - labwareOnDeck - ).find(labware => labware.id === lw.slot) - const labwareOnLabwareOnModuleSlot = Object.values( - modulesOnDeck - ).find(mod => mod.id === labwareOnLabwareEntity?.slot)?.slot - const labwareOnLabwareOnSlot = labwareOnLabwareEntity?.slot + + + {t('labware')} + + + {labware.length > 0 ? ( + labware.map(lw => { + const labwareOnModuleEntity = Object.values( + modulesOnDeck + ).find(mod => mod.id === lw.slot) + const labwareOnLabwareEntity = Object.values( + labwareOnDeck + ).find(labware => labware.id === lw.slot) + const labwareOnLabwareOnModuleSlot = Object.values( + modulesOnDeck + ).find(mod => mod.id === labwareOnLabwareEntity?.slot)?.slot + const labwareOnLabwareOnSlot = labwareOnLabwareEntity?.slot - let deckLabelSlot = lw.slot - if (labwareOnModuleEntity != null) { - deckLabelSlot = - labwareOnModuleEntity.type === THERMOCYCLER_MODULE_TYPE - ? tCSlot - : labwareOnModuleEntity.slot - } else if (labwareOnLabwareOnModuleSlot != null) { - deckLabelSlot = labwareOnLabwareOnModuleSlot - } else if (labwareOnLabwareOnSlot != null) { - deckLabelSlot = labwareOnLabwareOnSlot - } else if (deckLabelSlot === 'offDeck') { - deckLabelSlot = 'Off-deck' - } - return ( - - } - content={lw.def.metadata.displayName} - /> - - ) - }) - ) : ( - - )} + let deckLabelSlot = lw.slot + if (labwareOnModuleEntity != null) { + deckLabelSlot = + labwareOnModuleEntity.type === THERMOCYCLER_MODULE_TYPE + ? tCSlot + : labwareOnModuleEntity.slot + } else if (labwareOnLabwareOnModuleSlot != null) { + deckLabelSlot = labwareOnLabwareOnModuleSlot + } else if (labwareOnLabwareOnSlot != null) { + deckLabelSlot = labwareOnLabwareOnSlot + } else if (deckLabelSlot === 'offDeck') { + deckLabelSlot = 'Off-deck' + } + return ( + + + } + content={lw.def.metadata.displayName} + /> + + ) + }) + ) : ( + + )} + - - - - {t('liquids')} - - - {liquids.length > 0 ? ( - - - {t('name')} - - - {t('total_well_volume')} - - - ) : null} - + + + {t('liquids')} + + {liquids.length > 0 ? ( - liquids.map((liquid, id) => { - const volumePerWell = Object.values( - allLabwareWellContents - ).flatMap(labwareWithIngred => - Object.values(labwareWithIngred).map( - ingred => ingred[liquid.ingredientId]?.volume ?? 0 + + + {t('name')} + + + {t('total_well_volume')} + + + ) : null} + + {liquids.length > 0 ? ( + liquids.map((liquid, id) => { + const volumePerWell = Object.values( + allLabwareWellContents + ).flatMap(labwareWithIngred => + Object.values(labwareWithIngred).map( + ingred => ingred[liquid.ingredientId]?.volume ?? 0 + ) ) - ) - const totalVolume = sum(volumePerWell) + const totalVolume = sum(volumePerWell) - if (totalVolume === 0) { - return null - } else { - return ( - - + if (totalVolume === 0) { + return null + } else { + return ( + - - - {liquid.name ?? t('n/a')} - - + + + + {liquid.name ?? t('n/a')} + + - - + + + - - - ) - } - }) - ) : ( - - )} + + ) + } + }) + ) : ( + + )} + - - , + + , getTopPortalEl() ) } diff --git a/protocol-designer/src/pages/CreateNewProtocolWizard/AddMetadata.tsx b/protocol-designer/src/pages/CreateNewProtocolWizard/AddMetadata.tsx index a76f7bb4dcb..74f3b3d4690 100644 --- a/protocol-designer/src/pages/CreateNewProtocolWizard/AddMetadata.tsx +++ b/protocol-designer/src/pages/CreateNewProtocolWizard/AddMetadata.tsx @@ -11,7 +11,7 @@ import { } from '@opentrons/components' import { InputField } from '../../components/modals/CreateFileWizard/InputField' import { WizardBody } from './WizardBody' -import { HandleEnter } from './HandleEnter' +import { HandleEnter } from '../../atoms/HandleEnter' import type { WizardTileProps } from './types' diff --git a/protocol-designer/src/pages/CreateNewProtocolWizard/SelectFixtures.tsx b/protocol-designer/src/pages/CreateNewProtocolWizard/SelectFixtures.tsx index caebeecbe99..e21122653ed 100644 --- a/protocol-designer/src/pages/CreateNewProtocolWizard/SelectFixtures.tsx +++ b/protocol-designer/src/pages/CreateNewProtocolWizard/SelectFixtures.tsx @@ -22,7 +22,7 @@ import { getNumOptions, getNumSlotsAvailable, } from './utils' -import { HandleEnter } from './HandleEnter' +import { HandleEnter } from '../../atoms/HandleEnter' import type { DropdownBorder } from '@opentrons/components' import type { AdditionalEquipment, WizardTileProps } from './types' diff --git a/protocol-designer/src/pages/CreateNewProtocolWizard/SelectGripper.tsx b/protocol-designer/src/pages/CreateNewProtocolWizard/SelectGripper.tsx index 88dc6ab031d..611887ef6f9 100644 --- a/protocol-designer/src/pages/CreateNewProtocolWizard/SelectGripper.tsx +++ b/protocol-designer/src/pages/CreateNewProtocolWizard/SelectGripper.tsx @@ -11,7 +11,7 @@ import { DIRECTION_COLUMN, } from '@opentrons/components' import { WizardBody } from './WizardBody' -import { HandleEnter } from './HandleEnter' +import { HandleEnter } from '../../atoms/HandleEnter' import type { WizardTileProps } from './types' diff --git a/protocol-designer/src/pages/CreateNewProtocolWizard/SelectModules.tsx b/protocol-designer/src/pages/CreateNewProtocolWizard/SelectModules.tsx index 4cf2576c25e..b3bf5e225fa 100644 --- a/protocol-designer/src/pages/CreateNewProtocolWizard/SelectModules.tsx +++ b/protocol-designer/src/pages/CreateNewProtocolWizard/SelectModules.tsx @@ -25,10 +25,7 @@ import { TEMPERATURE_MODULE_TYPE, } from '@opentrons/shared-data' import { uuid } from '../../utils' -import { - getEnableAbsorbanceReader, - getEnableMoam, -} from '../../feature-flags/selectors' +import { getEnableAbsorbanceReader } from '../../feature-flags/selectors' import { useKitchen } from '../../organisms/Kitchen/hooks' import { ModuleDiagram } from '../../components/modules' import { WizardBody } from './WizardBody' @@ -39,7 +36,7 @@ import { OT2_SUPPORTED_MODULE_MODELS, } from './constants' import { getNumOptions, getNumSlotsAvailable } from './utils' -import { HandleEnter } from './HandleEnter' +import { HandleEnter } from '../../atoms/HandleEnter' import type { DropdownBorder } from '@opentrons/components' import type { ModuleModel, ModuleType } from '@opentrons/shared-data' @@ -56,7 +53,6 @@ export function SelectModules(props: WizardTileProps): JSX.Element | null { const fields = watch('fields') const modules = watch('modules') const additionalEquipment = watch('additionalEquipment') - const enableMoam = useSelector(getEnableMoam) const enableAbsorbanceReader = useSelector(getEnableAbsorbanceReader) const robotType = fields.robotType const supportedModules = @@ -83,9 +79,11 @@ export function SelectModules(props: WizardTileProps): JSX.Element | null { ) ) ) - const MOAM_MODULE_TYPES: ModuleType[] = enableMoam - ? [TEMPERATURE_MODULE_TYPE, HEATERSHAKER_MODULE_TYPE, MAGNETIC_BLOCK_TYPE] - : [TEMPERATURE_MODULE_TYPE] + const MOAM_MODULE_TYPES: ModuleType[] = [ + TEMPERATURE_MODULE_TYPE, + HEATERSHAKER_MODULE_TYPE, + MAGNETIC_BLOCK_TYPE, + ] const handleAddModule = (moduleModel: ModuleModel): void => { if (hasNoAvailableSlots) { diff --git a/protocol-designer/src/pages/CreateNewProtocolWizard/SelectPipettes.tsx b/protocol-designer/src/pages/CreateNewProtocolWizard/SelectPipettes.tsx index f15a716c617..115a8a46f9d 100644 --- a/protocol-designer/src/pages/CreateNewProtocolWizard/SelectPipettes.tsx +++ b/protocol-designer/src/pages/CreateNewProtocolWizard/SelectPipettes.tsx @@ -41,7 +41,7 @@ import { BUTTON_LINK_STYLE } from '../../atoms' import { WizardBody } from './WizardBody' import { PIPETTE_GENS, PIPETTE_TYPES, PIPETTE_VOLUMES } from './constants' import { getTiprackOptions } from './utils' -import { HandleEnter } from './HandleEnter' +import { HandleEnter } from '../../atoms/HandleEnter' import { removeOpentronsPhrases } from '../../utils' import type { ThunkDispatch } from 'redux-thunk' diff --git a/protocol-designer/src/pages/CreateNewProtocolWizard/SelectRobot.tsx b/protocol-designer/src/pages/CreateNewProtocolWizard/SelectRobot.tsx index 3d2894bd4a6..b6ab28893e6 100644 --- a/protocol-designer/src/pages/CreateNewProtocolWizard/SelectRobot.tsx +++ b/protocol-designer/src/pages/CreateNewProtocolWizard/SelectRobot.tsx @@ -8,7 +8,7 @@ import { } from '@opentrons/components' import { FLEX_ROBOT_TYPE, OT2_ROBOT_TYPE } from '@opentrons/shared-data' import { WizardBody } from './WizardBody' -import { HandleEnter } from './HandleEnter' +import { HandleEnter } from '../../atoms/HandleEnter' import type { WizardTileProps } from './types' export function SelectRobot(props: WizardTileProps): JSX.Element { diff --git a/protocol-designer/src/pages/CreateNewProtocolWizard/__tests__/SelectModules.test.tsx b/protocol-designer/src/pages/CreateNewProtocolWizard/__tests__/SelectModules.test.tsx index bddd3174b7f..a18f06bf508 100644 --- a/protocol-designer/src/pages/CreateNewProtocolWizard/__tests__/SelectModules.test.tsx +++ b/protocol-designer/src/pages/CreateNewProtocolWizard/__tests__/SelectModules.test.tsx @@ -4,10 +4,7 @@ import '@testing-library/jest-dom/vitest' import { FLEX_ROBOT_TYPE, OT2_ROBOT_TYPE } from '@opentrons/shared-data' import { fireEvent, screen } from '@testing-library/react' import { i18n } from '../../../assets/localization' -import { - getEnableAbsorbanceReader, - getEnableMoam, -} from '../../../feature-flags/selectors' +import { getEnableAbsorbanceReader } from '../../../feature-flags/selectors' import { renderWithProviders } from '../../../__testing-utils__' import { SelectModules } from '../SelectModules' import type { WizardFormState, WizardTileProps } from '../types' @@ -46,7 +43,6 @@ describe('SelectModules', () => { props = { ...mockWizardTileProps, } as WizardTileProps - vi.mocked(getEnableMoam).mockReturnValue(true) vi.mocked(getEnableAbsorbanceReader).mockReturnValue(true) }) diff --git a/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupTools.tsx b/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupTools.tsx index 20975e76c88..e062fa4784d 100644 --- a/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupTools.tsx +++ b/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupTools.tsx @@ -37,14 +37,11 @@ import { selectNestedLabware, selectZoomedIntoSlot, } from '../../../labware-ingred/actions' -import { - getEnableAbsorbanceReader, - getEnableMoam, -} from '../../../feature-flags/selectors' +import { getEnableAbsorbanceReader } from '../../../feature-flags/selectors' import { selectors } from '../../../labware-ingred/selectors' import { useKitchen } from '../../../organisms/Kitchen/hooks' import { createContainerAboveModule } from '../../../step-forms/actions/thunks' -import { FIXTURES, MOAM_MODELS, MOAM_MODELS_WITH_FF } from './constants' +import { FIXTURES, MOAM_MODELS } from './constants' import { getSlotInformation } from '../utils' import { getModuleModelsBySlot, getDeckErrors } from './utils' import { LabwareTools } from './LabwareTools' @@ -70,7 +67,6 @@ export function DeckSetupTools(props: DeckSetupToolsProps): JSX.Element | null { const robotType = useSelector(getRobotType) const dispatch = useDispatch>() const enableAbsorbanceReader = useSelector(getEnableAbsorbanceReader) - const enableMoam = useSelector(getEnableMoam) const deckSetup = useSelector(getDeckSetupForActiveItem) const { selectedLabwareDefUri, @@ -161,6 +157,8 @@ export function DeckSetupTools(props: DeckSetupToolsProps): JSX.Element | null { } const handleClear = (): void => { + onDeckProps?.setHoveredModule(null) + onDeckProps?.setHoveredFixture(null) if (slot !== 'offDeck') { // clear module from slot if (createdModuleForSlot != null) { @@ -291,9 +289,7 @@ export function DeckSetupTools(props: DeckSetupToolsProps): JSX.Element | null { module.type === getModuleType(model) && module.slot !== slot ) - const moamModels = enableMoam - ? MOAM_MODELS - : MOAM_MODELS_WITH_FF + const moamModels = MOAM_MODELS const collisionError = getDeckErrors({ modules: deckSetupModules, @@ -312,6 +308,7 @@ export function DeckSetupTools(props: DeckSetupToolsProps): JSX.Element | null { }} setHovered={() => { if (onDeckProps?.setHoveredModule != null) { + onDeckProps.setHoveredFixture(null) onDeckProps.setHoveredModule(model) } }} @@ -390,6 +387,7 @@ export function DeckSetupTools(props: DeckSetupToolsProps): JSX.Element | null { }} setHovered={() => { if (onDeckProps?.setHoveredFixture != null) { + onDeckProps.setHoveredModule(null) onDeckProps.setHoveredFixture(fixture) } }} diff --git a/protocol-designer/src/pages/Designer/DeckSetup/__tests__/DeckSetupTools.test.tsx b/protocol-designer/src/pages/Designer/DeckSetup/__tests__/DeckSetupTools.test.tsx index a3d63343389..cb85cf12693 100644 --- a/protocol-designer/src/pages/Designer/DeckSetup/__tests__/DeckSetupTools.test.tsx +++ b/protocol-designer/src/pages/Designer/DeckSetup/__tests__/DeckSetupTools.test.tsx @@ -12,10 +12,7 @@ import { renderWithProviders } from '../../../../__testing-utils__' import { deleteContainer } from '../../../../labware-ingred/actions' import { createModule, deleteModule } from '../../../../step-forms/actions' import { getRobotType } from '../../../../file-data/selectors' -import { - getEnableAbsorbanceReader, - getEnableMoam, -} from '../../../../feature-flags/selectors' +import { getEnableAbsorbanceReader } from '../../../../feature-flags/selectors' import { createDeckFixture, deleteDeckFixture, @@ -63,7 +60,6 @@ describe('DeckSetupTools', () => { vi.mocked(LabwareTools).mockReturnValue(
mock labware tools
) vi.mocked(getRobotType).mockReturnValue(FLEX_ROBOT_TYPE) vi.mocked(getEnableAbsorbanceReader).mockReturnValue(true) - vi.mocked(getEnableMoam).mockReturnValue(true) vi.mocked(getDeckSetupForActiveItem).mockReturnValue({ labware: {}, modules: {}, diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/index.tsx index 26d1daa324e..0272a35e618 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/index.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/index.tsx @@ -2,6 +2,12 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import { connect } from 'react-redux' import { useConditionalConfirm } from '@opentrons/components' +import { + HEATERSHAKER_MODULE_TYPE, + TEMPERATURE_MODULE_TYPE, + getModuleDisplayName, +} from '@opentrons/shared-data' + import { actions } from '../../../../steplist' import { actions as stepsActions } from '../../../../ui/steps' import { @@ -17,7 +23,6 @@ import { DELETE_STEP_FORM, } from '../../../../components/modals/ConfirmDeleteModal' import { AutoAddPauseUntilTempStepModal } from '../../../../components/modals/AutoAddPauseUntilTempStepModal' -import { AutoAddPauseUntilHeaterShakerTempStepModal } from '../../../../components/modals/AutoAddPauseUntilHeaterShakerTempStepModal' import { getDirtyFields, makeSingleEditFieldProps } from './utils' import { StepFormToolbox } from './StepFormToolbox' @@ -159,20 +164,30 @@ function StepFormManager(props: StepFormManagerProps): JSX.Element | null { onContinueClick={confirmClose} /> )} - {showAddPauseUntilTempStepModal && ( + {showAddPauseUntilTempStepModal || + showAddPauseUntilHeaterShakerTempStepModal ? ( - )} - {showAddPauseUntilHeaterShakerTempStepModal && ( - - )} + ) : null} { + if (targetWells.length === 1) { + return targetWells[0] + } + const firstElementIndexOffset = labwareWells.indexOf(targetWells[0]) + const isInOrder = targetWells.every( + (targetWell, i) => + labwareWells.indexOf(targetWell) === firstElementIndexOffset + i + ) + return isInOrder + ? `${first(targetWells)}-${last(targetWells)}` + : `${targetWells.length} wells` +} + interface StepSummaryProps { currentStep: FormData | null + stepDetails?: string } export function StepSummary(props: StepSummaryProps): JSX.Element | null { - const { currentStep } = props + const { currentStep, stepDetails } = props const { t } = useTranslation(['protocol_steps', 'application']) const labwareNicknamesById = useSelector(getLabwareNicknamesById) + const labwareEntities = useSelector(getLabwareEntities) const modules = useSelector(getModuleEntities) if (currentStep?.stepType == null) { return null @@ -62,13 +86,28 @@ export function StepSummary(props: StepSummaryProps): JSX.Element | null { let stepSummaryContent: JSX.Element | null = null switch (stepType) { case 'mix': - const { labware: mixLabwareId, volume: mixVolume, times } = currentStep + const { + labware: mixLabwareId, + volume: mixVolume, + times, + wells: mix_wells, + labware: mixLabware, + } = currentStep const mixLabwareDisplayName = labwareNicknamesById[mixLabwareId] + const mixWellsDisplay = getWellsForStepSummary( + mix_wells as string[], + flatten(labwareEntities[mixLabware].def.ordering) + ) + stepSummaryContent = ( ) break @@ -254,13 +293,29 @@ export function StepSummary(props: StepSummaryProps): JSX.Element | null { } else { moveLiquidType = 'transfer' } - const { aspirate_labware, dispense_labware, volume } = currentStep + const { + aspirate_labware, + aspirate_wells, + dispense_wells, + dispense_labware, + volume, + } = currentStep const sourceLabwareName = labwareNicknamesById[aspirate_labware] const destinationLabwareName = labwareNicknamesById[dispense_labware] + const aspirateWellsDisplay = getWellsForStepSummary( + aspirate_wells as string[], + flatten(labwareEntities[aspirate_labware].def.ordering) + ) + const dispenseWellsDisplay = getWellsForStepSummary( + dispense_wells as string[], + flatten(labwareEntities[dispense_labware].def.ordering) + ) stepSummaryContent = ( {targetSpeed != null ? ( - {stepSummaryContent} + {stepSummaryContent != null ? ( + + {stepSummaryContent} + + ) : null} + {stepDetails != null && stepDetails !== '' ? ( + + {stepDetails} + + ) : null} ) : null } diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/StepContainer.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/StepContainer.tsx index 2a9330b4a15..039c09220c2 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/StepContainer.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/StepContainer.tsx @@ -1,5 +1,6 @@ import * as React from 'react' import { createPortal } from 'react-dom' +import { useDispatch, useSelector } from 'react-redux' import { ALIGN_CENTER, BORDERS, @@ -15,12 +16,27 @@ import { OverflowBtn, SPACING, StyledText, + useConditionalConfirm, } from '@opentrons/components' +import { + ConfirmDeleteModal, + DELETE_MULTIPLE_STEP_FORMS, + DELETE_STEP_FORM, +} from '../../../../components/modals/ConfirmDeleteModal' import { getTopPortalEl } from '../../../../components/portals/TopPortal' +import { actions as steplistActions } from '../../../../steplist' +import { + deselectAllSteps, + populateForm, +} from '../../../../ui/steps/actions/actions' +import { getMultiSelectItemIds } from '../../../../ui/steps/selectors' import { StepOverflowMenu } from './StepOverflowMenu' import { capitalizeFirstLetterAfterNumber } from './utils' +import type { ThunkDispatch } from 'redux-thunk' import type { IconName } from '@opentrons/components' +import type { StepIdType } from '../../../../form-types' +import type { BaseState } from '../../../../types' const STARTING_DECK_STATE = 'Starting deck state' const FINAL_DECK_STATE = 'Final deck state' @@ -60,6 +76,8 @@ export function StepContainer(props: StepContainerProps): JSX.Element { const [stepOverflowMenu, setStepOverflowMenu] = React.useState(false) const isStartingOrEndingState = title === STARTING_DECK_STATE || title === FINAL_DECK_STATE + const dispatch = useDispatch>() + const multiSelectItemIds = useSelector(getMultiSelectItemIds) let backgroundColor = isStartingOrEndingState ? COLORS.blue20 : COLORS.grey20 let color = COLORS.black90 @@ -76,6 +94,17 @@ export function StepContainer(props: StepContainerProps): JSX.Element { color = COLORS.white } + const handleClick = (event: MouseEvent): void => { + const wasOutside = !( + event.target instanceof Node && + menuRootRef.current?.contains(event.target) + ) + + if (wasOutside && stepOverflowMenu) { + setStepOverflowMenu(false) + } + } + const handleOverflowClick = (event: React.MouseEvent): void => { const { clientY } = event @@ -98,19 +127,66 @@ export function StepContainer(props: StepContainerProps): JSX.Element { } }) - const handleClick = (event: MouseEvent): void => { - const wasOutside = !( - event.target instanceof Node && - menuRootRef.current?.contains(event.target) - ) + const handleStepItemSelection = (): void => { + if (stepId != null) { + dispatch(populateForm(stepId)) + } + setStepOverflowMenu(false) + } - if (wasOutside && stepOverflowMenu) { - setStepOverflowMenu(false) + const onDeleteClickAction = (): void => { + if (multiSelectItemIds) { + dispatch(steplistActions.deleteMultipleSteps(multiSelectItemIds)) + dispatch(deselectAllSteps('EXIT_BATCH_EDIT_MODE_BUTTON_PRESS')) + } else { + console.warn( + 'something went wrong, you cannot delete multiple steps if none are selected' + ) } } + const { + confirm: confirmMultiDelete, + showConfirmation: showMultiDeleteConfirmation, + cancel: cancelMultiDelete, + } = useConditionalConfirm(onDeleteClickAction, true) + + const deleteStep = (stepId: StepIdType): void => { + dispatch(steplistActions.deleteStep(stepId)) + } + + const handleDelete = (): void => { + if (stepId != null) { + deleteStep(stepId) + } else { + console.warn( + 'something went wrong, cannot delete a step without a step id' + ) + } + } + + const { + confirm: confirmDelete, + showConfirmation: showDeleteConfirmation, + cancel: cancelDelete, + } = useConditionalConfirm(handleDelete, true) + return ( <> + {showDeleteConfirmation && ( + + )} + {showMultiDeleteConfirmation && ( + + )} , getTopPortalEl() ) diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/StepOverflowMenu.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/StepOverflowMenu.tsx index 5078ff4c0e5..b83198d2d81 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/StepOverflowMenu.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/StepOverflowMenu.tsx @@ -12,175 +12,68 @@ import { NO_WRAP, POSITION_ABSOLUTE, SPACING, - useConditionalConfirm, } from '@opentrons/components' -import { actions as steplistActions } from '../../../../steplist' -import { - getMultiSelectItemIds, - actions as stepsActions, -} from '../../../../ui/steps' -import { - CLOSE_BATCH_EDIT_FORM, - CLOSE_STEP_FORM_WITH_CHANGES, - CLOSE_UNSAVED_STEP_FORM, - ConfirmDeleteModal, - DELETE_MULTIPLE_STEP_FORMS, - DELETE_STEP_FORM, -} from '../../../../components/modals/ConfirmDeleteModal' +import { actions as stepsActions } from '../../../../ui/steps' import { hoverOnStep, toggleViewSubstep, - populateForm, - deselectAllSteps, } from '../../../../ui/steps/actions/actions' import { - getBatchEditFormHasUnsavedChanges, - getCurrentFormHasUnsavedChanges, - getCurrentFormIsPresaved, getSavedStepForms, getUnsavedForm, } from '../../../../step-forms/selectors' -import { deleteMultipleSteps } from '../../../../steplist/actions' -import { duplicateMultipleSteps } from '../../../../ui/steps/actions/thunks' -import type * as React from 'react' import type { ThunkDispatch } from 'redux-thunk' import type { BaseState } from '../../../../types' import type { StepIdType } from '../../../../form-types' -import type { DeleteModalType } from '../../../../components/modals/ConfirmDeleteModal' interface StepOverflowMenuProps { stepId: string menuRootRef: React.MutableRefObject top: number setStepOverflowMenu: React.Dispatch> + handleEdit: () => void + confirmDelete: () => void + confirmMultiDelete: () => void + multiSelectItemIds: string[] | null } export function StepOverflowMenu(props: StepOverflowMenuProps): JSX.Element { - const { stepId, menuRootRef, top, setStepOverflowMenu } = props + const { + stepId, + menuRootRef, + top, + setStepOverflowMenu, + handleEdit, + confirmDelete, + confirmMultiDelete, + multiSelectItemIds, + } = props const { t } = useTranslation('protocol_steps') - const multiSelectItemIds = useSelector(getMultiSelectItemIds) const dispatch = useDispatch>() - const deleteStep = (stepId: StepIdType): void => { - dispatch(steplistActions.deleteStep(stepId)) - } const formData = useSelector(getUnsavedForm) const savedStepFormData = useSelector(getSavedStepForms)[stepId] - const currentFormIsPresaved = useSelector(getCurrentFormIsPresaved) - const singleEditFormHasUnsavedChanges = useSelector( - getCurrentFormHasUnsavedChanges - ) - const batchEditFormHasUnsavedChanges = useSelector( - getBatchEditFormHasUnsavedChanges - ) + const isPipetteStep = + savedStepFormData.stepType === 'moveLiquid' || + savedStepFormData.stepType === 'mix' + const isThermocyclerStep = savedStepFormData.stepType === 'thermocycler' + const duplicateStep = ( stepId: StepIdType ): ReturnType => dispatch(stepsActions.duplicateStep(stepId)) - const handleStepItemSelection = (): void => { - dispatch(populateForm(stepId)) - setStepOverflowMenu(false) - } - const handleDelete = (): void => { - if (stepId != null) { - deleteStep(stepId) - } else { - console.warn( - 'something went wrong, cannot delete a step without a step id' - ) - } - } - const onDuplicateClickAction = (): void => { + const duplicateMultipleSteps = (): void => { if (multiSelectItemIds) { - dispatch(duplicateMultipleSteps(multiSelectItemIds)) + dispatch(stepsActions.duplicateMultipleSteps(multiSelectItemIds)) } else { console.warn( 'something went wrong, you cannot duplicate multiple steps if none are selected' ) } } - const onDeleteClickAction = (): void => { - if (multiSelectItemIds) { - dispatch(deleteMultipleSteps(multiSelectItemIds)) - dispatch(deselectAllSteps('EXIT_BATCH_EDIT_MODE_BUTTON_PRESS')) - } else { - console.warn( - 'something went wrong, you cannot delete multiple steps if none are selected' - ) - } - } - - const { confirm, showConfirmation, cancel } = useConditionalConfirm( - handleStepItemSelection, - currentFormIsPresaved || singleEditFormHasUnsavedChanges - ) - const { - confirm: confirmDuplicate, - showConfirmation: showDuplicateConfirmation, - cancel: cancelDuplicate, - } = useConditionalConfirm( - onDuplicateClickAction, - batchEditFormHasUnsavedChanges - ) - - const { - confirm: confirmMultiDelete, - showConfirmation: showMultiDeleteConfirmation, - cancel: cancelMultiDelete, - } = useConditionalConfirm(onDeleteClickAction, true) - - const { - confirm: confirmDelete, - showConfirmation: showDeleteConfirmation, - cancel: cancelDelete, - } = useConditionalConfirm(handleDelete, true) - - const getModalType = (): DeleteModalType => { - if (currentFormIsPresaved) { - return CLOSE_UNSAVED_STEP_FORM - } else { - return CLOSE_STEP_FORM_WITH_CHANGES - } - } - const isPipetteStep = - savedStepFormData.stepType === 'moveLiquid' || - savedStepFormData.stepType === 'mix' - const isThermocyclerStep = savedStepFormData.stepType === 'thermocycler' return ( <> - {/* TODO: update this modal */} - {showConfirmation && ( - - )} - {/* TODO: update this modal */} - {showDuplicateConfirmation && ( - - )} - {/* TODO: update this modal */} - {showMultiDeleteConfirmation && ( - - )} - {/* TODO: update this modal */} - {showDeleteConfirmation && ( - - )} {multiSelectItemIds != null && multiSelectItemIds.length > 0 ? ( <> - + { + duplicateMultipleSteps() + setStepOverflowMenu(false) + }} + > {t('duplicate_steps')} - + + { + confirmMultiDelete() + setStepOverflowMenu(false) + }} + > {t('delete_steps')} ) : ( <> {formData != null ? null : ( - {t('edit_step')} + {t('edit_step')} )} {isPipetteStep || isThermocyclerStep ? ( { duplicateStep(stepId) + setStepOverflowMenu(false) }} > {t('duplicate')} - {t('delete')} + { + confirmDelete() + setStepOverflowMenu(false) + }} + > + {t('delete')} + )} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/__tests__/StepContainer.test.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/__tests__/StepContainer.test.tsx index 93ea0baab62..0f5981906d6 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/__tests__/StepContainer.test.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/__tests__/StepContainer.test.tsx @@ -9,6 +9,8 @@ import { StepContainer } from '../StepContainer' import { StepOverflowMenu } from '../StepOverflowMenu' vi.mock('../../../../../step-forms/selectors') +vi.mock('../../../../../ui/steps/actions/actions') +vi.mock('../../../../../ui/steps/selectors') vi.mock('../StepOverflowMenu') const render = (props: React.ComponentProps) => { diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/__tests__/StepOverflowMenu.test.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/__tests__/StepOverflowMenu.test.tsx index f37d2114c74..597771e0854 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/__tests__/StepOverflowMenu.test.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/__tests__/StepOverflowMenu.test.tsx @@ -29,6 +29,8 @@ vi.mock('../../../../../step-forms/selectors') vi.mock('../../../../../ui/steps/actions/actions') vi.mock('../../../../../ui/steps/actions/thunks') vi.mock('../../../../../steplist/actions') +vi.mock('../../../../../feature-flags/selectors') + vi.mock('@opentrons/components', async importOriginal => { const actual = await importOriginal() return { @@ -56,6 +58,10 @@ describe('StepOverflowMenu', () => { top: 0, menuRootRef: { current: null }, setStepOverflowMenu: vi.fn(), + multiSelectItemIds: [], + handleEdit: vi.fn(), + confirmDelete: mockConfirm, + confirmMultiDelete: vi.fn(), } vi.mocked(getMultiSelectItemIds).mockReturnValue(null) vi.mocked(getCurrentFormIsPresaved).mockReturnValue(false) @@ -71,24 +77,19 @@ describe('StepOverflowMenu', () => { it('renders each button and clicking them calls the action', () => { render(props) - fireEvent.click(screen.getAllByText('Delete step')[0]) - screen.getByText('Are you sure you want to delete this step?') - fireEvent.click(screen.getByText('delete step')) + fireEvent.click(screen.getByText('Delete step')) expect(mockConfirm).toHaveBeenCalled() fireEvent.click(screen.getByText('Duplicate step')) expect(vi.mocked(stepsActions.duplicateStep)).toHaveBeenCalled() fireEvent.click(screen.getByText('Edit step')) - expect(mockConfirm).toHaveBeenCalled() fireEvent.click(screen.getByText('View details')) expect(vi.mocked(hoverOnStep)).toHaveBeenCalled() expect(vi.mocked(toggleViewSubstep)).toHaveBeenCalled() }) it('renders the multi select overflow menu', () => { - vi.mocked(getMultiSelectItemIds).mockReturnValue(['1', '2']) - render(props) + render({ ...props, multiSelectItemIds: ['abc', '123'] }) screen.getByText('Duplicate steps') screen.getByText('Delete steps') - screen.getByText('Delete multiple steps') }) }) diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/index.tsx index 4d1585040a7..468ea45e01f 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/index.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/index.tsx @@ -63,10 +63,10 @@ export function ProtocolSteps(): JSX.Element { ? savedStepForms[currentstepIdForStepSummary] : null + const stepDetails = currentStep?.stepDetails ?? null return ( ) : null} - + {deckView === leftString ? ( ) : ( )} {formData == null ? ( - + ) : null} diff --git a/protocol-designer/src/pages/ProtocolOverview/DeckThumbnailDetails.tsx b/protocol-designer/src/pages/ProtocolOverview/DeckThumbnailDetails.tsx index dda66237feb..36caf29c4ad 100644 --- a/protocol-designer/src/pages/ProtocolOverview/DeckThumbnailDetails.tsx +++ b/protocol-designer/src/pages/ProtocolOverview/DeckThumbnailDetails.tsx @@ -10,6 +10,7 @@ import { inferModuleOrientationFromXCoordinate, isAddressableAreaStandardSlot, THERMOCYCLER_MODULE_TYPE, + SPAN7_8_10_11_SLOT, } from '@opentrons/shared-data' import { LabwareOnDeck } from '../../components/DeckSetup/LabwareOnDeck' import { getStagingAreaAddressableAreas } from '../../utils' @@ -65,22 +66,23 @@ export const DeckThumbnailDetails = ( return ( <> {/* all modules */} - {allModules.map(moduleOnDeck => { - const slotId = moduleOnDeck.slot + {allModules.map(({ id, slot, model, type, moduleState }) => { + const slotId = + slot === SPAN7_8_10_11_SLOT && type === THERMOCYCLER_MODULE_TYPE + ? '7' + : slot const slotPosition = getPositionFromSlotId(slotId, deckDef) if (slotPosition == null) { - console.warn(`no slot ${slotId} for module ${moduleOnDeck.id}`) + console.warn(`no slot ${slotId} for module ${id}`) return null } - const moduleDef = getModuleDef2(moduleOnDeck.model) - const labwareLoadedOnModule = allLabware.find( - lw => lw.slot === moduleOnDeck.id - ) + const moduleDef = getModuleDef2(model) + const labwareLoadedOnModule = allLabware.find(lw => lw.slot === id) return ( - + -

{t('alert:export_warnings.no_commands.body1')}

-

{t('alert:export_warnings.no_commands.body2')}

- + + {t('alert:export_warnings.no_commands.redesign.body')} + + ), + titleElement: ( + ), - heading: t('alert:export_warnings.no_commands.heading'), } } diff --git a/protocol-designer/src/pages/ProtocolOverview/__tests__/ProtocolOverview.test.tsx b/protocol-designer/src/pages/ProtocolOverview/__tests__/ProtocolOverview.test.tsx index e8536e4a549..50d9d48e7bd 100644 --- a/protocol-designer/src/pages/ProtocolOverview/__tests__/ProtocolOverview.test.tsx +++ b/protocol-designer/src/pages/ProtocolOverview/__tests__/ProtocolOverview.test.tsx @@ -28,8 +28,9 @@ vi.mock('../../../step-forms/selectors') vi.mock('../../../file-data/selectors') vi.mock('../../../organisms/MaterialsListModal') vi.mock('../../../labware-ingred/selectors') +vi.mock('../../../load-file/actions') +vi.mock('../../../feature-flags/selectors') vi.mock('../../../organisms') -vi.mock('../../../labware-ingred/selectors') vi.mock('../ProtocolMetadata') vi.mock('../LiquidDefinitions') vi.mock('../InstrumentsInfo') diff --git a/protocol-designer/src/pages/ProtocolOverview/index.tsx b/protocol-designer/src/pages/ProtocolOverview/index.tsx index 0b402ed3884..7f9969575c5 100644 --- a/protocol-designer/src/pages/ProtocolOverview/index.tsx +++ b/protocol-designer/src/pages/ProtocolOverview/index.tsx @@ -203,16 +203,16 @@ export function ProtocolOverview(): JSX.Element { fixtureWithoutStep.wasteChute || fixtureWithoutStep.stagingAreaSlots.length > 0 - const warning = - hasWarning && - getWarningContent({ - noCommands, - pipettesWithoutStep, - modulesWithoutStep, - gripperWithoutStep, - fixtureWithoutStep, - t, - }) + const warning = hasWarning + ? getWarningContent({ + noCommands, + pipettesWithoutStep, + modulesWithoutStep, + gripperWithoutStep, + fixtureWithoutStep, + t, + }) + : null const cancelModal = (): void => { setShowExportWarningModal(false) @@ -239,11 +239,13 @@ export function ProtocolOverview(): JSX.Element { {t('shared:cancel')} diff --git a/protocol-designer/src/pages/Settings/__tests__/Settings.test.tsx b/protocol-designer/src/pages/Settings/__tests__/Settings.test.tsx index cb62cfe7d62..186b50f029c 100644 --- a/protocol-designer/src/pages/Settings/__tests__/Settings.test.tsx +++ b/protocol-designer/src/pages/Settings/__tests__/Settings.test.tsx @@ -53,9 +53,9 @@ describe('Settings', () => { screen.getByText('Reset hints') screen.getByText('Privacy') screen.getByText('Share sessions with Opentrons') - screen.getByText( - 'We’re working to improve Protocol Designer. Part of the process involves watching real user sessions to understand which parts of the interface are working and which could use improvement. We never share sessions outside of Opentrons.' - ) + screen.debug() + screen.getByRole('link', { name: 'privacy policy' }) + screen.getByRole('link', { name: 'EULA' }) }) it('renders the announcement modal when view release notes button is clicked', () => { vi.mocked(AnnouncementModal).mockReturnValue( diff --git a/protocol-designer/src/pages/Settings/index.tsx b/protocol-designer/src/pages/Settings/index.tsx index 624cc4fa104..fc57ebc6d53 100644 --- a/protocol-designer/src/pages/Settings/index.tsx +++ b/protocol-designer/src/pages/Settings/index.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { useTranslation } from 'react-i18next' +import { Trans, useTranslation } from 'react-i18next' import { useDispatch, useSelector } from 'react-redux' import { css } from 'styled-components' import { @@ -11,6 +11,7 @@ import { Flex, Icon, JUSTIFY_SPACE_BETWEEN, + Link as LinkComponent, SPACING, StyledText, TYPOGRAPHY, @@ -31,6 +32,8 @@ import { getFeatureFlagData } from '../../feature-flags/selectors' import type { FlagTypes } from '../../feature-flags' const HOT_KEY_FLAG = 'OT_PD_ENABLE_HOT_KEYS_DISPLAY' +const PRIVACY_POLICY_URL = 'https://opentrons.com/privacy-policy' +const EULA_URL = 'https://opentrons.com/eula' export function Settings(): JSX.Element { const dispatch = useDispatch() @@ -235,11 +238,28 @@ export function Settings(): JSX.Element { {t('shared:shared_sessions')} - - - {t('shared:we_are_improving')} - - + + + ), + link2: ( + + ), + }} + /> + FileProviderWrapper: + """Return the server's singleton `FileProviderWrapper` which provides the engine related callbacks for FileProvider.""" + file_provider_wrapper = FileProviderWrapper( + data_files_directory=data_files_directory, data_files_store=data_files_store + ) + + return file_provider_wrapper + + +async def get_file_provider( + file_provider_wrapper: Annotated[ + FileProviderWrapper, fastapi.Depends(get_file_provider_wrapper) + ], +) -> FileProvider: + """Return theengine `FileProvider` which accepts callbacks from FileProviderWrapper.""" + file_provider = FileProvider( + data_files_write_csv_callback=file_provider_wrapper.write_csv_callback, + data_files_filecount=file_provider_wrapper.csv_filecount_callback, + ) + + return file_provider diff --git a/robot-server/robot_server/file_provider/provider.py b/robot-server/robot_server/file_provider/provider.py new file mode 100644 index 00000000000..5cfeb640fef --- /dev/null +++ b/robot-server/robot_server/file_provider/provider.py @@ -0,0 +1,74 @@ +"""Wrapper to provide the callbacks utilized by the Protocol Engine File Provider.""" +import os +import asyncio +import csv +from pathlib import Path +from typing import Annotated +from fastapi import Depends +from robot_server.data_files.dependencies import ( + get_data_files_directory, + get_data_files_store, +) +from ..service.dependencies import get_current_time, get_unique_id +from robot_server.data_files.data_files_store import DataFilesStore, DataFileInfo +from opentrons.protocol_engine.resources.file_provider import GenericCsvTransform + + +class FileProviderWrapper: + """Wrapper to provide File Read and Write capabilities to Protocol Engine.""" + + def __init__( + self, + data_files_directory: Annotated[Path, Depends(get_data_files_directory)], + data_files_store: Annotated[DataFilesStore, Depends(get_data_files_store)], + ) -> None: + """Provides callbacks for data file manipulation for the Protocol Engine's File Provider class. + + Params: + data_files_directory: The directory to store engine-create files in during a protocol run. + data_files_store: The data files store utilized for database interaction when creating files. + """ + self._data_files_directory = data_files_directory + self._data_files_store = data_files_store + + # dta file store is not generally safe for concurrent access. + self._lock = asyncio.Lock() + + async def write_csv_callback( + self, + csv_data: GenericCsvTransform, + ) -> str: + """Write the provided data transform to a CSV file. Returns the File ID of the created file.""" + async with self._lock: + file_id = await get_unique_id() + os.makedirs( + os.path.dirname( + self._data_files_directory / file_id / csv_data.filename + ), + exist_ok=True, + ) + with open( + file=self._data_files_directory / file_id / csv_data.filename, + mode="w", + newline="", + ) as csvfile: + writer = csv.writer(csvfile, delimiter=csv_data.delimiter) + writer.writerows(csv_data.rows) + + created_at = await get_current_time() + # TODO (cb, 10-14-24): Engine created files do not currently get a file_hash, unlike explicitly uploaded files. Do they need one? + file_info = DataFileInfo( + id=file_id, + name=csv_data.filename, + file_hash="", + created_at=created_at, + ) + await self._data_files_store.insert(file_info) + return file_id + + async def csv_filecount_callback(self) -> int: + """Return the current count of files stored within the data files directory.""" + data_file_usage_info = [ + usage_info for usage_info in self._data_files_store.get_usage_info() + ] + return len(data_file_usage_info) diff --git a/robot-server/robot_server/maintenance_runs/maintenance_run_data_manager.py b/robot-server/robot_server/maintenance_runs/maintenance_run_data_manager.py index 589aaf5614d..46b2c86bd40 100644 --- a/robot-server/robot_server/maintenance_runs/maintenance_run_data_manager.py +++ b/robot-server/robot_server/maintenance_runs/maintenance_run_data_manager.py @@ -33,6 +33,7 @@ def _build_run( modules=[], liquids=[], wells=[], + files=[], hasEverEnteredErrorRecovery=False, ) return MaintenanceRun.construct( diff --git a/robot-server/robot_server/runs/router/base_router.py b/robot-server/robot_server/runs/router/base_router.py index b9bd8cd24b2..639e6d91628 100644 --- a/robot-server/robot_server/runs/router/base_router.py +++ b/robot-server/robot_server/runs/router/base_router.py @@ -72,6 +72,10 @@ get_deck_configuration_store, ) from robot_server.deck_configuration.store import DeckConfigurationStore +from robot_server.file_provider.fastapi_dependencies import ( + get_file_provider, +) +from opentrons.protocol_engine.resources.file_provider import FileProvider from robot_server.service.notifications import get_pe_notify_publishers log = logging.getLogger(__name__) @@ -187,6 +191,7 @@ async def create_run( # noqa: C901 deck_configuration_store: Annotated[ DeckConfigurationStore, Depends(get_deck_configuration_store) ], + file_provider: Annotated[FileProvider, Depends(get_file_provider)], notify_publishers: Annotated[Callable[[], None], Depends(get_pe_notify_publishers)], request_body: Optional[RequestModel[RunCreate]] = None, ) -> PydanticResponse[SimpleBody[Union[Run, BadRun]]]: @@ -206,6 +211,7 @@ async def create_run( # noqa: C901 resources to make room for the new run. check_estop: Dependency to verify the estop is in a valid state. deck_configuration_store: Dependency to fetch the deck configuration. + file_provider: Dependency to provide access to file Reading and Writing to Protocol engine. notify_publishers: Utilized by the engine to notify publishers of state changes. """ protocol_id = request_body.data.protocolId if request_body is not None else None @@ -260,6 +266,7 @@ async def create_run( # noqa: C901 created_at=created_at, labware_offsets=offsets, deck_configuration=deck_configuration, + file_provider=file_provider, run_time_param_values=rtp_values, run_time_param_paths=rtp_paths, protocol=protocol_resource, diff --git a/robot-server/robot_server/runs/run_data_manager.py b/robot-server/robot_server/runs/run_data_manager.py index 4168b1d4d5d..3edf89ef163 100644 --- a/robot-server/robot_server/runs/run_data_manager.py +++ b/robot-server/robot_server/runs/run_data_manager.py @@ -34,6 +34,7 @@ from .run_models import Run, BadRun, RunDataError from opentrons.protocol_engine.types import DeckConfigurationType, RunTimeParameter +from opentrons.protocol_engine.resources.file_provider import FileProvider _INITIAL_ERROR_RECOVERY_RULES: list[ErrorRecoveryRule] = [] @@ -65,6 +66,7 @@ def _build_run( completedAt=state_summary.completedAt, startedAt=state_summary.startedAt, liquids=state_summary.liquids, + outputFileIds=state_summary.files, runTimeParameters=run_time_parameters, ) @@ -79,6 +81,7 @@ def _build_run( modules=[], liquids=[], wells=[], + files=[], hasEverEnteredErrorRecovery=False, ) errors.append(state_summary.dataError) @@ -122,6 +125,7 @@ def _build_run( startedAt=state.startedAt, liquids=state.liquids, runTimeParameters=run_time_parameters, + outputFileIds=state.files, hasEverEnteredErrorRecovery=state.hasEverEnteredErrorRecovery, ) @@ -170,6 +174,7 @@ async def create( created_at: datetime, labware_offsets: List[LabwareOffsetCreate], deck_configuration: DeckConfigurationType, + file_provider: FileProvider, run_time_param_values: Optional[PrimitiveRunTimeParamValuesType], run_time_param_paths: Optional[CSVRuntimeParamPaths], notify_publishers: Callable[[], None], @@ -217,6 +222,7 @@ async def create( labware_offsets=labware_offsets, initial_error_recovery_policy=initial_error_recovery_policy, deck_configuration=deck_configuration, + file_provider=file_provider, protocol=protocol, run_time_param_values=run_time_param_values, run_time_param_paths=run_time_param_paths, diff --git a/robot-server/robot_server/runs/run_models.py b/robot-server/robot_server/runs/run_models.py index 962c3ab51e7..bac7c4c740c 100644 --- a/robot-server/robot_server/runs/run_models.py +++ b/robot-server/robot_server/runs/run_models.py @@ -146,6 +146,10 @@ class Run(ResourceModel): " if none are specified in the request." ), ) + outputFileIds: List[str] = Field( + ..., + description="File IDs of files output during a protocol run.", + ) protocolId: Optional[str] = Field( None, description=( @@ -223,6 +227,10 @@ class BadRun(ResourceModel): " if none are specified in the request." ), ) + outputFileIds: List[str] = Field( + ..., + description="File IDs of files output during a protocol run.", + ) protocolId: Optional[str] = Field( None, description=( diff --git a/robot-server/robot_server/runs/run_orchestrator_store.py b/robot-server/robot_server/runs/run_orchestrator_store.py index 03af7315ef9..e05bd3bd349 100644 --- a/robot-server/robot_server/runs/run_orchestrator_store.py +++ b/robot-server/robot_server/runs/run_orchestrator_store.py @@ -52,6 +52,7 @@ EngineStatus, ) from opentrons_shared_data.labware.types import LabwareUri +from opentrons.protocol_engine.resources.file_provider import FileProvider _log = logging.getLogger(__name__) @@ -193,6 +194,7 @@ async def create( labware_offsets: List[LabwareOffsetCreate], initial_error_recovery_policy: error_recovery_policy.ErrorRecoveryPolicy, deck_configuration: DeckConfigurationType, + file_provider: FileProvider, notify_publishers: Callable[[], None], protocol: Optional[ProtocolResource], run_time_param_values: Optional[PrimitiveRunTimeParamValuesType] = None, @@ -236,6 +238,7 @@ async def create( error_recovery_policy=initial_error_recovery_policy, load_fixed_trash=load_fixed_trash, deck_configuration=deck_configuration, + file_provider=file_provider, notify_publishers=notify_publishers, ) diff --git a/robot-server/tests/integration/http_api/runs/test_json_v6_protocol_run.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_json_v6_protocol_run.tavern.yaml index 48e1088eb4c..fd98c29a2dc 100644 --- a/robot-server/tests/integration/http_api/runs/test_json_v6_protocol_run.tavern.yaml +++ b/robot-server/tests/integration/http_api/runs/test_json_v6_protocol_run.tavern.yaml @@ -52,6 +52,7 @@ stages: description: Liquid H2O displayColor: '#7332a8' runTimeParameters: [] + outputFileIds: [] protocolId: '{protocol_id}' - name: Execute a setup command diff --git a/robot-server/tests/integration/http_api/runs/test_json_v7_protocol_run.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_json_v7_protocol_run.tavern.yaml index 0915fb69f12..3ab7386ba4f 100644 --- a/robot-server/tests/integration/http_api/runs/test_json_v7_protocol_run.tavern.yaml +++ b/robot-server/tests/integration/http_api/runs/test_json_v7_protocol_run.tavern.yaml @@ -47,6 +47,7 @@ stages: location: !anydict labwareOffsets: [] runTimeParameters: [] + outputFileIds: [] liquids: - id: waterId displayName: Water diff --git a/robot-server/tests/integration/http_api/runs/test_protocol_run.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_protocol_run.tavern.yaml index 2bfa2ccd552..2ad0a92eb8c 100644 --- a/robot-server/tests/integration/http_api/runs/test_protocol_run.tavern.yaml +++ b/robot-server/tests/integration/http_api/runs/test_protocol_run.tavern.yaml @@ -44,6 +44,7 @@ stages: location: !anydict labwareOffsets: [] runTimeParameters: [] + outputFileIds: [] protocolId: '{protocol_id}' liquids: [] save: @@ -240,6 +241,7 @@ stages: startedAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))" liquids: [] runTimeParameters: [] + outputFileIds: [] completedAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))" errors: [] hasEverEnteredErrorRecovery: False diff --git a/robot-server/tests/integration/http_api/runs/test_run_queued_protocol_commands.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_run_queued_protocol_commands.tavern.yaml index edec26c4e03..14ae502d800 100644 --- a/robot-server/tests/integration/http_api/runs/test_run_queued_protocol_commands.tavern.yaml +++ b/robot-server/tests/integration/http_api/runs/test_run_queued_protocol_commands.tavern.yaml @@ -96,6 +96,7 @@ stages: labwareOffsets: [] liquids: [] runTimeParameters: [] + outputFileIds: [] modules: [] pipettes: [] status: 'idle' diff --git a/robot-server/tests/integration/http_api/runs/test_run_with_run_time_parameters.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_run_with_run_time_parameters.tavern.yaml index 2533769b56f..8916ebd1cf2 100644 --- a/robot-server/tests/integration/http_api/runs/test_run_with_run_time_parameters.tavern.yaml +++ b/robot-server/tests/integration/http_api/runs/test_run_with_run_time_parameters.tavern.yaml @@ -115,6 +115,7 @@ stages: file: id: '{data_file_id}' name: sample_plates.csv + outputFileIds: [] liquids: [] protocolId: '{protocol_id}' diff --git a/robot-server/tests/protocols/test_protocol_analyzer.py b/robot-server/tests/protocols/test_protocol_analyzer.py index 8448ded8870..5d3d9da8a13 100644 --- a/robot-server/tests/protocols/test_protocol_analyzer.py +++ b/robot-server/tests/protocols/test_protocol_analyzer.py @@ -190,6 +190,7 @@ async def test_analyze( labwareOffsets=[], liquids=[], wells=[], + files=[], hasEverEnteredErrorRecovery=False, ), parameters=[bool_parameter], diff --git a/robot-server/tests/runs/router/conftest.py b/robot-server/tests/runs/router/conftest.py index 700be63f4ef..0ca0c5cc4f5 100644 --- a/robot-server/tests/runs/router/conftest.py +++ b/robot-server/tests/runs/router/conftest.py @@ -12,7 +12,10 @@ ) from robot_server.deck_configuration.store import DeckConfigurationStore +from robot_server.file_provider.provider import FileProviderWrapper + from opentrons.protocol_engine import ProtocolEngine +from opentrons.protocol_engine.resources import FileProvider @pytest.fixture() @@ -63,3 +66,17 @@ def mock_maintenance_run_orchestrator_store( def mock_deck_configuration_store(decoy: Decoy) -> DeckConfigurationStore: """Get a mock DeckConfigurationStore.""" return decoy.mock(cls=DeckConfigurationStore) + + +@pytest.fixture() +def mock_file_provider_wrapper(decoy: Decoy) -> FileProviderWrapper: + """Return a mock FileProviderWrapper.""" + return decoy.mock(cls=FileProviderWrapper) + + +@pytest.fixture() +def mock_file_provider( + decoy: Decoy, mock_file_provider_wrapper: FileProviderWrapper +) -> FileProvider: + """Return a mock FileProvider.""" + return decoy.mock(cls=FileProvider) diff --git a/robot-server/tests/runs/router/test_base_router.py b/robot-server/tests/runs/router/test_base_router.py index 8a10af1940d..25c91f70ade 100644 --- a/robot-server/tests/runs/router/test_base_router.py +++ b/robot-server/tests/runs/router/test_base_router.py @@ -69,6 +69,10 @@ ) from robot_server.deck_configuration.store import DeckConfigurationStore +from opentrons.protocol_engine.resources.file_provider import ( + FileProvider, +) +from robot_server.file_provider.provider import FileProviderWrapper def mock_notify_publishers() -> None: @@ -125,8 +129,10 @@ async def test_create_run( mock_run_auto_deleter: RunAutoDeleter, labware_offset_create: pe_types.LabwareOffsetCreate, mock_deck_configuration_store: DeckConfigurationStore, + mock_file_provider_wrapper: FileProviderWrapper, mock_protocol_store: ProtocolStore, mock_data_files_store: DataFilesStore, + mock_file_provider: FileProvider, ) -> None: """It should be able to create a basic run.""" run_id = "run-id" @@ -145,17 +151,20 @@ async def test_create_run( labwareOffsets=[], status=pe_types.EngineStatus.IDLE, liquids=[], + outputFileIds=[], hasEverEnteredErrorRecovery=False, ) decoy.when( await mock_deck_configuration_store.get_deck_configuration() ).then_return([]) + decoy.when( await mock_run_data_manager.create( run_id=run_id, created_at=run_created_at, labware_offsets=[labware_offset_create], deck_configuration=[], + file_provider=mock_file_provider, protocol=None, run_time_param_values=None, run_time_param_paths=None, @@ -175,6 +184,7 @@ async def test_create_run( run_auto_deleter=mock_run_auto_deleter, quick_transfer_run_auto_deleter=mock_run_auto_deleter, deck_configuration_store=mock_deck_configuration_store, + file_provider=mock_file_provider, notify_publishers=mock_notify_publishers, protocol_store=mock_protocol_store, check_estop=True, @@ -193,6 +203,7 @@ async def test_create_protocol_run( mock_run_auto_deleter: RunAutoDeleter, mock_deck_configuration_store: DeckConfigurationStore, mock_data_files_store: DataFilesStore, + mock_file_provider: FileProvider, ) -> None: """It should be able to create a protocol run.""" run_id = "run-id" @@ -228,6 +239,7 @@ async def test_create_protocol_run( labwareOffsets=[], status=pe_types.EngineStatus.IDLE, liquids=[], + outputFileIds=[], hasEverEnteredErrorRecovery=False, ) decoy.when(mock_data_files_store.get("file-id")).then_return( @@ -251,6 +263,7 @@ async def test_create_protocol_run( created_at=run_created_at, labware_offsets=[], deck_configuration=[], + file_provider=mock_file_provider, protocol=protocol_resource, run_time_param_values={"foo": "bar"}, run_time_param_paths={"my-csv-param": Path("/dev/null/file-id/abc.xyz")}, @@ -275,6 +288,7 @@ async def test_create_protocol_run( run_auto_deleter=mock_run_auto_deleter, quick_transfer_run_auto_deleter=mock_run_auto_deleter, deck_configuration_store=mock_deck_configuration_store, + file_provider=mock_file_provider, notify_publishers=mock_notify_publishers, check_estop=True, ) @@ -293,6 +307,7 @@ async def test_create_protocol_run_bad_protocol_id( mock_run_auto_deleter: RunAutoDeleter, mock_data_files_store: DataFilesStore, mock_data_files_directory: Path, + mock_file_provider: FileProvider, ) -> None: """It should 404 if a protocol for a run does not exist.""" error = ProtocolNotFoundError("protocol-id") @@ -309,6 +324,7 @@ async def test_create_protocol_run_bad_protocol_id( run_data_manager=mock_run_data_manager, data_files_store=mock_data_files_store, data_files_directory=mock_data_files_directory, + file_provider=mock_file_provider, run_id="run-id", created_at=datetime.now(), run_auto_deleter=mock_run_auto_deleter, @@ -329,6 +345,7 @@ async def test_create_run_conflict( mock_protocol_store: ProtocolStore, mock_data_files_store: DataFilesStore, mock_data_files_directory: Path, + mock_file_provider: FileProvider, ) -> None: """It should respond with a conflict error if multiple engines are created.""" created_at = datetime(year=2021, month=1, day=1) @@ -342,6 +359,7 @@ async def test_create_run_conflict( created_at=created_at, labware_offsets=[], deck_configuration=[], + file_provider=mock_file_provider, protocol=None, run_time_param_values=None, run_time_param_paths=None, @@ -361,6 +379,7 @@ async def test_create_run_conflict( deck_configuration_store=mock_deck_configuration_store, data_files_store=mock_data_files_store, data_files_directory=mock_data_files_directory, + file_provider=mock_file_provider, notify_publishers=mock_notify_publishers, check_estop=True, ) @@ -387,6 +406,7 @@ async def test_get_run_data_from_url( labware=[], labwareOffsets=[], liquids=[], + outputFileIds=[], hasEverEnteredErrorRecovery=False, ) @@ -434,6 +454,7 @@ async def test_get_run() -> None: labware=[], labwareOffsets=[], liquids=[], + outputFileIds=[], hasEverEnteredErrorRecovery=False, ) @@ -480,6 +501,7 @@ async def test_get_runs_not_empty( labware=[], labwareOffsets=[], liquids=[], + outputFileIds=[], hasEverEnteredErrorRecovery=False, ) @@ -496,6 +518,7 @@ async def test_get_runs_not_empty( labware=[], labwareOffsets=[], liquids=[], + outputFileIds=[], hasEverEnteredErrorRecovery=False, ) @@ -575,6 +598,7 @@ async def test_update_run_to_not_current( labware=[], labwareOffsets=[], liquids=[], + outputFileIds=[], hasEverEnteredErrorRecovery=False, ) @@ -610,6 +634,7 @@ async def test_update_current_none_noop( labware=[], labwareOffsets=[], liquids=[], + outputFileIds=[], hasEverEnteredErrorRecovery=False, ) diff --git a/robot-server/tests/runs/router/test_labware_router.py b/robot-server/tests/runs/router/test_labware_router.py index 1e3b929446d..900eac530f1 100644 --- a/robot-server/tests/runs/router/test_labware_router.py +++ b/robot-server/tests/runs/router/test_labware_router.py @@ -40,6 +40,7 @@ def run() -> Run: labwareOffsets=[], protocolId=None, liquids=[], + outputFileIds=[], hasEverEnteredErrorRecovery=False, ) diff --git a/robot-server/tests/runs/test_run_controller.py b/robot-server/tests/runs/test_run_controller.py index d60e9da6082..b069632a4e4 100644 --- a/robot-server/tests/runs/test_run_controller.py +++ b/robot-server/tests/runs/test_run_controller.py @@ -72,6 +72,7 @@ def engine_state_summary() -> StateSummary: modules=[], liquids=[], wells=[], + files=[], hasEverEnteredErrorRecovery=False, ) diff --git a/robot-server/tests/runs/test_run_data_manager.py b/robot-server/tests/runs/test_run_data_manager.py index 981a0e7177c..869f1c1c643 100644 --- a/robot-server/tests/runs/test_run_data_manager.py +++ b/robot-server/tests/runs/test_run_data_manager.py @@ -52,6 +52,8 @@ ) from robot_server.service.notifications import RunsPublisher from robot_server.service.task_runner import TaskRunner +from opentrons.protocol_engine.resources import FileProvider +from robot_server.file_provider.provider import FileProviderWrapper def mock_notify_publishers() -> None: @@ -141,6 +143,20 @@ def mock_nozzle_maps(decoy: Decoy) -> Dict[str, NozzleMap]: return {"mock-pipette-id": mock_nozzle_map} +@pytest.fixture() +def mock_file_provider_wrapper(decoy: Decoy) -> FileProviderWrapper: + """Return a mock FileProviderWrapper.""" + return decoy.mock(cls=FileProviderWrapper) + + +@pytest.fixture() +def mock_file_provider( + decoy: Decoy, mock_file_provider_wrapper: FileProviderWrapper +) -> FileProvider: + """Return a mock FileProvider.""" + return decoy.mock(cls=FileProvider) + + @pytest.fixture def run_resource() -> RunResource: """Get a StateSummary value object.""" @@ -210,6 +226,7 @@ async def test_create( initial_error_recovery_policy=sentinel.initial_error_recovery_policy, protocol=protocol, deck_configuration=sentinel.deck_configuration, + file_provider=sentinel.file_provider, run_time_param_values=sentinel.run_time_param_values, run_time_param_paths=sentinel.run_time_param_paths, notify_publishers=mock_notify_publishers, @@ -251,6 +268,7 @@ async def test_create( labware_offsets=sentinel.labware_offsets, protocol=protocol, deck_configuration=sentinel.deck_configuration, + file_provider=sentinel.file_provider, run_time_param_values=sentinel.run_time_param_values, run_time_param_paths=sentinel.run_time_param_paths, notify_publishers=mock_notify_publishers, @@ -271,6 +289,7 @@ async def test_create( modules=engine_state_summary.modules, liquids=engine_state_summary.liquids, runTimeParameters=[bool_parameter, file_parameter], + outputFileIds=engine_state_summary.files, ) decoy.verify( mock_run_store.insert_csv_rtp( @@ -284,6 +303,7 @@ async def test_create_engine_error( mock_run_orchestrator_store: RunOrchestratorStore, mock_run_store: RunStore, mock_error_recovery_setting_store: ErrorRecoverySettingStore, + mock_file_provider: FileProvider, subject: RunDataManager, ) -> None: """It should not create a resource if engine creation fails.""" @@ -307,6 +327,7 @@ async def test_create_engine_error( labware_offsets=[], protocol=None, deck_configuration=[], + file_provider=mock_file_provider, run_time_param_values=None, run_time_param_paths=None, notify_publishers=mock_notify_publishers, @@ -321,6 +342,7 @@ async def test_create_engine_error( labware_offsets=[], protocol=None, deck_configuration=[], + file_provider=mock_file_provider, run_time_param_values=None, run_time_param_paths=None, notify_publishers=mock_notify_publishers, @@ -374,6 +396,7 @@ async def test_get_current_run( modules=engine_state_summary.modules, liquids=engine_state_summary.liquids, runTimeParameters=run_time_parameters, + outputFileIds=engine_state_summary.files, ) assert subject.current_run_id == run_id @@ -416,6 +439,7 @@ async def test_get_historical_run( modules=engine_state_summary.modules, liquids=engine_state_summary.liquids, runTimeParameters=run_time_parameters, + outputFileIds=engine_state_summary.files, ) @@ -459,6 +483,7 @@ async def test_get_historical_run_no_data( modules=[], liquids=[], runTimeParameters=run_time_parameters, + outputFileIds=[], ) @@ -560,6 +585,7 @@ async def test_get_all_runs( modules=historical_run_data.modules, liquids=historical_run_data.liquids, runTimeParameters=historical_run_time_parameters, + outputFileIds=historical_run_data.files, ), Run( current=True, @@ -576,6 +602,7 @@ async def test_get_all_runs( modules=current_run_data.modules, liquids=current_run_data.liquids, runTimeParameters=current_run_time_parameters, + outputFileIds=current_run_data.files, ), ] @@ -674,6 +701,7 @@ async def test_update_current( modules=engine_state_summary.modules, liquids=engine_state_summary.liquids, runTimeParameters=run_time_parameters, + outputFileIds=engine_state_summary.files, ) @@ -730,6 +758,7 @@ async def test_update_current_noop( modules=engine_state_summary.modules, liquids=engine_state_summary.liquids, runTimeParameters=run_time_parameters, + outputFileIds=engine_state_summary.files, ) @@ -759,6 +788,7 @@ async def test_create_archives_existing( mock_run_orchestrator_store: RunOrchestratorStore, mock_run_store: RunStore, mock_error_recovery_setting_store: ErrorRecoverySettingStore, + mock_file_provider: FileProvider, subject: RunDataManager, ) -> None: """It should persist the previously current run when a new run is created.""" @@ -792,6 +822,7 @@ async def test_create_archives_existing( protocol=None, initial_error_recovery_policy=sentinel.initial_error_recovery_policy, deck_configuration=[], + file_provider=mock_file_provider, run_time_param_values=None, run_time_param_paths=None, notify_publishers=mock_notify_publishers, @@ -812,6 +843,7 @@ async def test_create_archives_existing( labware_offsets=[], protocol=None, deck_configuration=[], + file_provider=mock_file_provider, run_time_param_values=None, run_time_param_paths=None, notify_publishers=mock_notify_publishers, diff --git a/robot-server/tests/runs/test_run_orchestrator_store.py b/robot-server/tests/runs/test_run_orchestrator_store.py index e34c1340359..1774215acfd 100644 --- a/robot-server/tests/runs/test_run_orchestrator_store.py +++ b/robot-server/tests/runs/test_run_orchestrator_store.py @@ -24,6 +24,7 @@ NoRunOrchestrator, handle_estop_event, ) +from opentrons.protocol_engine.resources import FileProvider def mock_notify_publishers() -> None: @@ -61,6 +62,7 @@ async def test_create_engine(decoy: Decoy, subject: RunOrchestratorStore) -> Non labware_offsets=[], initial_error_recovery_policy=never_recover, protocol=None, + file_provider=FileProvider(), deck_configuration=[], notify_publishers=mock_notify_publishers, ) @@ -90,6 +92,7 @@ async def test_create_engine_uses_robot_type( initial_error_recovery_policy=never_recover, deck_configuration=[], protocol=None, + file_provider=FileProvider(), notify_publishers=mock_notify_publishers, ) @@ -112,6 +115,7 @@ async def test_create_engine_with_labware_offsets( initial_error_recovery_policy=never_recover, deck_configuration=[], protocol=None, + file_provider=FileProvider(), notify_publishers=mock_notify_publishers, ) @@ -136,6 +140,7 @@ async def test_archives_state_if_engine_already_exists( initial_error_recovery_policy=never_recover, deck_configuration=[], protocol=None, + file_provider=FileProvider(), notify_publishers=mock_notify_publishers, ) @@ -146,6 +151,7 @@ async def test_archives_state_if_engine_already_exists( initial_error_recovery_policy=never_recover, deck_configuration=[], protocol=None, + file_provider=FileProvider(), notify_publishers=mock_notify_publishers, ) @@ -160,6 +166,7 @@ async def test_clear_engine(subject: RunOrchestratorStore) -> None: initial_error_recovery_policy=never_recover, deck_configuration=[], protocol=None, + file_provider=FileProvider(), notify_publishers=mock_notify_publishers, ) assert subject._run_orchestrator is not None @@ -182,6 +189,7 @@ async def test_clear_engine_not_stopped_or_idle( initial_error_recovery_policy=never_recover, deck_configuration=[], protocol=None, + file_provider=FileProvider(), notify_publishers=mock_notify_publishers, ) assert subject._run_orchestrator is not None @@ -198,6 +206,7 @@ async def test_clear_idle_engine(subject: RunOrchestratorStore) -> None: initial_error_recovery_policy=never_recover, deck_configuration=[], protocol=None, + file_provider=FileProvider(), notify_publishers=mock_notify_publishers, ) assert subject._run_orchestrator is not None @@ -250,6 +259,7 @@ async def test_get_default_orchestrator_current_unstarted( initial_error_recovery_policy=never_recover, deck_configuration=[], protocol=None, + file_provider=FileProvider(), notify_publishers=mock_notify_publishers, ) @@ -265,6 +275,7 @@ async def test_get_default_orchestrator_conflict(subject: RunOrchestratorStore) initial_error_recovery_policy=never_recover, deck_configuration=[], protocol=None, + file_provider=FileProvider(), notify_publishers=mock_notify_publishers, ) subject.play() @@ -283,6 +294,7 @@ async def test_get_default_orchestrator_run_stopped( initial_error_recovery_policy=never_recover, deck_configuration=[], protocol=None, + file_provider=FileProvider(), notify_publishers=mock_notify_publishers, ) await subject.finish(error=None) diff --git a/robot-server/tests/runs/test_run_store.py b/robot-server/tests/runs/test_run_store.py index 55a1849e693..ce6f8326c22 100644 --- a/robot-server/tests/runs/test_run_store.py +++ b/robot-server/tests/runs/test_run_store.py @@ -132,6 +132,7 @@ def state_summary() -> StateSummary: status=EngineStatus.IDLE, liquids=liquids, wells=[], + files=[], hasEverEnteredErrorRecovery=False, ) @@ -216,6 +217,7 @@ def invalid_state_summary() -> StateSummary: status=EngineStatus.IDLE, liquids=liquids, wells=[], + files=[], ) diff --git a/shared-data/command/schemas/10.json b/shared-data/command/schemas/10.json index 6508269ac62..be8e870c5bb 100644 --- a/shared-data/command/schemas/10.json +++ b/shared-data/command/schemas/10.json @@ -4236,6 +4236,11 @@ "title": "Moduleid", "description": "Unique ID of the Absorbance Reader.", "type": "string" + }, + "fileName": { + "title": "Filename", + "description": "Optional file name to use when storing the results of a measurement.", + "type": "string" } }, "required": ["moduleId"] diff --git a/update-server/oe_upload.py b/update-server/oe_upload.py index 43d8bf47525..9d70dcaf430 100644 --- a/update-server/oe_upload.py +++ b/update-server/oe_upload.py @@ -26,7 +26,7 @@ async def do_update(update_file: str, host: str, timeout = aiohttp.ClientTimeout(total=7200) async with aiohttp.ClientSession(timeout=timeout) as session: root = host + '/server/update' - filename = "ot3-system.zip" + filename = "system-update.zip" print(f"Starting update of {update_file.name} to {host}") begin_resp = await session.post(root + '/begin') if begin_resp.status == 409: