From e55ff9d065432e082071b771ea9c4e8fd8a48abf Mon Sep 17 00:00:00 2001 From: tginsbu1 Date: Fri, 21 Mar 2025 15:31:47 -0500 Subject: [PATCH 01/10] basic functionality --- Dockerfile | 25 + compose.yaml | 20 + pyproject.toml | 144 +++++ src/sciclops_driver.py | 1265 +++++++++++++++++++++++++++++++++++++ src/sciclops_rest_node.py | 73 +++ 5 files changed, 1527 insertions(+) create mode 100644 Dockerfile create mode 100644 compose.yaml create mode 100644 pyproject.toml create mode 100644 src/sciclops_driver.py create mode 100644 src/sciclops_rest_node.py diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4eb26e6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM ghcr.io/ad-sdl/madsci + +LABEL org.opencontainers.image.source=https://github.com/AD-SDL/hudson_platecrane_module +LABEL org.opencontainers.image.description="Drivers and REST API's for the Hudson Platecrane and Sciclops robots" +LABEL org.opencontainers.image.licenses=MIT + +######################################### +# Module specific logic goes below here # +######################################### + +RUN apt update && apt install -y libusb-1.0-0-dev && rm -rf /var/lib/apt/lists/* + +RUN mkdir -p sciclops_module + +COPY ./src sciclops_module/src +COPY ./README.md sciclops_module/README.md +COPY ./pyproject.toml sciclops_module/pyproject.toml + +RUN --mount=type=cache,target=/root/.cache \ + pip install -e ./sciclops_module + +RUN usermod -aG dialout madsci + +CMD ["python", "-,", "sciclops_rest_node"] +######################################### diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..16392ce --- /dev/null +++ b/compose.yaml @@ -0,0 +1,20 @@ +name: sciclops_node +services: + sciclops: + container_name: sciclops + image: sciclops + build: + context: . + dockerfile: Dockerfile + tags: + - sciclops:latest + - sciclops:0.0.1 + - sciclops:dev + command: python -m sciclops_rest_node + privileged: true + env_file: .env + volumes: + - /dev:/dev + - ./src:/home/app/sciclops_module/src + ports: + - 2000:2000 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f62f22b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,144 @@ +[project] +name = "hudson_platecrane_module" +version = "1.5.0" +description = "Driver for the Platecrane and Sciclops" +authors = [ + {name = "Ryan D. Lewis", email="ryan.lewis@anl.gov"}, + {name = "Rafael Vescovi", email="ravescovi@anl.gov"}, + {name = "Doga Ozgulbas", email="dozgulbas@anl.gov"}, + {name = "Abe Stroka", email="astroka@anl.gov"}, + {name = "Kyle Hippe", email = "khippe@anl.gov"}, + {name = "Tobias Ginsburg", email = "tginsburg@anl.gov"}, +] +dependencies = [ + "fastapi>=0.103", + "uvicorn>=0.14.0", + "pyusb", + "libusb", + "pyserial", + "madsci.node_module>=0.1.5", + "pydantic>=2.7", + "pytest" +] +requires-python = ">=3.9.1" +readme = "README.md" +license = {text = "MIT"} + +[project.urls] +homepage = "https://github.com/AD-SDL/sciclops_module" + +###################### +# Build Info + Tools # +###################### +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" + +##################### +# Development Tools # +##################### + +[tool.ruff] +# https://docs.astral.sh/ruff/configuration/ + +# Exclude a variety of commonly ignored directories. +exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".git-rewrite", + ".hg", + ".mypy_cache", + ".nox", + ".pants.d", + ".pytype", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + "venv", + "docs", +] + +# Same as Black. +line-length = 88 +indent-width = 4 + +# Assume Python 3.8 +target-version = "py38" + +[tool.ruff.lint] +# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. +select = [ + # pycodestyle + "E", + # Pyflakes + "F", + # pyupgrade + # "UP", + # flake8-bugbear + "B", + # flake8-simplify + # "SIM", + # isort + "I", + # Warning + "W", + # pydocstyle + "D100", "D101", "D102", "D103", "D104", "D105", "D106", "D107", + # ruff + # "RUF" +] +ignore = [ + "E501" # Line too long +] + +# Allow fix for all enabled rules (when `--fix`) is provided. +fixable = ["ALL"] +unfixable = [] + +# Allow unused variables when underscore-prefixed. +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" + +[tool.ruff.format] +# Like Black, use double quotes for strings. +quote-style = "double" + +# Like Black, indent with spaces, rather than tabs. +indent-style = "space" + +# Like Black, respect magic trailing commas. +skip-magic-trailing-comma = false + +# Like Black, automatically detect the appropriate line ending. +line-ending = "auto" + +[tool.pytest.ini_options] +# https://docs.pytest.org/en/stable/customize.html +addopts = "-x" +junit_family="xunit1" +filterwarnings = [ + "ignore::DeprecationWarning", + "ignore::pottery.exceptions.InefficientAccessWarning", +] +markers = [ + "hardware: marks test as requiring hardware (deselect with '-m \"not hardware\"')", +] + +[tool.mypy] +# https://mypy.readthedocs.io/en/stable/config_file.html#using-a-pyproject-toml +show_error_codes = true +check_untyped_defs = true +follow_imports = "normal" +strict_optional = true +plugins = ["pydantic.mypy"] +strict = true +disallow_untyped_defs = true +implicit_reexport = true diff --git a/src/sciclops_driver.py b/src/sciclops_driver.py new file mode 100644 index 0000000..9f9c2dd --- /dev/null +++ b/src/sciclops_driver.py @@ -0,0 +1,1265 @@ +"""Driver for the Hudson Robotics Sciclops robot.""" + +import asyncio +import re + +import usb.core +import usb.util + + +class SCICLOPS: + """ + Description: + Python interface that allows remote commands to be executed to the Sciclops. + """ + + def __init__(self, VENDOR_ID=0x7513, PRODUCT_ID=0x0002): + """Creates a new SCICLOPS driver object. The default VENDOR_ID and PRODUCT_ID are for the Sciclops robot.""" + self.VENDOR_ID = VENDOR_ID + self.PRODUCT_ID = PRODUCT_ID + self.host_path = self.connect_sciclops() + self.TEACH_PLATE = 15.0 + self.STD_FINGER_LENGTH = 17.2 + self.COMPRESSION_DISTANCE = 3.35 + self.current_pos = [0, 0, 0, 0] + # self.NEST_ADJUSTMENT = 20.0 + self.STATUS = 0 + # self.VERSION = 0 + # self.CONFIG = 0 + self.ERROR = "" + self.GRIPLENGTH = 0 + # self.COLLAPSEDDISTANCE = 0 + # self.STEPSPERUNIT = [0, 0 ,0, 0] + # self.HOMEMSG = "" + # self.OPENMSG = "" + # self.CLOSEMSG = "" + self.labware = self.load_labware() + self.plate_info = self.load_plate_info() + self.success_count = 0 + self.status = self.get_status() + self.error = self.get_error() + self.movement_state = "READY" + + def connect_sciclops(self): + """ + Connect to USB device. If wrong device, inform user + """ + host_path = usb.core.find(idVendor=self.VENDOR_ID, idProduct=self.PRODUCT_ID) + + if host_path is None: + raise Exception("Could not establish connection.") + + else: + print("Device Connected") + return host_path + + def disconnect_robot(self): + """Disconnects from the sciclops robot.""" + try: + usb.util.dispose_resources(self.host_path) + except Exception as err: + print(err) + else: + print("Robot is disconnected") + + def load_plate_info(self): + """ + hard-codes size information for any possible plates + """ + plates = { + "96_well": { + "height": 16.2562, + "grab_exchange": -30, # downward motion from Z = -356.5375 + "grab_lid_exchange": -21, # downward motion from Z = -356.5375 + "grab_tower": -18, # downward motion from 10 above top of plate + "grab_lid_tower": -13, + "grab_lid_nest": -12, + }, + "pcr_plate": { + "height": 15.2762, + "grab_exchange": -28, # from Z = -356.5375 + "grab_lid_exchange": 0, # downward motion from Z = -356.5375, no lid + "grab_tower": -17, # downward motion from 10 above top of plate + "grab_lid_tower": 0, # no lid + "grab_lid_nest": 0, # no lid + }, + } + + return plates + + def load_labware(self, labware_file=None): + """ + Loads plate information which affects get_plate function. + """ + if labware_file: + pass # will load file in the future + + # Dictionary for plate information + labware = { + "tower1": { + "pos": {"Z": 23.5188, "R": 133.5, "Y": 171.9895, "P": 8.6648}, + "type": "pcr_plate", + "howmany": 1, + "grab_height": 8, + "cap_height": 20, + "size": [10, 11, 12], + "has_lid": True, + }, + "tower2": { + "pos": {"Z": 23.5188, "R": 151.3, "Y": 171.4872, "P": 8.4943}, + "type": "96_well", + "howmany": 0, + "grab_height": 8, + "cap_height": 20, + "size": [10, 11, 12], + "has_lid": True, + }, + "tower3": { + "pos": {"Z": 23.5188, "R": 169.5, "Y": 171.4810, "P": 12.4716}, + "type": "96_well", + "howmany": 0, + "grab_height": 8, + "cap_height": 20, + "size": [10, 11, 12], + "has_lid": True, + }, + "tower4": { + "pos": {"Z": 23.5188, "R": 187.5, "Y": 169.4470, "P": 5.9091}, + "type": "96_well", + "howmany": 0, + "grab_height": 8, + "cap_height": 20, + "size": [10, 11, 12], + "has_lid": True, + }, + "tower5": { + "pos": {"Z": 23.5188, "R": 205.4, "Y": 171.2082, "P": 10.8807}, + "type": "96_well", + "howmany": 0, + "grab_height": 8, + "cap_height": 20, + "size": [10, 11, 12], + "has_lid": True, + }, + "lidnest1": { + "pos": {"Z": 23.5188, "R": 169.2706, "Y": 25.7535, "P": 10.2159}, + "type": "96_well", + "howmany": 0, # can only hold one + "size": [10, 11, 12], + "grab_height": 15, # Z of -372.4625 ( 10 above lid) + }, + "lidnest2": { + "pos": {"Z": 23.5188, "R": 201.2665, "Y": 25.7535, "P": 8.0909}, + "type": "96_well", + "howmany": 0, # can only hold one + "size": [10, 11, 12], + "grab_height": 15, + }, + "exchange": { + "pos": {"Z": 23.5188, "R": 109.2741, "Y": 32.7484, "P": 100.8955}, + "type": "96_well", + "howmany": 0, + "size": [10, 11, 2], + "grab_height": 0, + "cap_height": 15, + "has_lid": False, + }, + "neutral": { + "pos": {"Z": 23.5188, "R": 109.2741, "Y": 32.7484, "P": 98.2955} + }, + "trash": {"pos": {"Z": 23.5188, "R": 259.2688, "Y": 62.7497, "P": 98.2670}}, + } + + return labware + + def send_command(self, command): + """ + Sends provided command to Sciclops and stores data outputted by the sciclops. + """ + + self.host_path.write(4, command) + + response_buffer = "Write: " + command + msg = None + + # Adds SciClops output to response_buffer + while msg != command: + # or "success" not in msg or "error" in msg + try: + response = self.host_path.read(0x83, 200, timeout=5000) + except Exception: + break + msg = "".join(chr(i) for i in response) + response_buffer = response_buffer + "Read: " + msg + + print(response_buffer) + + self.success_count = self.success_count + response_buffer.count("0000 Success") + + self.get_error(response_buffer) + + return response_buffer + + def get_error(self, response_buffer=None): + """ + Gets error message from the feedback. + """ + + if not response_buffer: + return + + output_line = response_buffer[response_buffer[:-1].rfind("\n") :] + exp = r"(\d)(\d)(\d)(\d)(.*\w)" # Format of feedback that indicates an error message + output_line = re.search(exp, response_buffer) + + try: + # Checks if specified format is found in the last line of feedback + if output_line[5][5:9] != "0000": + self.ERROR = "ERROR: %s" % output_line[5] + + except Exception: + pass + + ################################ + # Individual Command Functions + + def get_position(self): + """ + Requests and stores sciclops position. + Coordinates: + Z: Vertical axis + R: Base turning axis + Y: Extension axis + P: Gripper turning axis + """ + + command = "GETPOS\r\n" # Command interpreted by Sciclops + out_msg = self.send_command(command) + + try: + # Checks if specified format is found in feedback + exp = r"Z:([-.\d]+), R:([-.\d]+), Y:([-.\d]+), P:([-.\d]+)" # Format of coordinates provided in feedback + find_current_pos = re.search(exp, out_msg) + self.current_pos = [ + float(find_current_pos[1]), + float(find_current_pos[2]), + float(find_current_pos[3]), + float(find_current_pos[4]), + ] + + print(self.current_pos) + except Exception: + pass + + def get_status(self): + """ + Checks status of Sciclops + """ + + command = "STATUS\r\n" # Command interpreted by Sciclops + out_msg = self.send_command(command) + + try: + # Checks if specified format is found in feedback + exp = r"0000 (.*\w)" # Format of feedback that indicates that the rest of the line is the status + find_status = re.search(exp, out_msg) + self.status = find_status[1] + + print(self.status) + + except Exception: + pass + + async def check_complete(self): + """ + Checks to see if current sciclops action has completed + """ + print("Checking if complete") + command = "STATUS\r\n" # Command interpreted by Sciclops + out_msg = self.send_command(command) + + try: + # Checks if specified format is found in feedback + exp = r"0000 (.*\w)" # Format of feedback that indicates that the rest of the line is the status + find_status = re.search(exp, out_msg) + self.status = find_status[1] + self.movement_state = "READY" + + return True + + except Exception: + self.movement_state = "BUSY" + + return False + finally: + await asyncio.sleep(0.1) + + async def check_complete_loop(self): + """ + continuously runs check_complete until it returns True + """ + a = False + while not a: + a = await self.check_complete() + + print("ACTION COMPLETE") + + def get_version(self): + """ + Checks version of Sciclops + """ + + command = "VERSION\r\n" # Command interpreted by Sciclops + out_msg = self.send_command(command) + + try: + # Checks if specified format is found in feedback + exp = r"0000 (.*\w)" # Format of feedback that indicates that the rest of the line is the version + find_version = re.search(exp, out_msg) + self.VERSION = find_version[1] + + print(self.VERSION) + + except Exception: + pass + + # TODO: swings outward and collides with pf400 + def reset(self): + """ + Resets Sciclops + """ + + self.set_speed(5) + + command = "RESET\r\n" # Command interpreted by Sciclops + out_msg = self.send_command(command) + + try: + # Checks if specified format is found in feedback + exp = r"0000 (.*\w)" # Format of feedback that indicates that the rest of the line is the version + find_reset = re.search(exp, out_msg) + self.RESET = find_reset[1] + + print(self.RESET) + + except Exception: + pass + + def get_config(self): + """ + Checks configuration of Sciclops + """ + + command = "GETCONFIG\r\n" # Command interpreted by Sciclops + out_msg = self.send_command(command) + + try: + # Checks if specified format is found in feedback + exp = r"0000 (.*\w)" # Format of feedback that indicates that the rest of the line is the configuration + find_config = re.search(exp, out_msg) + self.CONFIG = find_config[1] + + print(self.CONFIG) + + except Exception: + pass + + def get_grip_length(self): + """ + Checks current length of the gripper (units unknown) of Sciclops + """ + + command = "GETGRIPPERLENGTH\r\n" # Command interpreted by Sciclops + out_msg = self.send_command(command) + + try: + # Checks if specified format is found in feedback + exp = r"0000 (.*\w)" # Format of feedback that indicates that the rest of the line is the gripper length + find_grip_length = re.search(exp, out_msg) + self.GRIPLENGTH = find_grip_length[1] + + print(self.GRIPLENGTH) + + except Exception: + pass + + def get_collapsed_distance(self): + """ + ??? + """ + + command = "GETCOLLAPSEDISTANCE\r\n" # Command interpreted by Sciclops + out_msg = self.send_command(command) + + try: + # Checks if specified format is found in feedback + exp = r"0000 (.*\w)" # Format of feedback that indicates that the rest of the line is the collapsed distance + find_collapsed_distance = re.search(exp, out_msg) + self.COLLAPSEDDISTANCE = find_collapsed_distance[1] + + print(self.COLLAPSEDDISTANCE) + + except Exception: + pass + + def get_steps_per_unit(self): + """ + ??? + """ + + command = "GETSTEPSPERUNIT\r\n" # Command interpreted by Sciclops + out_msg = self.send_command(command) + + try: + # Checks if specified format is found in feedback + exp = r"Z:([-.\d]+),R:([-.\d]+),Y:([-.\d]+),P:([-.\d]+)" # Format of the coordinates provided in feedback + find_steps_per_unit = re.search(exp, out_msg) + self.STEPSPERUNIT = [ + float(find_steps_per_unit[1]), + float(find_steps_per_unit[2]), + float(find_steps_per_unit[3]), + float(find_steps_per_unit[4]), + ] + + print(self.STEPSPERUNIT) + + except Exception: + pass + + def home(self, axis=""): + """ + Homes all of the axes. Returns to neutral position (above exchange) + """ + + # Moves axes to home position + command = "HOME\r\n" # Command interpreted by Sciclops + out_msg = self.send_command(command) + + try: + # Checks if specified format is found in feedback + exp = r"0000 (.*\w)" # Format of feedback that indicates that the rest of the line is the success message + home_msg = re.search(exp, out_msg) + self.HOMEMSG = home_msg[1] + + print(self.HOMEMSG) + except Exception: + pass + + # Moves axes to neutral position (above exchange) + self.move( + R=self.labware["neutral"]["pos"]["R"], + Z=23.5188, + P=self.labware["neutral"]["pos"]["P"], + Y=self.labware["neutral"]["pos"]["Y"], + ) + + def open(self): + """ + Opens gripper + """ + + command = "OPEN\r\n" # Command interpreted by Sciclops + out_msg = self.send_command(command) + + try: + # Checks if specified format is found in feedback + exp = r"0000 (.*\w)" # Format of feedback that indicates that the rest of the line is the success message + open_msg = re.search(exp, out_msg) + self.OPENMSG = open_msg[1] + print(self.OPENMSG) + + except Exception: + pass + + def close(self): + """ + Closes gripper + """ + + command = "CLOSE\r\n" # Command interpreted by Sciclops + out_msg = self.send_command(command) + + try: + # Checks if specified format is found in feedback + exp = r"0000 (.*\w)" # Format of feedback that indicates that the rest of the line is the success message + close_msg = re.search(exp, out_msg) + self.CLOSEMSG = close_msg[1] + + print(self.CLOSEMSG) + except Exception: + pass + + def check_open(self): + """ + Checks if gripper is open + """ + + command = "GETGRIPPERISOPEN\r\n" # Command interpreted by Sciclops + out_msg = self.send_command(command) + + try: + # Checks if specified format is found in feedback + exp = r"0000 (.*\w)" # Format of feedback that indicates that the rest of the line answers if the gripper is open + check_open_msg = re.search(exp, out_msg) + self.CHECKOPENMSG = check_open_msg[1] + + print(self.CHECKOPENMSG) + except Exception: + pass + + def check_closed(self): + """ + Checks if gripper is closed + """ + + command = "GETGRIPPERISCLOSED\r\n" # Command interpreted by Sciclops + out_msg = self.send_command(command) + + try: + # Checks if specified format is found in feedback + exp = r"0000 (.*\w)" # Format of feedback that indicates that the rest of the line answers if the gripper is closed + check_closed_msg = re.search(exp, out_msg) + self.CHECKCLOSEDMSG = check_closed_msg[1] + + print(self.CHECKCLOSEDMSG) + + except Exception: + pass + + def check_plate(self): + """ + ??? + """ + + command = "GETPLATEPRESENT\r\n" # Command interpreted by Sciclops + out_msg = self.send_command(command) + + try: + # Checks if specified format is found in feedback + exp = r"0000 (.*\w)" # Format of feedback that indicates ??? + check_plate_msg = re.search(exp, out_msg) + self.CHECKPLATEMSG = check_plate_msg[1] + + print(self.CHECKPLATEMSG) + + except Exception: + pass + + def set_speed(self, speed): + """ + Changes speed of Sciclops + """ + + command = "SETSPEED %d\r\n" % speed # Command interpreted by Sciclops + out_msg = self.send_command(command) + + try: + # Checks if specified format is found in feedback + exp = r"0000 (.*\w)" # Format of feedback that indicates success message + set_speed_msg = re.search(exp, out_msg) + self.SETSPEEDMSG = set_speed_msg[1] + print(self.SETSPEEDMSG) + except Exception: + pass + + def list_points(self): + """ + Lists all of the preset points + """ + + command = "LISTPOINTS\r\n" # Command interpreted by Sciclops + out_msg = self.send_command(command) + + try: + # Checks if specified format is found in feedback + list_point_msg_index = out_msg.find( + "0000" + ) # Format of feedback that indicates success message + self.LISTPOINTS = out_msg[list_point_msg_index + 4 :] + print(self.LISTPOINTS) + except Exception: + pass + + def jog(self, axis, distance): + """ + Moves the specified axis the specified distance. + """ + + command = "JOG %s,%d\r\n" % (axis, distance) # Command interpreted by Sciclops + out_msg = self.send_command(command) + + try: + # Checks if specified format is found in feedback + jog_msg_index = out_msg.find( + "0000" + ) # Format of feedback that indicates success message + self.JOGMSG = out_msg[jog_msg_index + 4 :] + print(self.JOGMSG) + except Exception: + pass + + def loadpoint(self, R, Z, P, Y): + """ + Adds point to listpoints function + """ + + command = "LOADPOINT R:%s, Z:%s, P:%s, Y:%s, R:%s\r\n" % ( + R, + Z, + P, + Y, + R, + ) # Command interpreted by Sciclops + out_msg = self.send_command(command) + try: + # Checks if specified format is found in feedback + loadpoint_msg_index = out_msg.find( + "0000" + ) # Format of feedback that indicates success message + self.LOADPOINTMSG = out_msg[loadpoint_msg_index + 5 :] + except Exception: + pass + + def deletepoint(self, R, Z, P, Y): + """ + Deletes point from listpoints function + """ + + command = "DELETEPOINT R:%s\r\n" % R # Command interpreted by Sciclops + out_msg = self.send_command(command) + try: + deletepoint_msg_index = out_msg.find( + "0000" + ) # Format of feedback that indicates success message + self.DELETEPOINTMSG = out_msg[deletepoint_msg_index + 5 :] + except Exception: + pass + + def move(self, R, Z, P, Y): + """ + Moves to specified coordinates + """ + + self.loadpoint(R, Z, P, Y) + + command = "MOVE R:%s\r\n" % R + out_msg_move = self.send_command(command) + + try: + # Checks if specified format is found in feedback + move_msg_index = out_msg_move.find( + "0000" + ) # Format of feedback that indicates success message + self.MOVEMSG = out_msg_move[move_msg_index + 4 :] + except Exception: + pass + + self.deletepoint(R, Z, P, Y) + + def move_loc(self, loc): + """ + Move to preset locations located in load_labware function + """ + + # check if loc exists (later) + self.move( + self.labware[loc]["pos"]["R"], + self.labware[loc]["pos"]["Z"], + self.labware[loc]["pos"]["P"], + self.labware[loc]["pos"]["Y"], + ) + + def get_plate(self, location, remove_lid=False, trash=False): + """ + Grabs plate and places on exchange. Paramater is the stack that the Sciclops is requested to remove the plate from. + Format: "Stack" + remove lid and trash bools tell whether to remove lid from plate and whether to throw said lid in the trash or place in nest + """ + + # check to see if plate already on the exchange + # removed for now until labware can be edited in a file + # if self.labware['exchange']['howmany'] != 0: + # print("PLATE ALREADY ON THE EXCHANGE") + # else: + tower_info = self.labware[location] + plate_type = tower_info["type"] + + # Move arm up and to neutral position to avoid hitting any objects + self.open() + self.set_speed(10) # + self.jog("Y", -1000) + self.jog("Z", 1000) + self.set_speed(12) + self.move( + R=self.labware["neutral"]["pos"]["R"], + Z=23.5188, + P=self.labware["neutral"]["pos"]["P"], + Y=self.labware["neutral"]["pos"]["Y"], + ) + + # check coordinates + asyncio.run(self.check_complete_loop()) + + # Move above desired tower + self.set_speed(100) + self.move( + R=tower_info["pos"]["R"], + Z=23.5188, + P=tower_info["pos"]["P"], + Y=tower_info["pos"]["Y"], + ) + # check coordinates + asyncio.run(self.check_complete_loop()) + + # Remove plate from tower + self.close() + self.set_speed(15) + self.jog("Z", -1000) + # move up certain amount + self.jog("Z", 10) + self.open() + grab_height = self.plate_info[plate_type]["grab_tower"] + self.jog("Z", grab_height) + self.close() + self.set_speed(100) + self.jog("Z", 1000) + # check coordinates + # asyncio.run(self.check_complete_loop()) + + # Place in exchange + self.move( + R=self.labware["exchange"]["pos"]["R"], + Z=23.5188, + P=self.labware["exchange"]["pos"]["P"], + Y=self.labware["exchange"]["pos"]["Y"], + ) + # check coordinates + # asyncio.run(self.check_complete_loop()) + self.jog("Z", -380) + self.set_speed(5) + self.jog("Z", -30) + self.open() + self.set_speed(100) + self.jog("Z", 1000) + # check coordinates + # asyncio.run(self.check_complete_loop()) + self.labware["exchange"]["howmany"] += 1 + self.labware["exchange"]["type"] = self.labware[location]["type"] + self.labware["exchange"]["size"] = self.labware[location]["size"] + self.labware["exchange"]["has_lid"] = self.labware[location]["has_lid"] + + # check if lid needs to be removed + if remove_lid: + self.remove_lid(trash=trash) + else: + self.labware["exchange"]["has_lid"] = True + + # Move back to neutral + self.move( + R=self.labware["neutral"]["pos"]["R"], + Z=23.5188, + P=self.labware["neutral"]["pos"]["P"], + Y=self.labware["neutral"]["pos"]["Y"], + ) + # check coordinates + # asyncio.run(self.check_complete_loop()) + + # update labware + self.labware[location]["howmany"] -= 1 + + def limp(self, limp_bool): + """ + Turns on/off limp mode (allows someone to manually move joints) + """ + if limp_bool: + limp_string = "FALSE" + else: + limp_string = "TRUE" + command = "LIMP %s" % limp_string # Command interpreted by Sciclops + self.send_command(command) + + def check_for_lid( + self, + ): # TODO: conditional for no available lid to prevent delays? + """Checks all lid nests to see if there's a lid of same type present, returns occupied lid nest""" + if ( + self.labware["lidnest1"]["howmany"] >= 1 + and self.labware["lidnest1"]["type"] == self.labware["exchange"]["type"] + ): + return "lidnest1" + elif ( + self.labware["lidnest2"]["howmany"] >= 1 + and self.labware["lidnest2"]["type"] == self.labware["exchange"]["type"] + ): + return "lidnest2" + else: + print("NO MATCHING LID IN LID NESTS") + pass + + # * check all lid nests to see if there's an empty "available" lid nst, returns open lid nest + def check_for_empty_nest( + self, + ): # TODO: maybe add conditional to throw away lids in nests if none available? + """Check all lid nests to see if there's an empty "available" lid nest, returns open lid nest""" + if self.labware["lidnest1"]["howmany"] == 0: + return "lidnest1" + elif self.labware["lidnest2"]["howmany"] == 0: + return "lidnest2" + else: + print("NO AVAILABLE LID NESTS") + pass + + def check_stack(self, tower): + """Check a stack to see if there's room for another plate, returns True if there is room, False if not.""" + # save z height of stack Z = -36 + tower_z_height = -50 + tower_z_bottom = -421.8625 + # get type of plate in desired tower + plate_type = self.labware[tower]["type"] + # get height of plate type + plate_height = self.plate_info[plate_type]["height"] + # number of plates in stack + num_stack = self.labware[tower]["howmany"] + total_height = plate_height * num_stack + remaining = total_height + tower_z_bottom + if remaining < tower_z_height: # room for another plate + return True + else: # stack full + return False + + def remove_lid(self, trash): + """Remove lid, (self, lidnest, plate_type), removes lid from plate in exchange, trash bool will throw lid into trash""" + # move above plate exchange + self.set_speed(100) + self.open() + self.move( + R=self.labware["exchange"]["pos"]["R"], + Z=23.5188, + P=self.labware["exchange"]["pos"]["P"], + Y=self.labware["exchange"]["pos"]["Y"], + ) + # check coordinates + asyncio.run(self.check_complete_loop()) + plate_type = self.labware["exchange"]["type"] + + # check to make sure plate has lid + if not self.labware["exchange"]["has_lid"]: + print("NO LID ON PLATE IN EXCHANGE") + else: + # remove lid + self.jog("Z", -380) + self.set_speed(7) + lid_height = self.plate_info[plate_type]["grab_lid_exchange"] + self.jog("Z", lid_height) + self.close() + + self.set_speed(100) + self.jog("Z", 1000) + # check coordinates + asyncio.run(self.check_complete_loop()) + + if trash: + # move above trash + self.move( + R=self.labware["trash"]["pos"]["R"], + Z=23.5188, + P=self.labware["trash"]["pos"]["P"], + Y=self.labware["trash"]["pos"]["Y"], + ) + asyncio.run(self.check_complete_loop()) + + # drop in trash + self.jog("Z", -400) + self.open() + self.jog("Z", 1000) + + # return to home + self.move( + R=self.labware["neutral"]["pos"]["R"], + Z=23.5188, + P=self.labware["neutral"]["pos"]["P"], + Y=self.labware["neutral"]["pos"]["Y"], + ) + # check coordinates + asyncio.run(self.check_complete_loop()) + + # update labware + self.labware["exchange"]["has_lid"] = False + else: + # find empty plate nest + lid_nest = self.check_for_empty_nest() + + # move above desired lid nest + self.move( + R=self.labware[lid_nest]["pos"]["R"], + Z=23.5188, + P=self.labware[lid_nest]["pos"]["P"], + Y=self.labware[lid_nest]["pos"]["Y"], + ) + # check coordinates + asyncio.run(self.check_complete_loop()) + + # place in lid nest + self.jog("Z", -400) + self.open() + self.jog("Z", 1000) + # check coordinates + asyncio.run(self.check_complete_loop()) + + # return to home + self.move( + R=self.labware["neutral"]["pos"]["R"], + Z=23.5188, + P=self.labware["neutral"]["pos"]["P"], + Y=self.labware["neutral"]["pos"]["Y"], + ) + # check coordinates + asyncio.run(self.check_complete_loop()) + + # update labware dict + self.labware[lid_nest]["howmany"] += 1 + self.labware[lid_nest]["type"] = self.labware["exchange"]["type"] + self.labware["exchange"]["has_lid"] = False + + def replace_lid(self): + """Plate on exchange, replace lid (self, plateinfo, lidnest)""" + # find a lid + self.set_speed(100) + self.open() + lid_nest = self.check_for_lid() + plate_type = self.labware["exchange"]["type"] + + # make sure current plate doesn't already have lid + if self.labware["exchange"]["has_lid"]: + print("PLATE IN EXCHANGE ALREADY HAS LID") + else: + # move above desired lidnest + self.move( + R=self.labware[lid_nest]["pos"]["R"], + Z=23.5188, + P=self.labware[lid_nest]["pos"]["P"], + Y=self.labware[lid_nest]["pos"]["Y"], + ) + # check coordinates + asyncio.run(self.check_complete_loop()) + + # grab lid + self.close() + self.jog("Z", -380) + self.set_speed(7) + self.jog("Z", -1000) + self.jog("Z", 10) + self.open() + lid_height = self.plate_info[plate_type]["grab_lid_nest"] + self.jog("Z", lid_height) + self.close() + self.set_speed(100) + self.jog("Z", 1000) + asyncio.run(self.check_complete_loop()) + + # move above exchange + self.move( + R=self.labware["exchange"]["pos"]["R"], + Z=23.5188, + P=self.labware["exchange"]["pos"]["P"], + Y=self.labware["exchange"]["pos"]["Y"], + ) + # check coordinates + asyncio.run(self.check_complete_loop()) + + # place lid onto plate + self.jog("Z", -400) + self.open() + self.jog("Z", 1000) + asyncio.run(self.check_complete_loop()) + + # return to home + self.move( + R=self.labware["neutral"]["pos"]["R"], + Z=23.5188, + P=self.labware["neutral"]["pos"]["P"], + Y=self.labware["neutral"]["pos"]["Y"], + ) + # check coordinates + asyncio.run(self.check_complete_loop()) + + # update labware dict + self.labware[lid_nest]["howmany"] -= 1 + self.labware["exchange"]["has_lid"] = True + + def plate_to_stack(self, tower, add_lid): + """Plate from exchange to stack (self, tower, plateinfo)""" + # Move arm up and to neutral position to avoid hitting any objects + self.open() + self.set_speed(10) + self.jog("Y", -1000) + self.jog("Z", 1000) + self.set_speed(12) + self.move( + R=self.labware["neutral"]["pos"]["R"], + Z=23.5188, + P=self.labware["neutral"]["pos"]["P"], + Y=self.labware["neutral"]["pos"]["Y"], + ) + asyncio.run(self.check_complete_loop()) + plate_type = self.labware["exchange"]["type"] + if add_lid: + self.check_for_lid() + self.replace_lid() + + # TODO: check to see if given stack is full, use function to account for different labware, maybe checks all stacks to find one with same labware? + + # move over exchange + self.open() + self.move( + R=self.labware["exchange"]["pos"]["R"], + Z=23.5188, + P=self.labware["exchange"]["pos"]["P"], + Y=self.labware["exchange"]["pos"]["Y"], + ) + # check coordinates + asyncio.run(self.check_complete_loop()) + # grab plate + self.set_speed(100) + self.jog("Z", -380) + grab_height = self.plate_info[plate_type]["grab_exchange"] + self.jog("Z", grab_height) + self.close() + self.set_speed(100) + self.jog("Z", 1000) + asyncio.run(self.check_complete_loop()) + + # move above tower, place plate in tower + self.move( + R=self.labware[tower]["pos"]["R"], + Z=23.5188, + P=self.labware[tower]["pos"]["P"], + Y=self.labware[tower]["pos"]["Y"], + ) + # check coordinates + asyncio.run(self.check_complete_loop()) + self.set_speed(10) + self.jog("Z", -1000) + self.open() + self.set_speed(100) + self.jog("Z", 1000) + # check coordinates + asyncio.run(self.check_complete_loop()) + + # move to home + self.move( + R=self.labware["neutral"]["pos"]["R"], + Z=23.5188, + P=self.labware["neutral"]["pos"]["P"], + Y=self.labware["neutral"]["pos"]["Y"], + ) + # check coordinates + asyncio.run(self.check_complete_loop()) + + # update labware dict + self.labware["exchange"]["howmany"] -= 1 + self.labware[tower]["howmany"] += 1 + + def lidnest_to_trash(self, lidnest): + """Remove lid from lidnest, throw away""" + # Move arm up and to neutral position to avoid hitting any objects + self.open() + self.set_speed(10) + self.jog("Y", -1000) + self.jog("Z", 1000) + self.set_speed(12) + self.move( + R=self.labware["neutral"]["pos"]["R"], + Z=23.5188, + P=self.labware["neutral"]["pos"]["P"], + Y=self.labware["neutral"]["pos"]["Y"], + ) + asyncio.run(self.check_complete_loop()) + # check to make sure lid present + if self.labware[lidnest]["howmany"] >= 1: # lid in nest + lid_type = self.labware[lidnest]["type"] + # move above lidnest + self.set_speed(100) + self.close() + self.move( + R=self.labware[lidnest]["pos"]["R"], + Z=23.5188, + P=self.labware[lidnest]["pos"]["P"], + Y=self.labware[lidnest]["pos"]["Y"], + ) + asyncio.run(self.check_complete_loop()) + + # grab lid + self.jog("Z", -380) + self.set_speed(7) + self.jog("Z", -1000) + self.jog("Z", 10) + self.open() + lid_height = self.plate_info[lid_type]["grab_lid_nest"] + self.jog("Z", lid_height) + self.close() + self.set_speed(100) + self.jog("Z", 1000) + asyncio.run(self.check_complete_loop()) + + # move above trash + self.move( + R=self.labware["trash"]["pos"]["R"], + Z=23.5188, + P=self.labware["trash"]["pos"]["P"], + Y=self.labware["trash"]["pos"]["Y"], + ) + asyncio.run(self.check_complete_loop()) + + # drop lid + self.jog("Z", -1000) + self.open() + self.jog("Z", 1000) + asyncio.run(self.check_complete_loop()) + + # back to neutral + self.move( + R=self.labware["neutral"]["pos"]["R"], + Z=23.5188, + P=self.labware["neutral"]["pos"]["P"], + Y=self.labware["neutral"]["pos"]["Y"], + ) + asyncio.run(self.check_complete_loop()) + + # update labware + self.labware[lidnest]["howmany"] -= 1 + + else: + print("NO LID IN NEST") + + def plate_to_trash(self, add_lid): + """Remove plate from exchange, throw away""" + # Move arm up and to neutral position to avoid hitting any objects + self.open() + self.set_speed(10) + self.jog("Y", -1000) + self.jog("Z", 1000) + self.set_speed(12) + self.move( + R=self.labware["neutral"]["pos"]["R"], + Z=23.5188, + P=self.labware["neutral"]["pos"]["P"], + Y=self.labware["neutral"]["pos"]["Y"], + ) + asyncio.run(self.check_complete_loop()) + # check if plate is present + if self.labware["exchange"]["howmany"] >= 1: + # check if add_lid is true, if yes, add lid + if add_lid: + self.replace_lid() + + plate_type = self.labware["exchange"]["type"] + + # move over exchange + self.set_speed(100) + self.open() + self.move( + R=self.labware["exchange"]["pos"]["R"], + Z=23.5188, + P=self.labware["exchange"]["pos"]["P"], + Y=self.labware["exchange"]["pos"]["Y"], + ) + asyncio.run(self.check_complete_loop()) + + # grab plate + self.jog("Z", -380) + self.set_speed(7) + grab_height = self.plate_info[plate_type]["grab_plate_exchange"] + self.jog("Z", grab_height) + self.close() + self.set_speed(100) + self.jog("Z", 1000) + asyncio.run(self.check_complete_loop()) + + # move over trash + self.move( + R=self.labware["trash"]["pos"]["R"], + Z=23.5188, + P=self.labware["trash"]["pos"]["P"], + Y=self.labware["trash"]["pos"]["Y"], + ) + asyncio.run(self.check_complete_loop()) + + # drop plate + self.jog("Z", -1000) + self.open() + self.jog("Z", 1000) + asyncio.run(self.check_complete_loop()) + + # back to neutral + self.move( + R=self.labware["neutral"]["pos"]["R"], + Z=23.5188, + P=self.labware["neutral"]["pos"]["P"], + Y=self.labware["neutral"]["pos"]["Y"], + ) + asyncio.run(self.check_complete_loop()) + + # update labware + self.labware["exchange"]["howmany"] -= 1 + + else: + print("NO PLATE IN EXCHANGE") + + +if __name__ == "__main__": + """ + Runs given function. + """ + s = SCICLOPS() + # s.get_error() + # s.get_status() + # for i in range(1): + # # s.jog("R", -1000) + # # s.reset() + # # sleep(5) + # # s.home() + # # s.send_command("") + # # sleep(5) + # s.get_plate("tower4") + # # sleep(25) + # print("STATUS MSG: ", s.status) + # s.check_closed() + # print(s.CURRENT_POS) + s.reset() + # s.home() + # s.get_plate("tower1") + # dummy_sciclops.check_plate() + +# Finished commands +# "GETPOS" +# "STATUS" +# "VERSION" +# "GETCONFIG" +# "GETGRIPPERLENGTH" +# "GETCOLLAPSEDISTANCE" +# "GETSTEPSPERUNIT" +# "HOME" +# "OPEN" +# "CLOSE" +# "GETGRIPPERISCLOSED" +# "GETGRIPPERISOPEN" +# "GETPLATEPRESENT" +# "SETSPEED " +# "MOVE " +# "JOG" +# "DELETEPOINT (ADD POINT)" +# "LISTPOINTS" +# "LOADPOINT R:0,Z:0,P:0,Y:0" +# "LIMP TRUE/FALSE" + +# #Unfinished Commands +# "GETLIMITS" + +# #Unknown Commands +# "LISTMOTIONS" +# "AUTOTEACH" +# "GETPOINT" +# "READINP 15" +# "GETGRIPSTRENGTH" +# "READINP" diff --git a/src/sciclops_rest_node.py b/src/sciclops_rest_node.py new file mode 100644 index 0000000..f35a7e5 --- /dev/null +++ b/src/sciclops_rest_node.py @@ -0,0 +1,73 @@ +#! /usr/bin/env python3 +"""The server for the Hudson Platecrane/Sciclops that takes incoming WEI flow requests from the experiment application""" + +from pathlib import Path + +from sciclops_driver import SCICLOPS +from typing_extensions import Annotated +from madsci.node_module.rest_node_module import RestNode +from typing import Union +from madsci.common.types.node_types import RestNodeConfig +from madsci.node_module.abstract_node_module import action +from madsci.common.types.action_types import ActionResult, ActionSucceeded + + +class SciclopsConfig(RestNodeConfig): + """Configuration for the camera node module.""" + + sciclops_address: Union[int, str] = 0 + """The sciclops usb address, a device path in Linux/Mac.""" + + + +class SciclopsNode(RestNode): + config_model = SciclopsConfig + + def startup_handler(self): + """Initial run function for the app, initializes the state + Parameters + ---------- + app : FastApi + The REST API app being initialized + + Returns + ------- + None""" + print("Hello, World!") + try: + self.sciclops = SCICLOPS() + except Exception as error_msg: + print("------- SCICLOPS Error message: " + str(error_msg) + (" -------")) + raise(error_msg) + else: + print("SCICLOPS online") + + @action(name="status") + def status(self): + """Action that forces the sciclops to check its status.""" + + self.sciclops.get_status() + return ActionSucceeded() + + + @action + def home(self): + """Homes the sciclops""" + self.sciclops.home() + return ActionSucceeded() + + + @action(name="get_plate") + def get_plate( + self, + pos: Annotated[int, "Stack to get plate from"], + lid: Annotated[bool, "Whether plate has a lid or not"] = False, + trash: Annotated[bool, "Whether to use the trash"] = False, + ): + """Get a plate from a stack position and move it to transfer point (or trash)""" + self.sciclops.get_plate(pos, lid, trash) + return ActionSucceeded() + +if __name__ == "__main__": + sciclops_node = SciclopsNode() + sciclops_node.start_node() From 2de7043555039037532d0d899256ba253b31238d Mon Sep 17 00:00:00 2001 From: tginsbu1 Date: Fri, 21 Mar 2025 22:18:14 -0500 Subject: [PATCH 02/10] cleaning and improving --- src/sciclops_driver.py | 630 ++------------------------------------ src/sciclops_rest_node.py | 76 ++++- 2 files changed, 98 insertions(+), 608 deletions(-) diff --git a/src/sciclops_driver.py b/src/sciclops_driver.py index 9f9c2dd..98309c9 100644 --- a/src/sciclops_driver.py +++ b/src/sciclops_driver.py @@ -6,6 +6,7 @@ import usb.core import usb.util +from madsci.common.types.node_types import RestNodeConfig class SCICLOPS: """ @@ -13,28 +14,17 @@ class SCICLOPS: Python interface that allows remote commands to be executed to the Sciclops. """ - def __init__(self, VENDOR_ID=0x7513, PRODUCT_ID=0x0002): + def __init__(self, config: RestNodeConfig): """Creates a new SCICLOPS driver object. The default VENDOR_ID and PRODUCT_ID are for the Sciclops robot.""" - self.VENDOR_ID = VENDOR_ID - self.PRODUCT_ID = PRODUCT_ID + self.VENDOR_ID = config.vendor_id + self.PRODUCT_ID = config.product_id + self.neutral_joints = config.neutral_joints self.host_path = self.connect_sciclops() - self.TEACH_PLATE = 15.0 - self.STD_FINGER_LENGTH = 17.2 - self.COMPRESSION_DISTANCE = 3.35 + self.exchange_location = config.exchange_location self.current_pos = [0, 0, 0, 0] - # self.NEST_ADJUSTMENT = 20.0 self.STATUS = 0 - # self.VERSION = 0 - # self.CONFIG = 0 self.ERROR = "" - self.GRIPLENGTH = 0 - # self.COLLAPSEDDISTANCE = 0 - # self.STEPSPERUNIT = [0, 0 ,0, 0] - # self.HOMEMSG = "" - # self.OPENMSG = "" - # self.CLOSEMSG = "" - self.labware = self.load_labware() - self.plate_info = self.load_plate_info() + self.plate_info = config.plate_info self.success_count = 0 self.status = self.get_status() self.error = self.get_error() @@ -62,116 +52,6 @@ def disconnect_robot(self): else: print("Robot is disconnected") - def load_plate_info(self): - """ - hard-codes size information for any possible plates - """ - plates = { - "96_well": { - "height": 16.2562, - "grab_exchange": -30, # downward motion from Z = -356.5375 - "grab_lid_exchange": -21, # downward motion from Z = -356.5375 - "grab_tower": -18, # downward motion from 10 above top of plate - "grab_lid_tower": -13, - "grab_lid_nest": -12, - }, - "pcr_plate": { - "height": 15.2762, - "grab_exchange": -28, # from Z = -356.5375 - "grab_lid_exchange": 0, # downward motion from Z = -356.5375, no lid - "grab_tower": -17, # downward motion from 10 above top of plate - "grab_lid_tower": 0, # no lid - "grab_lid_nest": 0, # no lid - }, - } - - return plates - - def load_labware(self, labware_file=None): - """ - Loads plate information which affects get_plate function. - """ - if labware_file: - pass # will load file in the future - - # Dictionary for plate information - labware = { - "tower1": { - "pos": {"Z": 23.5188, "R": 133.5, "Y": 171.9895, "P": 8.6648}, - "type": "pcr_plate", - "howmany": 1, - "grab_height": 8, - "cap_height": 20, - "size": [10, 11, 12], - "has_lid": True, - }, - "tower2": { - "pos": {"Z": 23.5188, "R": 151.3, "Y": 171.4872, "P": 8.4943}, - "type": "96_well", - "howmany": 0, - "grab_height": 8, - "cap_height": 20, - "size": [10, 11, 12], - "has_lid": True, - }, - "tower3": { - "pos": {"Z": 23.5188, "R": 169.5, "Y": 171.4810, "P": 12.4716}, - "type": "96_well", - "howmany": 0, - "grab_height": 8, - "cap_height": 20, - "size": [10, 11, 12], - "has_lid": True, - }, - "tower4": { - "pos": {"Z": 23.5188, "R": 187.5, "Y": 169.4470, "P": 5.9091}, - "type": "96_well", - "howmany": 0, - "grab_height": 8, - "cap_height": 20, - "size": [10, 11, 12], - "has_lid": True, - }, - "tower5": { - "pos": {"Z": 23.5188, "R": 205.4, "Y": 171.2082, "P": 10.8807}, - "type": "96_well", - "howmany": 0, - "grab_height": 8, - "cap_height": 20, - "size": [10, 11, 12], - "has_lid": True, - }, - "lidnest1": { - "pos": {"Z": 23.5188, "R": 169.2706, "Y": 25.7535, "P": 10.2159}, - "type": "96_well", - "howmany": 0, # can only hold one - "size": [10, 11, 12], - "grab_height": 15, # Z of -372.4625 ( 10 above lid) - }, - "lidnest2": { - "pos": {"Z": 23.5188, "R": 201.2665, "Y": 25.7535, "P": 8.0909}, - "type": "96_well", - "howmany": 0, # can only hold one - "size": [10, 11, 12], - "grab_height": 15, - }, - "exchange": { - "pos": {"Z": 23.5188, "R": 109.2741, "Y": 32.7484, "P": 100.8955}, - "type": "96_well", - "howmany": 0, - "size": [10, 11, 2], - "grab_height": 0, - "cap_height": 15, - "has_lid": False, - }, - "neutral": { - "pos": {"Z": 23.5188, "R": 109.2741, "Y": 32.7484, "P": 98.2955} - }, - "trash": {"pos": {"Z": 23.5188, "R": 259.2688, "Y": 62.7497, "P": 98.2670}}, - } - - return labware - def send_command(self, command): """ Sends provided command to Sciclops and stores data outputted by the sciclops. @@ -376,17 +256,12 @@ def get_grip_length(self): # Checks if specified format is found in feedback exp = r"0000 (.*\w)" # Format of feedback that indicates that the rest of the line is the gripper length find_grip_length = re.search(exp, out_msg) - self.GRIPLENGTH = find_grip_length[1] + self.griplength = find_grip_length[1] - print(self.GRIPLENGTH) + print(self.griplength) - except Exception: - pass - - def get_collapsed_distance(self): - """ - ??? - """ + except Exception:self.labware[location] + command = "GETCOLLAPSEDISTANCE\r\n" # Command interpreted by Sciclops out_msg = self.send_command(command) @@ -417,7 +292,7 @@ def get_steps_per_unit(self): self.STEPSPERUNIT = [ float(find_steps_per_unit[1]), float(find_steps_per_unit[2]), - float(find_steps_per_unit[3]), + float(find_stepsGET_per_unit[3]), float(find_steps_per_unit[4]), ] @@ -446,12 +321,7 @@ def home(self, axis=""): pass # Moves axes to neutral position (above exchange) - self.move( - R=self.labware["neutral"]["pos"]["R"], - Z=23.5188, - P=self.labware["neutral"]["pos"]["P"], - Y=self.labware["neutral"]["pos"]["Y"], - ) + def open(self): """ @@ -655,21 +525,15 @@ def move(self, R, Z, P, Y): pass self.deletepoint(R, Z, P, Y) - - def move_loc(self, loc): - """ - Move to preset locations located in load_labware function - """ - - # check if loc exists (later) + + def move_neutral(self): self.move( - self.labware[loc]["pos"]["R"], - self.labware[loc]["pos"]["Z"], - self.labware[loc]["pos"]["P"], - self.labware[loc]["pos"]["Y"], + R=self.neutral_joints["R"], + Z=self.neutral_joints["Z"], + P=self.neutral_joints["P"], + Y=self.neutral_joints["Y"], ) - - def get_plate(self, location, remove_lid=False, trash=False): + def get_plate(self, location): """ Grabs plate and places on exchange. Paramater is the stack that the Sciclops is requested to remove the plate from. Format: "Stack" @@ -681,8 +545,7 @@ def get_plate(self, location, remove_lid=False, trash=False): # if self.labware['exchange']['howmany'] != 0: # print("PLATE ALREADY ON THE EXCHANGE") # else: - tower_info = self.labware[location] - plate_type = tower_info["type"] + plate_type = "96_well" # Move arm up and to neutral position to avoid hitting any objects self.open() @@ -690,12 +553,7 @@ def get_plate(self, location, remove_lid=False, trash=False): self.jog("Y", -1000) self.jog("Z", 1000) self.set_speed(12) - self.move( - R=self.labware["neutral"]["pos"]["R"], - Z=23.5188, - P=self.labware["neutral"]["pos"]["P"], - Y=self.labware["neutral"]["pos"]["Y"], - ) + self.move_neutral # check coordinates asyncio.run(self.check_complete_loop()) @@ -703,15 +561,15 @@ def get_plate(self, location, remove_lid=False, trash=False): # Move above desired tower self.set_speed(100) self.move( - R=tower_info["pos"]["R"], + R=location["R"], Z=23.5188, - P=tower_info["pos"]["P"], - Y=tower_info["pos"]["Y"], + P=location["P"], + Y=location["Y"], ) # check coordinates asyncio.run(self.check_complete_loop()) - # Remove plate from tower + # Remove plate from towertower_info["type"] self.close() self.set_speed(15) self.jog("Z", -1000) @@ -728,10 +586,10 @@ def get_plate(self, location, remove_lid=False, trash=False): # Place in exchange self.move( - R=self.labware["exchange"]["pos"]["R"], + R=self.exchange_location["R"], Z=23.5188, - P=self.labware["exchange"]["pos"]["P"], - Y=self.labware["exchange"]["pos"]["Y"], + P=self.exchange_location["P"], + Y=self.exchange_location["Y"], ) # check coordinates # asyncio.run(self.check_complete_loop()) @@ -743,30 +601,12 @@ def get_plate(self, location, remove_lid=False, trash=False): self.jog("Z", 1000) # check coordinates # asyncio.run(self.check_complete_loop()) - self.labware["exchange"]["howmany"] += 1 - self.labware["exchange"]["type"] = self.labware[location]["type"] - self.labware["exchange"]["size"] = self.labware[location]["size"] - self.labware["exchange"]["has_lid"] = self.labware[location]["has_lid"] - # check if lid needs to be removed - if remove_lid: - self.remove_lid(trash=trash) - else: - self.labware["exchange"]["has_lid"] = True - # Move back to neutral - self.move( - R=self.labware["neutral"]["pos"]["R"], - Z=23.5188, - P=self.labware["neutral"]["pos"]["P"], - Y=self.labware["neutral"]["pos"]["Y"], - ) + self.move_neutral() # check coordinates # asyncio.run(self.check_complete_loop()) - # update labware - self.labware[location]["howmany"] -= 1 - def limp(self, limp_bool): """ Turns on/off limp mode (allows someone to manually move joints) @@ -778,214 +618,6 @@ def limp(self, limp_bool): command = "LIMP %s" % limp_string # Command interpreted by Sciclops self.send_command(command) - def check_for_lid( - self, - ): # TODO: conditional for no available lid to prevent delays? - """Checks all lid nests to see if there's a lid of same type present, returns occupied lid nest""" - if ( - self.labware["lidnest1"]["howmany"] >= 1 - and self.labware["lidnest1"]["type"] == self.labware["exchange"]["type"] - ): - return "lidnest1" - elif ( - self.labware["lidnest2"]["howmany"] >= 1 - and self.labware["lidnest2"]["type"] == self.labware["exchange"]["type"] - ): - return "lidnest2" - else: - print("NO MATCHING LID IN LID NESTS") - pass - - # * check all lid nests to see if there's an empty "available" lid nst, returns open lid nest - def check_for_empty_nest( - self, - ): # TODO: maybe add conditional to throw away lids in nests if none available? - """Check all lid nests to see if there's an empty "available" lid nest, returns open lid nest""" - if self.labware["lidnest1"]["howmany"] == 0: - return "lidnest1" - elif self.labware["lidnest2"]["howmany"] == 0: - return "lidnest2" - else: - print("NO AVAILABLE LID NESTS") - pass - - def check_stack(self, tower): - """Check a stack to see if there's room for another plate, returns True if there is room, False if not.""" - # save z height of stack Z = -36 - tower_z_height = -50 - tower_z_bottom = -421.8625 - # get type of plate in desired tower - plate_type = self.labware[tower]["type"] - # get height of plate type - plate_height = self.plate_info[plate_type]["height"] - # number of plates in stack - num_stack = self.labware[tower]["howmany"] - total_height = plate_height * num_stack - remaining = total_height + tower_z_bottom - if remaining < tower_z_height: # room for another plate - return True - else: # stack full - return False - - def remove_lid(self, trash): - """Remove lid, (self, lidnest, plate_type), removes lid from plate in exchange, trash bool will throw lid into trash""" - # move above plate exchange - self.set_speed(100) - self.open() - self.move( - R=self.labware["exchange"]["pos"]["R"], - Z=23.5188, - P=self.labware["exchange"]["pos"]["P"], - Y=self.labware["exchange"]["pos"]["Y"], - ) - # check coordinates - asyncio.run(self.check_complete_loop()) - plate_type = self.labware["exchange"]["type"] - - # check to make sure plate has lid - if not self.labware["exchange"]["has_lid"]: - print("NO LID ON PLATE IN EXCHANGE") - else: - # remove lid - self.jog("Z", -380) - self.set_speed(7) - lid_height = self.plate_info[plate_type]["grab_lid_exchange"] - self.jog("Z", lid_height) - self.close() - - self.set_speed(100) - self.jog("Z", 1000) - # check coordinates - asyncio.run(self.check_complete_loop()) - - if trash: - # move above trash - self.move( - R=self.labware["trash"]["pos"]["R"], - Z=23.5188, - P=self.labware["trash"]["pos"]["P"], - Y=self.labware["trash"]["pos"]["Y"], - ) - asyncio.run(self.check_complete_loop()) - - # drop in trash - self.jog("Z", -400) - self.open() - self.jog("Z", 1000) - - # return to home - self.move( - R=self.labware["neutral"]["pos"]["R"], - Z=23.5188, - P=self.labware["neutral"]["pos"]["P"], - Y=self.labware["neutral"]["pos"]["Y"], - ) - # check coordinates - asyncio.run(self.check_complete_loop()) - - # update labware - self.labware["exchange"]["has_lid"] = False - else: - # find empty plate nest - lid_nest = self.check_for_empty_nest() - - # move above desired lid nest - self.move( - R=self.labware[lid_nest]["pos"]["R"], - Z=23.5188, - P=self.labware[lid_nest]["pos"]["P"], - Y=self.labware[lid_nest]["pos"]["Y"], - ) - # check coordinates - asyncio.run(self.check_complete_loop()) - - # place in lid nest - self.jog("Z", -400) - self.open() - self.jog("Z", 1000) - # check coordinates - asyncio.run(self.check_complete_loop()) - - # return to home - self.move( - R=self.labware["neutral"]["pos"]["R"], - Z=23.5188, - P=self.labware["neutral"]["pos"]["P"], - Y=self.labware["neutral"]["pos"]["Y"], - ) - # check coordinates - asyncio.run(self.check_complete_loop()) - - # update labware dict - self.labware[lid_nest]["howmany"] += 1 - self.labware[lid_nest]["type"] = self.labware["exchange"]["type"] - self.labware["exchange"]["has_lid"] = False - - def replace_lid(self): - """Plate on exchange, replace lid (self, plateinfo, lidnest)""" - # find a lid - self.set_speed(100) - self.open() - lid_nest = self.check_for_lid() - plate_type = self.labware["exchange"]["type"] - - # make sure current plate doesn't already have lid - if self.labware["exchange"]["has_lid"]: - print("PLATE IN EXCHANGE ALREADY HAS LID") - else: - # move above desired lidnest - self.move( - R=self.labware[lid_nest]["pos"]["R"], - Z=23.5188, - P=self.labware[lid_nest]["pos"]["P"], - Y=self.labware[lid_nest]["pos"]["Y"], - ) - # check coordinates - asyncio.run(self.check_complete_loop()) - - # grab lid - self.close() - self.jog("Z", -380) - self.set_speed(7) - self.jog("Z", -1000) - self.jog("Z", 10) - self.open() - lid_height = self.plate_info[plate_type]["grab_lid_nest"] - self.jog("Z", lid_height) - self.close() - self.set_speed(100) - self.jog("Z", 1000) - asyncio.run(self.check_complete_loop()) - - # move above exchange - self.move( - R=self.labware["exchange"]["pos"]["R"], - Z=23.5188, - P=self.labware["exchange"]["pos"]["P"], - Y=self.labware["exchange"]["pos"]["Y"], - ) - # check coordinates - asyncio.run(self.check_complete_loop()) - - # place lid onto plate - self.jog("Z", -400) - self.open() - self.jog("Z", 1000) - asyncio.run(self.check_complete_loop()) - - # return to home - self.move( - R=self.labware["neutral"]["pos"]["R"], - Z=23.5188, - P=self.labware["neutral"]["pos"]["P"], - Y=self.labware["neutral"]["pos"]["Y"], - ) - # check coordinates - asyncio.run(self.check_complete_loop()) - - # update labware dict - self.labware[lid_nest]["howmany"] -= 1 - self.labware["exchange"]["has_lid"] = True def plate_to_stack(self, tower, add_lid): """Plate from exchange to stack (self, tower, plateinfo)""" @@ -1060,206 +692,6 @@ def plate_to_stack(self, tower, add_lid): self.labware["exchange"]["howmany"] -= 1 self.labware[tower]["howmany"] += 1 - def lidnest_to_trash(self, lidnest): - """Remove lid from lidnest, throw away""" - # Move arm up and to neutral position to avoid hitting any objects - self.open() - self.set_speed(10) - self.jog("Y", -1000) - self.jog("Z", 1000) - self.set_speed(12) - self.move( - R=self.labware["neutral"]["pos"]["R"], - Z=23.5188, - P=self.labware["neutral"]["pos"]["P"], - Y=self.labware["neutral"]["pos"]["Y"], - ) - asyncio.run(self.check_complete_loop()) - # check to make sure lid present - if self.labware[lidnest]["howmany"] >= 1: # lid in nest - lid_type = self.labware[lidnest]["type"] - # move above lidnest - self.set_speed(100) - self.close() - self.move( - R=self.labware[lidnest]["pos"]["R"], - Z=23.5188, - P=self.labware[lidnest]["pos"]["P"], - Y=self.labware[lidnest]["pos"]["Y"], - ) - asyncio.run(self.check_complete_loop()) - - # grab lid - self.jog("Z", -380) - self.set_speed(7) - self.jog("Z", -1000) - self.jog("Z", 10) - self.open() - lid_height = self.plate_info[lid_type]["grab_lid_nest"] - self.jog("Z", lid_height) - self.close() - self.set_speed(100) - self.jog("Z", 1000) - asyncio.run(self.check_complete_loop()) - - # move above trash - self.move( - R=self.labware["trash"]["pos"]["R"], - Z=23.5188, - P=self.labware["trash"]["pos"]["P"], - Y=self.labware["trash"]["pos"]["Y"], - ) - asyncio.run(self.check_complete_loop()) - - # drop lid - self.jog("Z", -1000) - self.open() - self.jog("Z", 1000) - asyncio.run(self.check_complete_loop()) - - # back to neutral - self.move( - R=self.labware["neutral"]["pos"]["R"], - Z=23.5188, - P=self.labware["neutral"]["pos"]["P"], - Y=self.labware["neutral"]["pos"]["Y"], - ) - asyncio.run(self.check_complete_loop()) - - # update labware - self.labware[lidnest]["howmany"] -= 1 - - else: - print("NO LID IN NEST") - - def plate_to_trash(self, add_lid): - """Remove plate from exchange, throw away""" - # Move arm up and to neutral position to avoid hitting any objects - self.open() - self.set_speed(10) - self.jog("Y", -1000) - self.jog("Z", 1000) - self.set_speed(12) - self.move( - R=self.labware["neutral"]["pos"]["R"], - Z=23.5188, - P=self.labware["neutral"]["pos"]["P"], - Y=self.labware["neutral"]["pos"]["Y"], - ) - asyncio.run(self.check_complete_loop()) - # check if plate is present - if self.labware["exchange"]["howmany"] >= 1: - # check if add_lid is true, if yes, add lid - if add_lid: - self.replace_lid() - - plate_type = self.labware["exchange"]["type"] - - # move over exchange - self.set_speed(100) - self.open() - self.move( - R=self.labware["exchange"]["pos"]["R"], - Z=23.5188, - P=self.labware["exchange"]["pos"]["P"], - Y=self.labware["exchange"]["pos"]["Y"], - ) - asyncio.run(self.check_complete_loop()) - - # grab plate - self.jog("Z", -380) - self.set_speed(7) - grab_height = self.plate_info[plate_type]["grab_plate_exchange"] - self.jog("Z", grab_height) - self.close() - self.set_speed(100) - self.jog("Z", 1000) - asyncio.run(self.check_complete_loop()) - - # move over trash - self.move( - R=self.labware["trash"]["pos"]["R"], - Z=23.5188, - P=self.labware["trash"]["pos"]["P"], - Y=self.labware["trash"]["pos"]["Y"], - ) - asyncio.run(self.check_complete_loop()) - - # drop plate - self.jog("Z", -1000) - self.open() - self.jog("Z", 1000) - asyncio.run(self.check_complete_loop()) - - # back to neutral - self.move( - R=self.labware["neutral"]["pos"]["R"], - Z=23.5188, - P=self.labware["neutral"]["pos"]["P"], - Y=self.labware["neutral"]["pos"]["Y"], - ) - asyncio.run(self.check_complete_loop()) - - # update labware - self.labware["exchange"]["howmany"] -= 1 - - else: - print("NO PLATE IN EXCHANGE") -if __name__ == "__main__": - """ - Runs given function. - """ - s = SCICLOPS() - # s.get_error() - # s.get_status() - # for i in range(1): - # # s.jog("R", -1000) - # # s.reset() - # # sleep(5) - # # s.home() - # # s.send_command("") - # # sleep(5) - # s.get_plate("tower4") - # # sleep(25) - # print("STATUS MSG: ", s.status) - # s.check_closed() - # print(s.CURRENT_POS) - s.reset() - # s.home() - # s.get_plate("tower1") - # dummy_sciclops.check_plate() - -# Finished commands -# "GETPOS" -# "STATUS" -# "VERSION" -# "GETCONFIG" -# "GETGRIPPERLENGTH" -# "GETCOLLAPSEDISTANCE" -# "GETSTEPSPERUNIT" -# "HOME" -# "OPEN" -# "CLOSE" -# "GETGRIPPERISCLOSED" -# "GETGRIPPERISOPEN" -# "GETPLATEPRESENT" -# "SETSPEED " -# "MOVE " -# "JOG" -# "DELETEPOINT (ADD POINT)" -# "LISTPOINTS" -# "LOADPOINT R:0,Z:0,P:0,Y:0" -# "LIMP TRUE/FALSE" - -# #Unfinished Commands -# "GETLIMITS" - -# #Unknown Commands -# "LISTMOTIONS" -# "AUTOTEACH" -# "GETPOINT" -# "READINP 15" -# "GETGRIPSTRENGTH" -# "READINP" + \ No newline at end of file diff --git a/src/sciclops_rest_node.py b/src/sciclops_rest_node.py index f35a7e5..dda5cdc 100644 --- a/src/sciclops_rest_node.py +++ b/src/sciclops_rest_node.py @@ -6,17 +6,44 @@ from sciclops_driver import SCICLOPS from typing_extensions import Annotated from madsci.node_module.rest_node_module import RestNode -from typing import Union +from typing import Any, Optional from madsci.common.types.node_types import RestNodeConfig +from madsci.common.types.base_types import BaseModel from madsci.node_module.abstract_node_module import action from madsci.common.types.action_types import ActionResult, ActionSucceeded - +from madsci.common.types.location_types import Location class SciclopsConfig(RestNodeConfig): """Configuration for the camera node module.""" - sciclops_address: Union[int, str] = 0 - """The sciclops usb address, a device path in Linux/Mac.""" + vendor_id: int = 0x7513 + + """The sciclops vendor id address, a device path in Linux/Mac.""" + + product_id: int = 0x0002 + + """The sciclops vendor id address, a device path in Linux/Mac.""" + + neutral_joints: dict[str, float] = {"Z": 23.5188, "R": 109.2741, "Y": 32.7484, "P": 98.2955} + """The neutral joint position for the arm""" + + plate_info: Optional[Any] = None + """The specs for picking up different kinds of plates""" + + exchange_location: Optional[Any] = None + """the location of the exchange for placing plates""" +class NodeLocation(BaseModel): + """custom location format for the sciclops""" + Z: float = 23.5188 + """Z joint""" + R: float = 109.2741 + """Rotation joint""" + Y: float = 32.7484 + """extension joint""" + P: float = 98.2955 + """wrist joint""" + resource_id: Optional[str] = None + """id for the resource""" @@ -35,7 +62,7 @@ def startup_handler(self): None""" print("Hello, World!") try: - self.sciclops = SCICLOPS() + self.sciclops = SCICLOPS(self.config) except Exception as error_msg: print("------- SCICLOPS Error message: " + str(error_msg) + (" -------")) raise(error_msg) @@ -60,12 +87,43 @@ def home(self): @action(name="get_plate") def get_plate( self, - pos: Annotated[int, "Stack to get plate from"], - lid: Annotated[bool, "Whether plate has a lid or not"] = False, - trash: Annotated[bool, "Whether to use the trash"] = False, + pos: Annotated[NodeLocation, "Stack to get plate from"], + ): + """Get a plate from a stack position and move it to transfer point (or trash)""" + self.sciclops.get_plate(pos) + return ActionSucceeded() + + @action(name="limp") + def limp( + self, + toggle: Annotated[bool, "turn on or off bool"] = False, + ): + """Get a plate from a stack position and move it to transfer point (or trash)""" + self.sciclops.limp(toggle) + return ActionSucceeded() + + @action(name="open") + def open( + self, + ): + """Get a plate from a stack position and move it to transfer point (or trash)""" + self.sciclops.open() + return ActionSucceeded() + @action(name="close") + def close( + self, + ): + """Get a plate from a stack position and move it to transfer point (or trash)""" + self.sciclops.close() + return ActionSucceeded() + @action(name="move") + def move( + self, + target: NodeLocation ): """Get a plate from a stack position and move it to transfer point (or trash)""" - self.sciclops.get_plate(pos, lid, trash) + target = NodeLocation.model_validate(target) + self.sciclops.move(target.Z, target.R, target.Y, target.P) return ActionSucceeded() if __name__ == "__main__": From a547f00044b3ed3e4fa792ebcebec2e3a0eb95c0 Mon Sep 17 00:00:00 2001 From: tginsbu1 Date: Thu, 27 Mar 2025 14:09:12 -0500 Subject: [PATCH 03/10] resources --- Dockerfile | 2 +- ...iclops_driver.py => sciclops_interface.py} | 24 ++++++---- src/sciclops_rest_node.py | 47 ++++++++----------- 3 files changed, 36 insertions(+), 37 deletions(-) rename src/{sciclops_driver.py => sciclops_interface.py} (96%) diff --git a/Dockerfile b/Dockerfile index 4eb26e6..a84ccc1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/ad-sdl/madsci +FROM madsci LABEL org.opencontainers.image.source=https://github.com/AD-SDL/hudson_platecrane_module LABEL org.opencontainers.image.description="Drivers and REST API's for the Hudson Platecrane and Sciclops robots" diff --git a/src/sciclops_driver.py b/src/sciclops_interface.py similarity index 96% rename from src/sciclops_driver.py rename to src/sciclops_interface.py index 98309c9..8583529 100644 --- a/src/sciclops_driver.py +++ b/src/sciclops_interface.py @@ -7,6 +7,7 @@ import usb.util from madsci.common.types.node_types import RestNodeConfig +from madsci.client.resource_client import ResourceClient class SCICLOPS: """ @@ -14,10 +15,12 @@ class SCICLOPS: Python interface that allows remote commands to be executed to the Sciclops. """ - def __init__(self, config: RestNodeConfig): + def __init__(self, config: RestNodeConfig, resource_client: ResourceClient, gripper_id: str): """Creates a new SCICLOPS driver object. The default VENDOR_ID and PRODUCT_ID are for the Sciclops robot.""" self.VENDOR_ID = config.vendor_id self.PRODUCT_ID = config.product_id + self.resource_client = resource_client + self.gripper_id = gripper_id self.neutral_joints = config.neutral_joints self.host_path = self.connect_sciclops() self.exchange_location = config.exchange_location @@ -533,7 +536,7 @@ def move_neutral(self): P=self.neutral_joints["P"], Y=self.neutral_joints["Y"], ) - def get_plate(self, location): + def get_plate(self, source, target): """ Grabs plate and places on exchange. Paramater is the stack that the Sciclops is requested to remove the plate from. Format: "Stack" @@ -546,7 +549,6 @@ def get_plate(self, location): # print("PLATE ALREADY ON THE EXCHANGE") # else: plate_type = "96_well" - # Move arm up and to neutral position to avoid hitting any objects self.open() self.set_speed(10) # @@ -561,10 +563,10 @@ def get_plate(self, location): # Move above desired tower self.set_speed(100) self.move( - R=location["R"], + R=source.location["R"], Z=23.5188, - P=location["P"], - Y=location["Y"], + P=source.location["P"], + Y=source.location["Y"], ) # check coordinates asyncio.run(self.check_complete_loop()) @@ -579,6 +581,8 @@ def get_plate(self, location): grab_height = self.plate_info[plate_type]["grab_tower"] self.jog("Z", grab_height) self.close() + plate, _ = self.resource_client.pop(source.resource_id) + self.resource_client.push(self.gripper_id, plate) self.set_speed(100) self.jog("Z", 1000) # check coordinates @@ -586,10 +590,10 @@ def get_plate(self, location): # Place in exchange self.move( - R=self.exchange_location["R"], + R=target.location["R"], Z=23.5188, - P=self.exchange_location["P"], - Y=self.exchange_location["Y"], + P=target.location["P"], + Y=target.location["Y"], ) # check coordinates # asyncio.run(self.check_complete_loop()) @@ -597,6 +601,8 @@ def get_plate(self, location): self.set_speed(5) self.jog("Z", -30) self.open() + plate, _ = self.resource_client.pop(self.gripper_id) + self.resource_client.push(target.resource_id, plate) self.set_speed(100) self.jog("Z", 1000) # check coordinates diff --git a/src/sciclops_rest_node.py b/src/sciclops_rest_node.py index dda5cdc..c058680 100644 --- a/src/sciclops_rest_node.py +++ b/src/sciclops_rest_node.py @@ -3,21 +3,23 @@ from pathlib import Path -from sciclops_driver import SCICLOPS +from sciclops_interface import SCICLOPS from typing_extensions import Annotated from madsci.node_module.rest_node_module import RestNode from typing import Any, Optional from madsci.common.types.node_types import RestNodeConfig from madsci.common.types.base_types import BaseModel -from madsci.node_module.abstract_node_module import action +from madsci.node_module.helpers import action from madsci.common.types.action_types import ActionResult, ActionSucceeded -from madsci.common.types.location_types import Location +from madsci.common.types.location_types import Location, LocationArgument +from madsci.common.types.resource_types import Slot +from madsci.client.resource_client import ResourceClient +from madsci.common.types.auth_types import OwnershipInfo class SciclopsConfig(RestNodeConfig): """Configuration for the camera node module.""" vendor_id: int = 0x7513 - """The sciclops vendor id address, a device path in Linux/Mac.""" product_id: int = 0x0002 @@ -32,27 +34,15 @@ class SciclopsConfig(RestNodeConfig): exchange_location: Optional[Any] = None """the location of the exchange for placing plates""" -class NodeLocation(BaseModel): - """custom location format for the sciclops""" - Z: float = 23.5188 - """Z joint""" - R: float = 109.2741 - """Rotation joint""" - Y: float = 32.7484 - """extension joint""" - P: float = 98.2955 - """wrist joint""" - resource_id: Optional[str] = None - """id for the resource""" - - - + + resource_manager_url: Optional[str] = None + """the resource manager url for the sciclops""" class SciclopsNode(RestNode): config_model = SciclopsConfig def startup_handler(self): """Initial run function for the app, initializes the state - Parameters + ParametersNodeLocation ---------- app : FastApi The REST API app being initialized @@ -62,7 +52,9 @@ def startup_handler(self): None""" print("Hello, World!") try: - self.sciclops = SCICLOPS(self.config) + self.resource_client = ResourceClient(self.config.resource_manager_url) + self.gripper = self.resource_client.query_or_add_resource(resource_name="sciclops_gripper", owner=OwnershipInfo(node_id=self.node_definition.node_id), base_type="slot") + self.sciclops = SCICLOPS(self.config, self.resource_client, self.gripper.resource_id) except Exception as error_msg: print("------- SCICLOPS Error message: " + str(error_msg) + (" -------")) raise(error_msg) @@ -87,10 +79,11 @@ def home(self): @action(name="get_plate") def get_plate( self, - pos: Annotated[NodeLocation, "Stack to get plate from"], + source: Annotated[LocationArgument, "Stack to get plate from"], + target: Annotated[LocationArgument, "Exchange to place plate"], ): """Get a plate from a stack position and move it to transfer point (or trash)""" - self.sciclops.get_plate(pos) + self.sciclops.get_plate(source, target) return ActionSucceeded() @action(name="limp") @@ -119,11 +112,11 @@ def close( @action(name="move") def move( self, - target: NodeLocation + target: Annotated[LocationArgument, "Target Location to move to"] ): - """Get a plate from a stack position and move it to transfer point (or trash)""" - target = NodeLocation.model_validate(target) - self.sciclops.move(target.Z, target.R, target.Y, target.P) + """Get a plate from a stack position and move it to transfer point (or trash)""" + location = target.location + self.sciclops.move(location["Z"], location["R"], location["Y"], location["P"]) return ActionSucceeded() if __name__ == "__main__": From e64a7a791972307382a74b84eaa467baf3675afa Mon Sep 17 00:00:00 2001 From: tginsbu1 Date: Tue, 1 Apr 2025 19:35:48 -0500 Subject: [PATCH 04/10] adding replace_plate --- src/sciclops_interface.py | 89 +++++++++++++++++++++++++++++++++------ src/sciclops_rest_node.py | 21 ++++++++- 2 files changed, 94 insertions(+), 16 deletions(-) diff --git a/src/sciclops_interface.py b/src/sciclops_interface.py index 8583529..d51345c 100644 --- a/src/sciclops_interface.py +++ b/src/sciclops_interface.py @@ -120,19 +120,20 @@ def get_position(self): out_msg = self.send_command(command) try: + print(out_msg) # Checks if specified format is found in feedback exp = r"Z:([-.\d]+), R:([-.\d]+), Y:([-.\d]+), P:([-.\d]+)" # Format of coordinates provided in feedback find_current_pos = re.search(exp, out_msg) - self.current_pos = [ - float(find_current_pos[1]), - float(find_current_pos[2]), - float(find_current_pos[3]), - float(find_current_pos[4]), - ] + self.current_pos = { + "Z": float(find_current_pos[1]), + "R": float(find_current_pos[2]), + "Y": float(find_current_pos[3]), + "P": float(find_current_pos[4]), + } - print(self.current_pos) - except Exception: - pass + return self.current_pos + except Exception as e: + raise(e) def get_status(self): """ @@ -543,11 +544,7 @@ def get_plate(self, source, target): remove lid and trash bools tell whether to remove lid from plate and whether to throw said lid in the trash or place in nest """ - # check to see if plate already on the exchange - # removed for now until labware can be edited in a file - # if self.labware['exchange']['howmany'] != 0: - # print("PLATE ALREADY ON THE EXCHANGE") - # else: + plate_type = "96_well" # Move arm up and to neutral position to avoid hitting any objects self.open() @@ -612,7 +609,71 @@ def get_plate(self, source, target): self.move_neutral() # check coordinates # asyncio.run(self.check_complete_loop()) + + + def return_plate(self, source, target): + """ + Grabs plate and places on exchange. Paramater is the stack that the Sciclops is requested to remove the plate from. + Format: "Stack" + remove lid and trash bools tell whether to remove lid from plate and whether to throw said lid in the trash or place in nest + """ + + + plate_type = "96_well" + # Move arm up and to neutral position to avoid hitting any objects + self.open() + self.set_speed(10) # + self.jog("Y", -1000) + self.jog("Z", 1000) + self.set_speed(12) + self.move_neutral + # check coordinates + asyncio.run(self.check_complete_loop()) + + # Move above desired tower + self.set_speed(100) + self.move( + R=source.location["R"], + Z=23.5188, + P=source.location["P"], + Y=source.location["Y"], + ) + # check coordinates + asyncio.run(self.check_complete_loop()) + + self.close() + self.set_speed(15) + self.jog("Z", -380) + # move up certain amount + self.open() + self.set_speed(5) + self.jog("Z", -30) + grab_height = self.plate_info[plate_type]["grab_tower"] + self.jog("Z", grab_height) + self.close() + plate, _ = self.resource_client.pop(source.resource_id) + self.resource_client.push(self.gripper_id, plate) + self.set_speed(100) + self.jog("Z", 1000) + + # Place in exchange + self.move( + R=target.location["R"], + Z=23.5188, + P=target.location["P"], + Y=target.location["Y"], + ) + + self.jog("Z", -1000) + self.jog("Z", 10) + self.open() + plate, _ = self.resource_client.pop(self.gripper_id) + self.resource_client.push(target.resource_id, plate) + self.set_speed(100) + self.jog("Z", 1000) + self.move_neutral() + def limp(self, limp_bool): """ Turns on/off limp mode (allows someone to manually move joints) diff --git a/src/sciclops_rest_node.py b/src/sciclops_rest_node.py index c058680..abc2e10 100644 --- a/src/sciclops_rest_node.py +++ b/src/sciclops_rest_node.py @@ -11,8 +11,9 @@ from madsci.common.types.base_types import BaseModel from madsci.node_module.helpers import action from madsci.common.types.action_types import ActionResult, ActionSucceeded +from madsci.common.types.admin_command_types import AdminCommandResponse from madsci.common.types.location_types import Location, LocationArgument -from madsci.common.types.resource_types import Slot +from madsci.common.types.resource_types.definitions import SlotResourceDefinition from madsci.client.resource_client import ResourceClient from madsci.common.types.auth_types import OwnershipInfo @@ -53,7 +54,7 @@ def startup_handler(self): print("Hello, World!") try: self.resource_client = ResourceClient(self.config.resource_manager_url) - self.gripper = self.resource_client.query_or_add_resource(resource_name="sciclops_gripper", owner=OwnershipInfo(node_id=self.node_definition.node_id), base_type="slot") + self.gripper = self.resource_client.init_resource(SlotResourceDefinition(resource_name="sciclops_gripper_"+str(self.node_definition.node_name), owner=OwnershipInfo(node_id=self.node_definition.node_id))) self.sciclops = SCICLOPS(self.config, self.resource_client, self.gripper.resource_id) except Exception as error_msg: print("------- SCICLOPS Error message: " + str(error_msg) + (" -------")) @@ -86,6 +87,16 @@ def get_plate( self.sciclops.get_plate(source, target) return ActionSucceeded() + @action(name="return_plate") + def return_plate( + self, + source: Annotated[LocationArgument, "Exchange to get plate from"], + target: Annotated[LocationArgument, "Tower to place plate"], + ): + """Get a plate from a stack position and move it to transfer point (or trash)""" + self.sciclops.return_plate(source, target) + return ActionSucceeded() + @action(name="limp") def limp( self, @@ -118,6 +129,12 @@ def move( location = target.location self.sciclops.move(location["Z"], location["R"], location["Y"], location["P"]) return ActionSucceeded() + + def get_location(self) -> AdminCommandResponse: + try: + return AdminCommandResponse(data={"location": self.sciclops.get_position()}) + except Exception as e: + return AdminCommandResponse(success=False) if __name__ == "__main__": sciclops_node = SciclopsNode() From 1fba47867b6139ca13506a9a5a6203edfaafa366 Mon Sep 17 00:00:00 2001 From: tginsbu1 Date: Wed, 9 Apr 2025 15:02:26 -0500 Subject: [PATCH 05/10] Handle missing resource during get_plate --- src/sciclops_interface.py | 39 +++++++++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/src/sciclops_interface.py b/src/sciclops_interface.py index d51345c..8debada 100644 --- a/src/sciclops_interface.py +++ b/src/sciclops_interface.py @@ -578,8 +578,13 @@ def get_plate(self, source, target): grab_height = self.plate_info[plate_type]["grab_tower"] self.jog("Z", grab_height) self.close() - plate, _ = self.resource_client.pop(source.resource_id) - self.resource_client.push(self.gripper_id, plate) + try: + plate, _ = self.resource_client.pop(source.resource_id) + self.resource_client.push(self.gripper_id, plate) + except Exception as e: + self.open() + self.move_neutral() + raise e self.set_speed(100) self.jog("Z", 1000) # check coordinates @@ -598,8 +603,13 @@ def get_plate(self, source, target): self.set_speed(5) self.jog("Z", -30) self.open() - plate, _ = self.resource_client.pop(self.gripper_id) - self.resource_client.push(target.resource_id, plate) + try: + plate, _ = self.resource_client.pop(self.gripper_id) + self.resource_client.push(target.resource_id, plate) + except Exception as e: + self.move_neutral() + raise e + self.set_speed(100) self.jog("Z", 1000) # check coordinates @@ -652,12 +662,17 @@ def return_plate(self, source, target): grab_height = self.plate_info[plate_type]["grab_tower"] self.jog("Z", grab_height) self.close() - plate, _ = self.resource_client.pop(source.resource_id) - self.resource_client.push(self.gripper_id, plate) - self.set_speed(100) + try: + plate, _ = self.resource_client.pop(source.resource_id) + self.resource_client.push(self.gripper_id, plate) + except Exception as e: + self.open() + self.move_neutral() + raise e + self.set_speed(50) self.jog("Z", 1000) - # Place in exchange + # Place in tower self.move( R=target.location["R"], Z=23.5188, @@ -668,8 +683,12 @@ def return_plate(self, source, target): self.jog("Z", -1000) self.jog("Z", 10) self.open() - plate, _ = self.resource_client.pop(self.gripper_id) - self.resource_client.push(target.resource_id, plate) + try: + plate, _ = self.resource_client.pop(self.gripper_id) + self.resource_client.push(target.resource_id, plate) + except Exception as e: + self.move_neutral() + raise e self.set_speed(100) self.jog("Z", 1000) self.move_neutral() From 9a9f5a8d198019c3e7035ab58c72de70f16ea0b4 Mon Sep 17 00:00:00 2001 From: tginsbu1 Date: Thu, 10 Apr 2025 17:38:29 -0500 Subject: [PATCH 06/10] Cleanup --- .github/workflows/docker.yml | 71 ++++++++++++++++++++++++++++++++ .github/workflows/pre-commit.yml | 20 +++++++++ .justfile | 28 +++++++++++++ Dockerfile | 2 +- README.md | 3 +- compose.yaml | 14 +++---- pyproject.toml | 2 +- 7 files changed, 128 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/docker.yml create mode 100644 .github/workflows/pre-commit.yml create mode 100644 .justfile diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..a812dae --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,71 @@ +name: Docker Image Build and Publish + +on: + push: # * Run for every push + branches: [ "*" ] + tags: [ '*' ] + schedule: # Run on Tuesday's at 12:00 + - cron: '0 12 * * 2' + workflow_dispatch: # Run manually + +env: + # Use docker.io for Docker Hub if empty + REGISTRY: ghcr.io + # github.repository as / + IMAGE_NAME: ${{ github.repository }} + + +jobs: + build_and_publish: + + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + # Login against a Docker registry + # https://github.com/docker/login-action + - name: Log into registry ${{ env.REGISTRY }} + uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Extract metadata (tags, labels) for Docker + # https://github.com/docker/metadata-action + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=schedule + type=ref,event=branch + type=ref,event=tag + type=ref,event=pr + type=raw,value=latest,enable={{is_default_branch}} + + # Build and push Docker image with Buildx + # https://github.com/docker/build-push-action + - name: Build and push Docker image + id: build-and-push + uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + platforms: linux/amd64,linux/arm64 + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml new file mode 100644 index 0000000..f01c862 --- /dev/null +++ b/.github/workflows/pre-commit.yml @@ -0,0 +1,20 @@ +name: Pre-Commit Checks + +on: + push: # Run for every push + branches: ["*"] + tags: ["*"] + workflow_dispatch: # Run manually + +jobs: + pre-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + name: Checkout code + - uses: actions/setup-python@v4 + name: Setup Python + with: + python-version: 3.9 + - uses: pre-commit/action@v3.0.1 + name: Run Pre-Commit Checks diff --git a/.justfile b/.justfile new file mode 100644 index 0000000..4f0003b --- /dev/null +++ b/.justfile @@ -0,0 +1,28 @@ +# List available commands +default: + @just --list --justfile {{justfile()}} + +# initialize the project +init: + @which pdm || echo "pdm not found, you'll need to install it: https://github.com/pdm-project/pdm" + @pdm install -G:all + @OSTYPE="" . .venv/bin/activate + @which pre-commit && pre-commit install && pre-commit autoupdate || true + +# Build the project +build: init dcb + +# Run the pre-commit checks +checks: + @pre-commit run --all-files || { echo "Checking fixes\n" ; pre-commit run --all-files; } +check: checks + +# Run automated tests +test: + @pytest +tests: test +pytest: test + +# Build docker image +dcb: + @docker compose build diff --git a/Dockerfile b/Dockerfile index a84ccc1..4eb26e6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM madsci +FROM ghcr.io/ad-sdl/madsci LABEL org.opencontainers.image.source=https://github.com/AD-SDL/hudson_platecrane_module LABEL org.opencontainers.image.description="Drivers and REST API's for the Hudson Platecrane and Sciclops robots" diff --git a/README.md b/README.md index dd1c035..28b5353 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,3 @@ # sciclops_module -A MADSci node module for interfacing with the Hudson Robotics Sciclops Platecrane + +A MADSci Node module for interfacing with the Hudson Robotics Sciclops Platecrane. diff --git a/compose.yaml b/compose.yaml index 16392ce..577c6bc 100644 --- a/compose.yaml +++ b/compose.yaml @@ -2,19 +2,15 @@ name: sciclops_node services: sciclops: container_name: sciclops - image: sciclops + image: ghcr.io/ad-sdl/sciclops_module build: context: . dockerfile: Dockerfile tags: - - sciclops:latest - - sciclops:0.0.1 - - sciclops:dev + - ghcr.io/ad-sdl/sciclops_module:latest + - ghcr.io/ad-sdl/sciclops_module:dev command: python -m sciclops_rest_node privileged: true - env_file: .env + network_mode: host volumes: - - /dev:/dev - - ./src:/home/app/sciclops_module/src - ports: - - 2000:2000 + - ./definitions:/home/madsci/definitions diff --git a/pyproject.toml b/pyproject.toml index f62f22b..adac53b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ dependencies = [ "pyusb", "libusb", "pyserial", - "madsci.node_module>=0.1.5", + "madsci.node_module>=0.1.9", "pydantic>=2.7", "pytest" ] From e848449031721a41d2c691ee6d68ec98e7a26c78 Mon Sep 17 00:00:00 2001 From: Ryan Lewis Date: Mon, 14 Apr 2025 11:28:48 -0500 Subject: [PATCH 07/10] Fix pre-commit --- .pre-commit-config.yaml | 2 +- src/sciclops_interface.py | 43 ++++++++++----------- src/sciclops_rest_node.py | 78 +++++++++++++++++++++++---------------- 3 files changed, 67 insertions(+), 56 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 22ff296..caed31b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,7 +16,7 @@ repos: - id: nbstripout - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.9.9 + rev: v0.11.5 hooks: # Run the linter. - id: ruff diff --git a/src/sciclops_interface.py b/src/sciclops_interface.py index 8debada..a45efcc 100644 --- a/src/sciclops_interface.py +++ b/src/sciclops_interface.py @@ -5,9 +5,9 @@ import usb.core import usb.util - -from madsci.common.types.node_types import RestNodeConfig from madsci.client.resource_client import ResourceClient +from madsci.common.types.node_types import RestNodeConfig + class SCICLOPS: """ @@ -15,7 +15,9 @@ class SCICLOPS: Python interface that allows remote commands to be executed to the Sciclops. """ - def __init__(self, config: RestNodeConfig, resource_client: ResourceClient, gripper_id: str): + def __init__( + self, config: RestNodeConfig, resource_client: ResourceClient, gripper_id: str + ): """Creates a new SCICLOPS driver object. The default VENDOR_ID and PRODUCT_ID are for the Sciclops robot.""" self.VENDOR_ID = config.vendor_id self.PRODUCT_ID = config.product_id @@ -125,7 +127,7 @@ def get_position(self): exp = r"Z:([-.\d]+), R:([-.\d]+), Y:([-.\d]+), P:([-.\d]+)" # Format of coordinates provided in feedback find_current_pos = re.search(exp, out_msg) self.current_pos = { - "Z": float(find_current_pos[1]), + "Z": float(find_current_pos[1]), "R": float(find_current_pos[2]), "Y": float(find_current_pos[3]), "P": float(find_current_pos[4]), @@ -133,7 +135,7 @@ def get_position(self): return self.current_pos except Exception as e: - raise(e) + raise (e) def get_status(self): """ @@ -264,8 +266,8 @@ def get_grip_length(self): print(self.griplength) - except Exception:self.labware[location] - + except Exception: + pass command = "GETCOLLAPSEDISTANCE\r\n" # Command interpreted by Sciclops out_msg = self.send_command(command) @@ -296,7 +298,7 @@ def get_steps_per_unit(self): self.STEPSPERUNIT = [ float(find_steps_per_unit[1]), float(find_steps_per_unit[2]), - float(find_stepsGET_per_unit[3]), + float(find_steps_per_unit[3]), float(find_steps_per_unit[4]), ] @@ -325,7 +327,6 @@ def home(self, axis=""): pass # Moves axes to neutral position (above exchange) - def open(self): """ @@ -529,14 +530,16 @@ def move(self, R, Z, P, Y): pass self.deletepoint(R, Z, P, Y) - + def move_neutral(self): + """Move the robot arm to the neutral position.""" self.move( R=self.neutral_joints["R"], Z=self.neutral_joints["Z"], P=self.neutral_joints["P"], Y=self.neutral_joints["Y"], ) + def get_plate(self, source, target): """ Grabs plate and places on exchange. Paramater is the stack that the Sciclops is requested to remove the plate from. @@ -544,7 +547,6 @@ def get_plate(self, source, target): remove lid and trash bools tell whether to remove lid from plate and whether to throw said lid in the trash or place in nest """ - plate_type = "96_well" # Move arm up and to neutral position to avoid hitting any objects self.open() @@ -552,7 +554,7 @@ def get_plate(self, source, target): self.jog("Y", -1000) self.jog("Z", 1000) self.set_speed(12) - self.move_neutral + self.move_neutral() # check coordinates asyncio.run(self.check_complete_loop()) @@ -609,7 +611,7 @@ def get_plate(self, source, target): except Exception as e: self.move_neutral() raise e - + self.set_speed(100) self.jog("Z", 1000) # check coordinates @@ -619,7 +621,6 @@ def get_plate(self, source, target): self.move_neutral() # check coordinates # asyncio.run(self.check_complete_loop()) - def return_plate(self, source, target): """ @@ -628,7 +629,6 @@ def return_plate(self, source, target): remove lid and trash bools tell whether to remove lid from plate and whether to throw said lid in the trash or place in nest """ - plate_type = "96_well" # Move arm up and to neutral position to avoid hitting any objects self.open() @@ -636,7 +636,7 @@ def return_plate(self, source, target): self.jog("Y", -1000) self.jog("Z", 1000) self.set_speed(12) - self.move_neutral + self.move_neutral() # check coordinates asyncio.run(self.check_complete_loop()) @@ -671,7 +671,7 @@ def return_plate(self, source, target): raise e self.set_speed(50) self.jog("Z", 1000) - + # Place in tower self.move( R=target.location["R"], @@ -679,7 +679,7 @@ def return_plate(self, source, target): P=target.location["P"], Y=target.location["Y"], ) - + self.jog("Z", -1000) self.jog("Z", 10) self.open() @@ -692,7 +692,7 @@ def return_plate(self, source, target): self.set_speed(100) self.jog("Z", 1000) self.move_neutral() - + def limp(self, limp_bool): """ Turns on/off limp mode (allows someone to manually move joints) @@ -704,7 +704,6 @@ def limp(self, limp_bool): command = "LIMP %s" % limp_string # Command interpreted by Sciclops self.send_command(command) - def plate_to_stack(self, tower, add_lid): """Plate from exchange to stack (self, tower, plateinfo)""" # Move arm up and to neutral position to avoid hitting any objects @@ -777,7 +776,3 @@ def plate_to_stack(self, tower, add_lid): # update labware dict self.labware["exchange"]["howmany"] -= 1 self.labware[tower]["howmany"] += 1 - - - - \ No newline at end of file diff --git a/src/sciclops_rest_node.py b/src/sciclops_rest_node.py index abc2e10..19341da 100644 --- a/src/sciclops_rest_node.py +++ b/src/sciclops_rest_node.py @@ -1,21 +1,21 @@ #! /usr/bin/env python3 """The server for the Hudson Platecrane/Sciclops that takes incoming WEI flow requests from the experiment application""" -from pathlib import Path - -from sciclops_interface import SCICLOPS -from typing_extensions import Annotated -from madsci.node_module.rest_node_module import RestNode from typing import Any, Optional -from madsci.common.types.node_types import RestNodeConfig -from madsci.common.types.base_types import BaseModel -from madsci.node_module.helpers import action -from madsci.common.types.action_types import ActionResult, ActionSucceeded -from madsci.common.types.admin_command_types import AdminCommandResponse -from madsci.common.types.location_types import Location, LocationArgument -from madsci.common.types.resource_types.definitions import SlotResourceDefinition + from madsci.client.resource_client import ResourceClient +from madsci.common.types.action_types import ActionSucceeded +from madsci.common.types.admin_command_types import AdminCommandResponse from madsci.common.types.auth_types import OwnershipInfo +from madsci.common.types.location_types import LocationArgument +from madsci.common.types.node_types import RestNodeConfig +from madsci.common.types.resource_types.definitions import SlotResourceDefinition +from madsci.node_module.helpers import action +from madsci.node_module.rest_node_module import RestNode +from typing_extensions import Annotated + +from sciclops_interface import SCICLOPS + class SciclopsConfig(RestNodeConfig): """Configuration for the camera node module.""" @@ -26,8 +26,13 @@ class SciclopsConfig(RestNodeConfig): product_id: int = 0x0002 """The sciclops vendor id address, a device path in Linux/Mac.""" - - neutral_joints: dict[str, float] = {"Z": 23.5188, "R": 109.2741, "Y": 32.7484, "P": 98.2955} + + neutral_joints: dict[str, float] = { + "Z": 23.5188, + "R": 109.2741, + "Y": 32.7484, + "P": 98.2955, + } """The neutral joint position for the arm""" plate_info: Optional[Any] = None @@ -35,10 +40,14 @@ class SciclopsConfig(RestNodeConfig): exchange_location: Optional[Any] = None """the location of the exchange for placing plates""" - + resource_manager_url: Optional[str] = None """the resource manager url for the sciclops""" + + class SciclopsNode(RestNode): + """MADSci node module for the Hudson Robotics Sciclops.""" + config_model = SciclopsConfig def startup_handler(self): @@ -54,29 +63,35 @@ def startup_handler(self): print("Hello, World!") try: self.resource_client = ResourceClient(self.config.resource_manager_url) - self.gripper = self.resource_client.init_resource(SlotResourceDefinition(resource_name="sciclops_gripper_"+str(self.node_definition.node_name), owner=OwnershipInfo(node_id=self.node_definition.node_id))) - self.sciclops = SCICLOPS(self.config, self.resource_client, self.gripper.resource_id) + self.gripper = self.resource_client.init_resource( + SlotResourceDefinition( + resource_name="sciclops_gripper_" + + str(self.node_definition.node_name), + owner=OwnershipInfo(node_id=self.node_definition.node_id), + ) + ) + self.sciclops = SCICLOPS( + self.config, self.resource_client, self.gripper.resource_id + ) except Exception as error_msg: print("------- SCICLOPS Error message: " + str(error_msg) + (" -------")) - raise(error_msg) + raise (error_msg) else: print("SCICLOPS online") @action(name="status") def status(self): """Action that forces the sciclops to check its status.""" - + self.sciclops.get_status() return ActionSucceeded() - @action def home(self): """Homes the sciclops""" self.sciclops.home() return ActionSucceeded() - @action(name="get_plate") def get_plate( self, @@ -86,7 +101,7 @@ def get_plate( """Get a plate from a stack position and move it to transfer point (or trash)""" self.sciclops.get_plate(source, target) return ActionSucceeded() - + @action(name="return_plate") def return_plate( self, @@ -96,7 +111,7 @@ def return_plate( """Get a plate from a stack position and move it to transfer point (or trash)""" self.sciclops.return_plate(source, target) return ActionSucceeded() - + @action(name="limp") def limp( self, @@ -113,6 +128,7 @@ def open( """Get a plate from a stack position and move it to transfer point (or trash)""" self.sciclops.open() return ActionSucceeded() + @action(name="close") def close( self, @@ -120,21 +136,21 @@ def close( """Get a plate from a stack position and move it to transfer point (or trash)""" self.sciclops.close() return ActionSucceeded() + @action(name="move") - def move( - self, - target: Annotated[LocationArgument, "Target Location to move to"] - ): - """Get a plate from a stack position and move it to transfer point (or trash)""" + def move(self, target: Annotated[LocationArgument, "Target Location to move to"]): + """Get a plate from a stack position and move it to transfer point (or trash)""" location = target.location self.sciclops.move(location["Z"], location["R"], location["Y"], location["P"]) return ActionSucceeded() - + def get_location(self) -> AdminCommandResponse: + """Return the current position of the sciclops""" try: return AdminCommandResponse(data={"location": self.sciclops.get_position()}) - except Exception as e: - return AdminCommandResponse(success=False) + except Exception: + return AdminCommandResponse(success=False) + if __name__ == "__main__": sciclops_node = SciclopsNode() From c172a46db6f2558e0d1713e03649d54996fabfe0 Mon Sep 17 00:00:00 2001 From: Ryan Lewis Date: Mon, 14 Apr 2025 11:37:13 -0500 Subject: [PATCH 08/10] Update README.md --- README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/README.md b/README.md index 28b5353..f7cfce2 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,27 @@ # sciclops_module A MADSci Node module for interfacing with the Hudson Robotics Sciclops Platecrane. + +## Installation and Usage + +### Python + +```bash +# Create a virtual environment named .venv +python -m venv .venv +# Activate the virtual environment on Linux or macOS +source .venv/bin/activate +# Alternatively, activate the virtual environment on Windows +# .venv\Scripts\activate +# Install the module and dependencies in the venv +pip install . +# Run the environment +python -m sciclops_rest_node --host 127.0.0.1 --port 2000 +``` + +### Docker + +We provide a `Dockerfile` and example docker compose file (`compose.yaml`) to run this node dockerized. + +There is also a pre-built image avaible as `ghcr.io/ad-sdl/sciclops_module`. + From 087b34d20914d802ecc8031ba49f6b72b2ea6434 Mon Sep 17 00:00:00 2001 From: Ryan Lewis Date: Mon, 14 Apr 2025 11:37:41 -0500 Subject: [PATCH 09/10] Pre-commit checks --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index f7cfce2..2c16fd5 100644 --- a/README.md +++ b/README.md @@ -24,4 +24,3 @@ python -m sciclops_rest_node --host 127.0.0.1 --port 2000 We provide a `Dockerfile` and example docker compose file (`compose.yaml`) to run this node dockerized. There is also a pre-built image avaible as `ghcr.io/ad-sdl/sciclops_module`. - From 40936ef0017c042d3f92bafe0b2eb365be95a1d7 Mon Sep 17 00:00:00 2001 From: Ryan Lewis Date: Mon, 14 Apr 2025 11:40:53 -0500 Subject: [PATCH 10/10] Update README.md --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2c16fd5..9eedb34 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,6 @@ python -m sciclops_rest_node --host 127.0.0.1 --port 2000 ### Docker -We provide a `Dockerfile` and example docker compose file (`compose.yaml`) to run this node dockerized. - -There is also a pre-built image avaible as `ghcr.io/ad-sdl/sciclops_module`. +- We provide a `Dockerfile` and example docker compose file (`compose.yaml`) to run this node dockerized. +- There is also a pre-built image available as `ghcr.io/ad-sdl/sciclops_module`. +- You can control the container user's id and group id by setting the `USER_ID` and `GROUP_ID`