diff --git a/docs/api/pylabrobot.liquid_handling.backends.rst b/docs/api/pylabrobot.liquid_handling.backends.rst index 3beb4a48dd5..0ac11abd163 100644 --- a/docs/api/pylabrobot.liquid_handling.backends.rst +++ b/docs/api/pylabrobot.liquid_handling.backends.rst @@ -30,20 +30,6 @@ Hardware backends.opentrons_backend.OpentronsOT2Backend backends.tecan.EVO_backend.EVOBackend -Net ---- - -Net backends can be used to communicate with servers that manage liquid handling devices. - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - backends.http.HTTPBackend - backends.websocket.WebSocketBackend - - Testing ------- diff --git a/docs/user_guide/_getting-started/installation.md b/docs/user_guide/_getting-started/installation.md index 21e3692e9fc..85b7609449f 100644 --- a/docs/user_guide/_getting-started/installation.md +++ b/docs/user_guide/_getting-started/installation.md @@ -54,7 +54,6 @@ There's a multitude of other optional dependencies that you can install. Replace - `websockets`: Needed for the WebSocket backend. - `simulation`: Needed for the simulation backend. - `opentrons`: Needed for the Opentrons backend. -- `server`: Needed for LH server, an HTTP front end to LH. - `agrow`: Needed for the AgrowPumpArray backend. - `plate_reading`: Needed to interact with the CLARIO Star plate reader. - `inheco`: Needed for the Inheco backend. @@ -64,7 +63,7 @@ There's a multitude of other optional dependencies that you can install. Replace To install multiple dependencies, separate them with a comma: ```bash -pip install 'pylabrobot[fw,server]' +pip install 'pylabrobot[fw,http]' ``` Or install all dependencies at once: diff --git a/pylabrobot/liquid_handling/backends/__init__.py b/pylabrobot/liquid_handling/backends/__init__.py index 08ff55f4f74..121bc5edc28 100644 --- a/pylabrobot/liquid_handling/backends/__init__.py +++ b/pylabrobot/liquid_handling/backends/__init__.py @@ -3,10 +3,6 @@ from .chatterbox_backend import ChatterBoxBackend from .hamilton.STAR_backend import STAR, STARBackend from .hamilton.vantage_backend import Vantage, VantageBackend -from .http import HTTPBackend from .opentrons_backend import OpentronsOT2Backend from .serializing_backend import SerializingBackend from .tecan.EVO_backend import EVO, EVOBackend - -# many rely on this -from .websocket import WebSocketBackend diff --git a/pylabrobot/liquid_handling/backends/http.py b/pylabrobot/liquid_handling/backends/http.py deleted file mode 100644 index e4f84f70640..00000000000 --- a/pylabrobot/liquid_handling/backends/http.py +++ /dev/null @@ -1,93 +0,0 @@ -import urllib.parse -from typing import Any, Dict, Optional, cast - -from pylabrobot.__version__ import STANDARD_FORM_JSON_VERSION -from pylabrobot.liquid_handling.backends.serializing_backend import ( - SerializingBackend, -) - -try: - import requests - - HAS_REQUESTS = True -except ImportError as e: - HAS_REQUESTS = False - _REQUESTS_IMPORT_ERROR = e - - -class HTTPBackend(SerializingBackend): - """A backend that sends commands over HTTP(s). - - This backend is used when you want to run a :class:`~pylabrobot.liquid_handling.LiquidHandler` - locally and have a server communicating with the robot elsewhere. - - .. note:: - This backend is designed to work with - `the PyLabRobot server `_. - """ - - def __init__( - self, - host: str, - port: int, - num_channels: int, - protocol: str = "http", - base_path: str = "events", - ): - """Create a new web socket backend. - - Args: - host: The hostname of the server. - port: The port of the server. - protocol: The protocol to use. Either `http` or `https`. - base_path: The base path of the server. Note that events will be sent to `base_path/` - where `` is the event identifier, such as `/aspirate`. - """ - - if not HAS_REQUESTS: - raise RuntimeError( - f"The http backend requires the requests module. Import error: {_REQUESTS_IMPORT_ERROR}" - ) - - super().__init__(num_channels=num_channels) - self.session: Optional[requests.Session] = None - - self.host = host - self.port = port - assert protocol in ["http", "https"] - self.protocol = protocol - self.base_path = base_path - self.url = f"{self.protocol}://{self.host}:{self.port}/{self.base_path}/" - - async def send_command( - self, command: str, data: Optional[Dict[str, Any]] = None - ) -> Optional[dict]: - """Send an event to the server. - - Args: - event: The event identifier. - data: The event arguments, which must be serializable by `json.dumps`. - """ - - if self.session is None: - raise RuntimeError("The backend is not running. Did you call `setup()`?") - - command = command.replace("_", "-") - url = urllib.parse.urljoin(self.url, command) - - resp = self.session.post( - url, - json=data, - headers={ - "User-Agent": f"pylabrobot/{STANDARD_FORM_JSON_VERSION}", - }, - ) - return cast(dict, resp.json()) - - async def setup(self): - self.session = requests.Session() - await super().setup() - - async def stop(self): - await super().stop() - self.session = None diff --git a/pylabrobot/liquid_handling/backends/http_tests.py b/pylabrobot/liquid_handling/backends/http_tests.py deleted file mode 100644 index 09fad096a0f..00000000000 --- a/pylabrobot/liquid_handling/backends/http_tests.py +++ /dev/null @@ -1,236 +0,0 @@ -import unittest - -import responses # type: ignore -from responses import matchers # type: ignore - -from pylabrobot.liquid_handling import LiquidHandler -from pylabrobot.liquid_handling.backends import HTTPBackend -from pylabrobot.resources import ( - PLT_CAR_L5AC_A00, - TIP_CAR_480_A00, - Cor_96_wellplate_360ul_Fb, - hamilton_96_tiprack_1000uL_filter, - no_tip_tracking, - no_volume_tracking, -) -from pylabrobot.resources.hamilton import STARLetDeck - -header_match = matchers.header_matcher({"User-Agent": "pylabrobot/0.1.0"}) - - -class TestHTTPBackendCom(unittest.IsolatedAsyncioTestCase): - """Tests for setup and stop""" - - def setUp(self) -> None: - self.deck = STARLetDeck() - self.backend = HTTPBackend("localhost", 8080, num_channels=8) - self.lh = LiquidHandler(self.backend, deck=self.deck) - - @responses.activate - async def test_setup_stop(self): - responses.add( - responses.POST, - "http://localhost:8080/events/setup", - json={"status": "ok"}, - match=[header_match], - status=200, - ) - responses.add( - responses.POST, - "http://localhost:8080/events/stop", - match=[header_match], - json={"status": "ok"}, - status=200, - ) - responses.add( - responses.POST, - "http://localhost:8080/events/resource-assigned", - match=[header_match], - json={"status": "ok"}, - status=200, - ) - await self.lh.setup() - await self.lh.stop() - - -class TestHTTPBackendOps(unittest.IsolatedAsyncioTestCase): - """Tests for liquid handling ops.""" - - @responses.activate - async def asyncSetUp(self) -> None: # type: ignore - responses.add( - responses.POST, - "http://localhost:8080/events/setup", - json={"status": "ok"}, - status=200, - ) - responses.add( - responses.POST, - "http://localhost:8080/events/resource-assigned", - match=[header_match], - json={"status": "ok"}, - status=200, - ) - - self.deck = STARLetDeck() - self.tip_carrier = TIP_CAR_480_A00(name="tip_carrier") - self.tip_carrier[0] = self.tip_rack = hamilton_96_tiprack_1000uL_filter(name="tiprack") - self.plate_carrier = PLT_CAR_L5AC_A00(name="plate_carrier") - self.plate_carrier[0] = self.plate = Cor_96_wellplate_360ul_Fb(name="plate") - self.deck.assign_child_resource(self.tip_carrier, rails=3) - self.deck.assign_child_resource(self.plate_carrier, rails=15) - - self.backend = HTTPBackend("localhost", 8080, num_channels=8) - self.lh = LiquidHandler(self.backend, deck=self.deck) - - await self.lh.setup() - - @responses.activate - async def asyncTearDown(self) -> None: # type: ignore - await super().asyncTearDown() - responses.add( - responses.POST, - "http://localhost:8080/events/stop", - json={"status": "ok"}, - status=200, - ) - await self.lh.stop() - - @responses.activate - async def test_tip_pickup(self): - responses.add( - responses.POST, - "http://localhost:8080/events/pick-up-tips", - match=[header_match], - json={"status": "ok"}, - status=200, - ) - await self.lh.pick_up_tips(self.tip_rack["A1"]) - - @responses.activate - async def test_tip_drop(self): - responses.add( - responses.POST, - "http://localhost:8080/events/drop-tips", - match=[header_match], - json={"status": "ok"}, - status=200, - ) - - with no_tip_tracking(): - self.lh.update_head_state({0: self.tip_rack.get_tip("A1")}) - await self.lh.drop_tips(self.tip_rack["A1"]) - - @responses.activate - async def test_aspirate(self): - responses.add( - responses.POST, - "http://localhost:8080/events/aspirate", - match=[header_match], - json={"status": "ok"}, - status=200, - ) - self.lh.update_head_state({0: self.tip_rack.get_tip("A1")}) - well = self.plate.get_item("A1") - well.tracker.set_volume(10) - await self.lh.aspirate([well], [10]) - - @responses.activate - async def test_dispense(self): - responses.add( - responses.POST, - "http://localhost:8080/events/dispense", - match=[header_match], - json={"status": "ok"}, - status=200, - ) - self.lh.update_head_state({0: self.tip_rack.get_tip("A1")}) - self.lh.head[0].get_tip().tracker.add_liquid(10) - with no_volume_tracking(): - await self.lh.dispense(self.plate["A1"], [10]) - - @responses.activate - async def test_pick_up_tips96(self): - responses.add( - responses.POST, - "http://localhost:8080/events/pick-up-tips96", - match=[header_match], - json={"status": "ok"}, - status=200, - ) - await self.lh.pick_up_tips96(self.tip_rack) - - @responses.activate - async def test_drop_tips96(self): - # FIXME: pick up tips first, but make nicer. - responses.add( - responses.POST, - "http://localhost:8080/events/pick-up-tips96", - match=[header_match], - json={"status": "ok"}, - status=200, - ) - await self.lh.pick_up_tips96(self.tip_rack) - - responses.add( - responses.POST, - "http://localhost:8080/events/drop-tips96", - match=[header_match], - json={"status": "ok"}, - status=200, - ) - await self.lh.drop_tips96(self.tip_rack) - - @responses.activate - async def test_aspirate96(self): - # FIXME: pick up tips first, but make nicer. - responses.add( - responses.POST, - "http://localhost:8080/events/pick-up-tips96", - match=[header_match], - json={"status": "ok"}, - status=200, - ) - await self.lh.pick_up_tips96(self.tip_rack) - - responses.add( - responses.POST, - "http://localhost:8080/events/aspirate96", - match=[header_match], - json={"status": "ok"}, - status=200, - ) - - await self.lh.aspirate96(self.plate, 10) - - @responses.activate - async def test_dispense96(self): - # FIXME: pick up tips first, but make nicer. - responses.add( - responses.POST, - "http://localhost:8080/events/pick-up-tips96", - match=[header_match], - json={"status": "ok"}, - status=200, - ) - await self.lh.pick_up_tips96(self.tip_rack) - - # FIXME: aspirate first, but make nicer. - responses.add( - responses.POST, - "http://localhost:8080/events/aspirate96", - match=[header_match], - json={"status": "ok"}, - status=200, - ) - await self.lh.aspirate96(self.plate, 10) - - responses.add( - responses.POST, - "http://localhost:8080/events/dispense96", - match=[header_match], - json={"status": "ok"}, - status=200, - ) - - await self.lh.dispense96(self.plate, 10) diff --git a/pylabrobot/liquid_handling/backends/websocket.py b/pylabrobot/liquid_handling/backends/websocket.py deleted file mode 100644 index b15de365a2b..00000000000 --- a/pylabrobot/liquid_handling/backends/websocket.py +++ /dev/null @@ -1,290 +0,0 @@ -import asyncio -import json -import logging -import threading -import time -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple - -try: - import websockets - import websockets.asyncio.server - import websockets.exceptions - - HAS_WEBSOCKETS = True -except ImportError as e: - HAS_WEBSOCKETS = False - _WEBSOCKETS_IMPORT_ERROR = e - -from pylabrobot.__version__ import STANDARD_FORM_JSON_VERSION -from pylabrobot.liquid_handling.backends.serializing_backend import ( - SerializingBackend, -) - -if TYPE_CHECKING: - import websockets.asyncio.server - - -logger = logging.getLogger("pylabrobot") - - -class WebSocketBackend(SerializingBackend): - """A backend that hosts a websocket server and sends commands over it.""" - - def __init__( - self, - num_channels: int, - ws_host: str = "127.0.0.1", - ws_port: int = 2121, - ): - """Create a new web socket backend. - - Args: - ws_host: The hostname of the websocket server. - ws_port: The port of the websocket server. If this port is in use, the port will be - incremented until a free port is found. - """ - - if not HAS_WEBSOCKETS: - raise RuntimeError( - f"The WebSocketBackend requires websockets to be installed. Import error: {_WEBSOCKETS_IMPORT_ERROR}" - ) - - super().__init__(num_channels=num_channels) - self._websocket: Optional["websockets.asyncio.server.ServerConnection"] = None - self._loop: Optional[asyncio.AbstractEventLoop] = None - self._t: Optional[threading.Thread] = None - self._stop_: Optional[asyncio.Future] = None - - self.ws_host = ws_host - self.ws_port = ws_port - - self._sent_messages: List[str] = [] - self.received: List[dict] = [] - - self._id = 0 - - @property - def websocket( - self, - ) -> "websockets.asyncio.server.ServerConnection": - """The websocket connection.""" - if self._websocket is None: - raise RuntimeError("No websocket connection has been established.") - return self._websocket - - @property - def loop(self) -> asyncio.AbstractEventLoop: - """The event loop.""" - if self._loop is None: - raise RuntimeError("Event loop has not been started.") - return self._loop - - @property - def t(self) -> threading.Thread: - """The thread that runs the event loop.""" - if self._t is None: - raise RuntimeError("Event loop has not been started.") - return self._t - - @property - def stop_(self) -> asyncio.Future: - """The future that is set when the web socket is stopped.""" - if self._stop_ is None: - raise RuntimeError("Event loop has not been started.") - return self._stop_ - - def _generate_id(self): - """continuously generate unique ids 0 <= x < 10000.""" - self._id += 1 - return f"{self._id % 10000:04}" - - async def handle_event(self, event: str, data: dict): - """Handle an event from the browser. - - This method is intended to be overridden by subclasses. Be sure to call the superclass if you - want to preserve the default behavior. - - Args: - event: The event identifier. - data: The event data, deserialized from JSON. - """ - - if event == "ping": - await self.websocket.send(json.dumps({"event": "pong"})) - - async def _socket_handler( - self, - websocket: "websockets.asyncio.server.ServerConnection", - ): - """Handle a new websocket connection. Save the websocket connection store received - messages in `self.received`.""" - - while True: - try: - message = await websocket.recv() - except websockets.exceptions.ConnectionClosed: - return - except asyncio.CancelledError: - return - - data = json.loads(message) - self.received.append(data) - - # If the event is "ready", then we can save the connection and send the saved messages. - if data.get("event") == "ready": - self._websocket = websocket - await self._replay() - - # Echo command - await websocket.send(json.dumps(data)) - - if "event" in data: - await self.handle_event(data.get("event"), data) - else: - logger.warning("Unhandled message: %s", message) - - def _assemble_command(self, event: str, data) -> Tuple[str, str]: - """Assemble a command into standard JSON form.""" - id_ = self._generate_id() - command_data = { - "event": event, - "id": id_, - "version": STANDARD_FORM_JSON_VERSION, - **data, - } - return json.dumps(command_data), id_ - - def has_connection(self) -> bool: - """Return `True` if a websocket connection has been established.""" - # Since the websocket connection is saved in self.websocket, we can just check if it is `None`. - return self._websocket is not None - - def wait_for_connection(self): - """Wait for a websocket connection to be established. - - This method will block until a websocket connection is established. It is not required to wait, - since :meth:`~WebSocketBackend.send_event` automatically save messages until a connection is - established, but only if its `wait_for_response` is `False`. - """ - - while not self.has_connection(): - time.sleep(0.1) - - async def send_command( - self, - command: str, - data: Optional[Dict[str, Any]] = None, - wait_for_response: bool = True, - ) -> Optional[dict]: - """Send an event to the browser. - - If a websocket connection has not been established, the event will be saved and sent when it is - established. - - Args: - event: The event identifier. - wait_for_response: If `True`, the web socker backend will wait for a response from the - browser . If `False`, it is not guaranteed that the response will be available for reading - at a later time. This is useful for sending events that do not require a response. When - `True`, a `ValueError` will be raised if the response `"success"` field is not `True`. - data: The event arguments, which must be serializable by `json.dumps`. - - Returns: - The response from the browser, if `wait_for_response` is `True`, otherwise `None`. - """ - - if data is None: - data = {} - - serialized_data, id_ = self._assemble_command(command, data) - self._sent_messages.append(serialized_data) - - # Run and save if the websocket connection has been established, otherwise just save. - if wait_for_response and not self.has_connection(): - raise ValueError("Cannot wait for response when no websocket connection is established.") - - if self.has_connection(): - asyncio.run_coroutine_threadsafe(self.websocket.send(serialized_data), self.loop) - - if wait_for_response: - while True: - if len(self.received) > 0: - message = self.received.pop() - if "id" in message and message["id"] == id_: - break - time.sleep(0.1) - - if not message["success"]: - error = message.get("error", "unknown error") - raise RuntimeError(f"Error during event {command}: " + error) - - return message - - return None - - async def _replay(self): - """Send all sent messages. - - This is called when the websocket connection is established. - """ - - for message in self._sent_messages: - asyncio.run_coroutine_threadsafe(self.websocket.send(message), self.loop) - - async def setup(self): - """Start the websocket server. This will run in a separate thread.""" - - if not HAS_WEBSOCKETS: - raise RuntimeError( - f"The WebSocketBackend requires websockets to be installed. Import error: {_WEBSOCKETS_IMPORT_ERROR}" - ) - - async def run_server(): - self._stop_ = self.loop.create_future() - while True: - try: - async with websockets.asyncio.server.serve( - self._socket_handler, self.ws_host, self.ws_port - ): - print(f"Websocket server started at http://{self.ws_host}:{self.ws_port}") - lock.release() - await self.stop_ - break - except asyncio.CancelledError: - pass - except OSError: - # If the port is in use, try the next port. - self.ws_port += 1 - - def start_loop(): - self.loop.run_until_complete(run_server()) - - # Acquire a lock to prevent setup from returning until the server is running. - lock = threading.Lock() - lock.acquire() - self._loop = asyncio.new_event_loop() - self._t = threading.Thread(target=start_loop, daemon=True) - self.t.start() - - while lock.locked(): - time.sleep(0.001) - - self.setup_finished = True - - async def stop(self): - """Stop the web socket server.""" - - if self.has_connection(): - # send stop event to the browser - await self.send_command("stop", wait_for_response=False) - - # must be thread safe, because event loop is running in a separate thread - self.loop.call_soon_threadsafe(self.stop_.set_result, "done") - - # Clear all relevant attributes. - self._sent_messages.clear() - self.received.clear() - self._websocket = None - self._loop = None - self._t = None - self._stop_ = None diff --git a/pylabrobot/liquid_handling/backends/websocket_tests.py b/pylabrobot/liquid_handling/backends/websocket_tests.py deleted file mode 100644 index ec21795b5b5..00000000000 --- a/pylabrobot/liquid_handling/backends/websocket_tests.py +++ /dev/null @@ -1,61 +0,0 @@ -import json -import unittest - -import pytest -import websockets - -from pylabrobot.liquid_handling.backends import WebSocketBackend - - -class WebSocketBackendSetupStopTests(unittest.IsolatedAsyncioTestCase): - """Tests for the setup and stop methods of the websocket backend.""" - - @pytest.mark.timeout(20) - async def test_setup_stop(self): - """Test that the thread is started and stopped correctly.""" - - backend = WebSocketBackend(num_channels=8) - - async def setup_stop_single(): - await backend.setup() - self.assertIsNotNone(backend.loop) - await backend.stop() - self.assertFalse(backend.has_connection()) - - # setup and stop twice to ensure that everything is recycled correctly - await setup_stop_single() - await setup_stop_single() - - -class WebSocketBackendServerTests(unittest.IsolatedAsyncioTestCase): - """Tests for servers (ws/fs).""" - - async def asyncSetUp(self): - await super().asyncSetUp() - - self.backend = WebSocketBackend(num_channels=8) - await self.backend.setup() - - ws_port = self.backend.ws_port # port may change if port is already in use - self.uri = f"ws://localhost:{ws_port}" - self.client = await websockets.connect(self.uri) - - async def asyncTearDown(self): - await super().asyncTearDown() - await self.backend.stop() - await self.client.close() - - async def test_connect(self): - await self.client.send('{"event": "ready"}') - response = await self.client.recv() - self.assertEqual(response, '{"event": "ready"}') - - async def test_event_sent(self): - await self.client.send('{"event": "ready"}') - response = await self.client.recv() - self.assertEqual(response, '{"event": "ready"}') - - await self.backend.send_command("test", wait_for_response=False) - recv = await self.client.recv() - data = json.loads(recv) - self.assertEqual(data["event"], "test") diff --git a/pylabrobot/server/README.md b/pylabrobot/server/README.md deleted file mode 100644 index a1780c24be5..00000000000 --- a/pylabrobot/server/README.md +++ /dev/null @@ -1,139 +0,0 @@ -# PyLabRobot Server - -PyLabRobot Server is a server for PyLabRobot: it provides HTTP APIs that can be used to control -a lab. - -## Installation - -``` -pip install pylabrobot[server] -``` - -## Usage - -Each component of the library (currently `liquid_handling` and `plate_reading`) has its own server. Each server will run on its own port. You can use a reverse proxy like [nginx](https://www.nginx.com/) if you wish to use a single port or configure routing. - -Configuration using environment variables: - -- `PORT`: the port to listen on (default: `5001`) -- `HOST`: the host to listen on (default: `0.0.0.0`) - -## API Reference - -All action endpoints return a JSON object with a `status` field. The value of this field can be one of: - -- `queued`: the action is queued -- `running`: the action is running -- `succeeded`: the action succeeded -- `error`: the action failed, see the `message` field for more details - -You can view all tasks using the `GET /tasks` endpoint. You can request the status of a specific task using the `GET /tasks/` endpoint. - -```json -{ - "id": "task_id", - "status": "queued" -} -``` - -```json -{ - "id": "task_id", - "status": "error", - "error": "error message" -} -``` - -### Liquid handling - -Run: - -```sh -lh-server -``` - -The `backend.json` file must contain a serialized backend. See [`LiquidHandlerBackend.deserialize`](https://docs.pylabrobot.org/_autosummary/pylabrobot.liquid_handling.backends.backend.LiquidHandlerBackend.deserialize.html) and [`LiquidHandlerBackend.serialize`](https://docs.pylabrobot.org/_autosummary/pylabrobot.liquid_handling.backends.backend.LiquidHandlerBackend.serialize.html) - -The `deck.json` file must contain a serialized deck. See [`Deck.deserialize`](https://docs.pylabrobot.org/_autosummary/pylabrobot.resources.Deck.deserialize.html) and [`Deck.serialize`](https://docs.pylabrobot.org/_autosummary/pylabrobot.resources.Deck.serialize.html). - -Filenames and paths can be overridden with the `BACKEND_FILE` and `DECK_FILE` environment variables. - -#### Setting up the robot - -`POST /setup` - -#### Stopping the robot - -`POST /stop` - -#### Requesting the robot status - -`GET /status` - -**Response** - -- `200 OK` - -```json -{ - "status": "running" -} -``` - -#### Defining labware - -`POST /labware` - -Post a JSON object that was generatated by calling `serialize()` on `lh`. - -**Response** - -- `201 Created`: the labware was created - -#### Picking up tips - -`POST /pick-up-tips` - -```json -{ - "resource_name": "tiprack_tip_0_0", - "offset": { "x": 0, "y": 0, "z": 0 } -} -``` - -#### Discarding tips - -`POST /discard-tips` - -```json -{ - "resource_name": "tiprack_tip_0_0", - "offset": { "x": 0, "y": 0, "z": 0 } -} -``` - -#### Aspirating liquid - -`POST /aspirate` - -```json -{ - "resource_name": "plate_well_0_0", - "offset": { "x": 0, "y": 0, "z": 0 }, - "volume": 100, - "flow_rate": null -} -``` - -#### Dispensing liquid - -`POST /dispense` - -```json -{ - "resource_name": "plate_well_0_0", - "offset": { "x": 0, "y": 0, "z": 0 }, - "volume": 100, - "flow_rate": null -} -``` diff --git a/pylabrobot/server/liquid_handling_api_tests.py b/pylabrobot/server/liquid_handling_api_tests.py deleted file mode 100644 index 0abf774440c..00000000000 --- a/pylabrobot/server/liquid_handling_api_tests.py +++ /dev/null @@ -1,278 +0,0 @@ -import logging -import time -import unittest -from pathlib import Path -from typing import cast -from unittest.mock import PropertyMock - -from pylabrobot import Config -from pylabrobot.liquid_handling import LiquidHandler -from pylabrobot.liquid_handling.backends.backend import LiquidHandlerBackend -from pylabrobot.resources import ( - PLT_CAR_L5AC_A00, - TIP_CAR_480_A00, - Cor_96_wellplate_360ul_Fb, - Plate, - TipRack, - hamilton_96_tiprack_1000uL_filter, - no_tip_tracking, -) -from pylabrobot.resources.hamilton import HamiltonDeck, STARLetDeck -from pylabrobot.serializer import serialize -from pylabrobot.server.liquid_handling_server import create_app - - -def _create_mock_backend(num_channels: int = 8): - """Create a mock LiquidHandlerBackend with the specified number of channels.""" - mock = unittest.mock.create_autospec(LiquidHandlerBackend, instance=True) - type(mock).num_channels = PropertyMock(return_value=num_channels) - type(mock).num_arms = PropertyMock(return_value=1) - type(mock).head96_installed = PropertyMock(return_value=True) - mock.can_pick_up_tip.return_value = True - return mock - - -def build_layout() -> HamiltonDeck: - # copied from liquid_handler_tests.py, can we make this shared? - tip_car = TIP_CAR_480_A00(name="tip_carrier") - tip_car[0] = hamilton_96_tiprack_1000uL_filter(name="tip_rack_01") - - plt_car = PLT_CAR_L5AC_A00(name="plate_carrier") - plt_car[0] = plate = Cor_96_wellplate_360ul_Fb(name="aspiration plate") - plate.get_item("A1").tracker.set_volume(400) - - deck = STARLetDeck() - deck.assign_child_resource(tip_car, rails=1) - deck.assign_child_resource(plt_car, rails=21) - return deck - - -def _wait_for_task_done(base_url, client, task_id): - while True: - response = client.get(base_url + f"/tasks/{task_id}") - if response.json is None: - raise RuntimeError("No JSON in response: " + response.text) - if response.json.get("status") == "running": - time.sleep(0.1) - else: - return response - - -class LiquidHandlingApiGeneralTests(unittest.IsolatedAsyncioTestCase): - def setUp(self): - self.backend = _create_mock_backend(num_channels=8) - self.deck = STARLetDeck() - self.lh = LiquidHandler(backend=self.backend, deck=self.deck) - self.app = create_app(lh=self.lh) - self.base_url = "" - - def test_get_index(self): - with self.app.test_client() as client: - response = client.get(self.base_url + "/") - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data, b"PLR Liquid Handling API") - - def test_setup(self): # TODO: Figure out how we can configure LH - with self.app.test_client() as client: - task = client.post(self.base_url + "/setup") - response = _wait_for_task_done(self.base_url, client, task.json.get("id")) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json.get("status"), "succeeded") - - time.sleep(0.1) - assert self.lh.setup_finished - - def test_stop(self): - with self.app.test_client() as client: - task = client.post(self.base_url + "/setup") - response = _wait_for_task_done(self.base_url, client, task.json.get("id")) - - task = client.post(self.base_url + "/stop") - response = _wait_for_task_done(self.base_url, client, task.json.get("id")) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json.get("status"), "succeeded") - - assert not self.lh.setup_finished - - async def test_status(self): - with self.app.test_client() as client: - response = client.get(self.base_url + "/status") - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json, {"status": "stopped"}) - - await self.lh.setup() - response = client.get(self.base_url + "/status") - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json.get("status"), "running") - - await self.lh.stop() - response = client.get(self.base_url + "/status") - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json.get("status"), "stopped") - - def test_load_labware(self): - with self.app.test_client() as client: - # Post with no data - response = client.post( - self.base_url + "/labware", - headers={"Content-Type": "application/json"}, - ) - self.assertEqual(response.status_code, 400) - self.assertEqual(response.json, {"error": "json data must be a dict"}) - - # Post with invalid data - response = client.post(self.base_url + "/labware", json={"foo": "bar"}) - self.assertEqual(response.status_code, 400) - self.assertEqual(response.json, {"error": "missing key in json data: 'deck'"}) - - # Post with valid data - deck = build_layout() - response = client.post(self.base_url + "/labware", json={"deck": deck.serialize()}) - self.assertEqual(response.json, {"status": "ok"}) - self.assertEqual(response.status_code, 200) - self.assertEqual(self.lh.deck, deck) - - -class LiquidHandlingApiOpsTests(unittest.TestCase): - def setUp(self) -> None: - self.backend = _create_mock_backend(num_channels=8) - self.deck = STARLetDeck() - self.lh = LiquidHandler(backend=self.backend, deck=self.deck) - self.app = create_app(lh=self.lh) - self.base_url = "" - - deck = build_layout() - with self.app.test_client() as client: - response = client.post(self.base_url + "/labware", json={"deck": deck.serialize()}) - assert response.status_code == 200 - assert self.lh.deck == deck - assert self.lh.deck.get_all_children() == deck.get_all_children() - - client.post(self.base_url + "/setup") - time.sleep(0.5) - - def test_tip_pickup(self): - with self.app.test_client() as client: - tip_rack = cast(TipRack, self.lh.deck.get_resource("tip_rack_01")) - tip_spot = tip_rack.get_item("A1") - with no_tip_tracking(): - tip = tip_spot.get_tip() - task = client.post( - self.base_url + "/pick-up-tips", - json={ - "channels": [ - { - "resource_name": tip_spot.name, - "tip": serialize(tip), - "offset": None, - } - ], - "use_channels": [0], - }, - ) - response = _wait_for_task_done(self.base_url, client, task.json.get("id")) - self.assertEqual(response.json.get("status"), "succeeded") - self.assertEqual(response.status_code, 200) - - def test_drop_tip(self): - with self.app.test_client() as client: - tip_rack = cast(TipRack, self.lh.deck.get_resource("tip_rack_01")) - tip_spot = tip_rack.get_item("A1") - with no_tip_tracking(): - tip = tip_spot.get_tip() - - self.test_tip_pickup() # Pick up a tip first - - task = client.post( - self.base_url + "/drop-tips", - json={ - "channels": [ - { - "resource_name": tip_spot.name, - "tip": serialize(tip), - "offset": None, - } - ], - "use_channels": [0], - }, - ) - response = _wait_for_task_done(self.base_url, client, task.json.get("id")) - self.assertEqual(response.json.get("status"), "succeeded") - self.assertEqual(response.status_code, 200) - - def test_aspirate(self): - with no_tip_tracking(): - tip = cast(TipRack, self.lh.deck.get_resource("tip_rack_01")).get_tip("A1") - self.test_tip_pickup() # pick up a tip first - with self.app.test_client() as client: - well = cast(Plate, self.lh.deck.get_resource("aspiration plate")).get_item("A1") - task = client.post( - self.base_url + "/aspirate", - json={ - "channels": [ - { - "resource_name": well.name, - "volume": 10.0, - "tip": serialize(tip), - "offset": { - "type": "Coordinate", - "x": 0, - "y": 0, - "z": 0, - }, - "flow_rate": None, - "liquid_height": None, - "blow_out_air_volume": 0, - } - ], - "use_channels": [0], - }, - ) - print(task) - response = _wait_for_task_done(self.base_url, client, task.json.get("id")) - self.assertEqual(response.json.get("status"), "succeeded") - self.assertEqual(response.status_code, 200) - - def test_dispense(self): - with no_tip_tracking(): - tip = cast(TipRack, self.lh.deck.get_resource("tip_rack_01")).get_tip("A1") - self.test_aspirate() # aspirate first - with self.app.test_client() as client: - well = cast(Plate, self.lh.deck.get_resource("aspiration plate")).get_item("A1") - task = client.post( - self.base_url + "/dispense", - json={ - "channels": [ - { - "resource_name": well.name, - "volume": 10, - "tip": serialize(tip), - "offset": { - "type": "Coordinate", - "x": 0, - "y": 0, - "z": 0, - }, - "flow_rate": None, - "liquid_height": None, - "blow_out_air_volume": 0, - } - ], - "use_channels": [0], - }, - ) - response = _wait_for_task_done(self.base_url, client, task.json.get("id")) - self.assertEqual(response.json.get("status"), "succeeded") - self.assertEqual(response.status_code, 200) - - def test_config(self): - cfg = Config(logging=Config.Logging(log_dir=Path("logs"), level=logging.CRITICAL)) - with self.app.test_client() as client: - logger = logging.getLogger("pylabrobot") - cur_level = logger.level - response = client.post(self.base_url + "/config", json=cfg.as_dict) - new_level = logging.getLogger("pylabrobot").level - self.assertEqual(response.json, cfg.as_dict) - self.assertEqual(response.status_code, 200) - self.assertEqual(new_level, logging.CRITICAL) - self.assertNotEqual(cur_level, new_level) diff --git a/pylabrobot/server/liquid_handling_server.py b/pylabrobot/server/liquid_handling_server.py deleted file mode 100644 index 821e77a568e..00000000000 --- a/pylabrobot/server/liquid_handling_server.py +++ /dev/null @@ -1,360 +0,0 @@ -"""Needs some refactoring.""" -# mypy: disable-error-code = attr-defined - -import asyncio -import json -import os -import threading -from typing import Any, Coroutine, List, Optional, cast - -import werkzeug -from flask import ( - Blueprint, - Flask, - Request, - current_app, - jsonify, - request, -) - -from pylabrobot import Config, configure -from pylabrobot.config.formats.json_config import JsonLoader -from pylabrobot.config.io import ConfigReader -from pylabrobot.liquid_handling import LiquidHandler -from pylabrobot.liquid_handling.backends.backend import ( - LiquidHandlerBackend, -) -from pylabrobot.liquid_handling.standard import ( - Drop, - Mix, - Pickup, - SingleChannelAspiration, - SingleChannelDispense, -) -from pylabrobot.resources import Coordinate, Deck, Tip -from pylabrobot.serializer import deserialize - -lh_api = Blueprint("liquid handling", __name__) - - -class Task: - """A task is a coroutine that runs in a separate thread. Maintains its own event loop and - status.""" - - def __init__(self, co: Coroutine[Any, Any, None]): - self.status = "queued" - self.co = co - self.error: Optional[str] = None - - def run_in_thread(self) -> None: - """Run the coroutine in a new thread.""" - - def runner(): - self.status = "running" - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - try: - loop.run_until_complete(self.co) - except Exception as e: - self.error = str(e) - self.status = "error" - else: - self.status = "succeeded" - - t = threading.Thread(target=runner) - t.start() - - def serialize(self, id_: int) -> dict: - d = {"id": id_, "status": self.status} - if self.error is not None: - d["error"] = self.error - return d - - -tasks: List[Task] = [] - - -def add_and_run_task(task: Task): - id_ = len(tasks) - tasks.append(task) - task.run_in_thread() - return task.serialize(id_) - - -@lh_api.route("/") -def index(): - return "PLR Liquid Handling API" - - -@lh_api.route("/tasks", methods=["GET"]) -def get_tasks(): - return jsonify([{"id": i, "status": t.status} for i, t in enumerate(tasks)]) - - -@lh_api.route("/tasks/", methods=["GET"]) -def get_task(id_: int): - if id_ >= len(tasks): - return jsonify({"error": "task not found"}), 404 - return tasks[id_].serialize(id_) - - -@lh_api.route("/setup", methods=["POST"]) -async def setup(): - return add_and_run_task(Task(current_app.lh.setup())) - - -@lh_api.route("/stop", methods=["POST"]) -async def stop(): - return add_and_run_task(Task(current_app.lh.stop())) - - -@lh_api.route("/status", methods=["GET"]) -def get_status(): - status = "running" if current_app.lh.setup_finished else "stopped" - return jsonify({"status": status}) - - -@lh_api.route("/labware", methods=["POST"]) -def define_labware(): - try: - data = request.get_json() - if not isinstance(data, dict): - raise werkzeug.exceptions.BadRequest - except werkzeug.exceptions.BadRequest: - return jsonify({"error": "json data must be a dict"}), 400 - - try: - deck = Deck.deserialize(data=data["deck"]) - current_app.lh.deck = deck - except KeyError as e: - return jsonify({"error": "missing key in json data: " + str(e)}), 400 - - return jsonify({"status": "ok"}) - - -class ErrorResponse(Exception): - def __init__(self, data: dict, status_code: int): - self.data = data - self.status_code = status_code - - -@lh_api.route("/pick-up-tips", methods=["POST"]) -async def pick_up_tips(): - try: - data = request.get_json() - pickups = [] - for sc in data["channels"]: - try: - resource = current_app.lh.deck.get_resource(sc["resource_name"]) - except ValueError as exc: - raise ErrorResponse( - {"error": f"resource with name '{sc['resource_name']}' not found"}, - 404, - ) from exc - if "tip" not in sc: - raise ErrorResponse({"error": "missing key in json data: tip"}, 400) - tip = cast(Tip, deserialize(sc["tip"])) - if "offset" not in sc: - raise ErrorResponse({"error": "missing key in json data: offset"}, 400) - offset = cast(Coordinate, deserialize(sc["offset"])) - pickups.append(Pickup(resource=resource, tip=tip, offset=offset)) - use_channels = data["use_channels"] - except ErrorResponse as e: - return jsonify(e.data), e.status_code - - return add_and_run_task( - Task( - current_app.lh.pick_up_tips( - tip_spots=[p.resource for p in pickups], - offsets=[p.offset for p in pickups], - use_channels=use_channels, - ) - ) - ) - - -@lh_api.route("/drop-tips", methods=["POST"]) -async def drop_tips(): - try: - data = request.get_json() - drops = [] - for sc in data["channels"]: - try: - resource = current_app.lh.deck.get_resource(sc["resource_name"]) - except ValueError as exc: - raise ErrorResponse( - {"error": f"resource with name '{sc['resource_name']}' not found"}, - 404, - ) from exc - if "tip" not in sc: - raise ErrorResponse({"error": "missing key in json data: tip"}, 400) - tip = cast(Tip, deserialize(sc["tip"])) - if "offset" not in sc: - raise ErrorResponse({"error": "missing key in json data: offset"}, 400) - offset = cast(Coordinate, deserialize(sc["offset"])) - drops.append(Drop(resource=resource, tip=tip, offset=offset)) - use_channels = data["use_channels"] - except ErrorResponse as e: - return jsonify(e.data), e.status_code - - return add_and_run_task( - Task( - current_app.lh.drop_tips( - tip_spots=[d.resource for d in drops], - offsets=[d.offset for d in drops], - use_channels=use_channels, - ) - ) - ) - - -@lh_api.route("/aspirate", methods=["POST"]) -async def aspirate(): - try: - data = request.get_json() - aspirations = [] - for sc in data["channels"]: - try: - resource = current_app.lh.deck.get_resource(sc["resource_name"]) - except ValueError as exc: - raise ErrorResponse( - {"error": f"resource with name '{sc['resource_name']}' not found"}, - 404, - ) from exc - if "tip" not in sc: - raise ErrorResponse({"error": "missing key in json data: tip"}, 400) - tip = cast(Tip, deserialize(sc["tip"])) - if "offset" not in sc: - raise ErrorResponse({"error": "missing key in json data: offset"}, 400) - offset = cast(Coordinate, deserialize(sc["offset"])) - volume = sc["volume"] - flow_rate = sc["flow_rate"] - liquid_height = sc["liquid_height"] - blow_out_air_volume = sc["blow_out_air_volume"] - mix = Mix(**sc["mix"]) if sc.get("mix") is not None else None - aspirations.append( - SingleChannelAspiration( - resource=resource, - tip=tip, - offset=offset, - volume=volume, - flow_rate=flow_rate, - liquid_height=liquid_height, - blow_out_air_volume=blow_out_air_volume, - mix=mix, - ) - ) - use_channels = data["use_channels"] - except ErrorResponse as e: - return jsonify(e.data), e.status_code - - return add_and_run_task( - Task( - current_app.lh.aspirate( - resources=[a.resource for a in aspirations], - vols=[a.volume for a in aspirations], - offsets=[a.offset for a in aspirations], - flow_rates=[a.flow_rate for a in aspirations], - use_channels=use_channels, - ) - ) - ) - - -@lh_api.route("/dispense", methods=["POST"]) -async def dispense(): - try: - data = request.get_json() - dispenses = [] - for sc in data["channels"]: - try: - resource = current_app.lh.deck.get_resource(sc["resource_name"]) - except ValueError as exc: - raise ErrorResponse( - {"error": f"resource with name '{sc['resource_name']}' not found"}, - 404, - ) from exc - if "tip" not in sc: - raise ErrorResponse({"error": "missing key in json data: tip"}, 400) - tip = cast(Tip, deserialize(sc["tip"])) - if "offset" not in sc: - raise ErrorResponse({"error": "missing key in json data: offset"}, 400) - offset = cast(Coordinate, deserialize(sc["offset"])) - volume = sc["volume"] - flow_rate = sc["flow_rate"] - liquid_height = sc["liquid_height"] - blow_out_air_volume = sc["blow_out_air_volume"] - mix = Mix(**sc["mix"]) if sc.get("mix") is not None else None - dispenses.append( - SingleChannelDispense( - resource=resource, - tip=tip, - offset=offset, - volume=volume, - flow_rate=flow_rate, - liquid_height=liquid_height, - blow_out_air_volume=blow_out_air_volume, - mix=mix, - ) - ) - use_channels = data["use_channels"] - except ErrorResponse as e: - return jsonify(e.data), e.status_code - - return add_and_run_task( - Task( - current_app.lh.dispense( - resources=[d.resource for d in dispenses], - vols=[d.volume for d in dispenses], - offsets=[d.offset for d in dispenses], - flow_rates=[d.flow_rate for d in dispenses], - use_channels=use_channels, - ) - ) - ) - - -class HttpReader(ConfigReader): - def read(self, r: Request) -> Config: - return self.format_loader.load(r.stream) - - -CONFIG_READER = HttpReader(format_loader=JsonLoader()) - - -@lh_api.route("/config", methods=["POST"]) -async def config(): - cfg = CONFIG_READER.read(request) - configure(cfg) - return jsonify(cfg.as_dict) - - -def create_app(lh: LiquidHandler): - """Create a Flask app with the given LiquidHandler""" - app = Flask(__name__) - app.lh = lh - app.register_blueprint(lh_api) - return app - - -def main(): - backend_file = os.environ.get("BACKEND_FILE", "backend.json") - with open(backend_file, "r", encoding="utf-8") as f: - data = json.load(f) - backend = LiquidHandlerBackend.deserialize(data) - - deck_file = os.environ.get("DECK_FILE", "deck.json") - with open(deck_file, "r", encoding="utf-8") as f: - data = json.load(f) - deck = Deck.deserialize(data) - - lh = LiquidHandler(backend=backend, deck=deck) - - app = create_app(lh) - host = os.environ.get("HOST", "0.0.0.0") - port = int(os.environ.get("PORT", 5001)) - app.run(debug=True, host=host, port=port) - - -if __name__ == "__main__": - main() diff --git a/pyproject.toml b/pyproject.toml index 1a444938617..a200c578df4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,11 +18,10 @@ plate_reading = ["pylibftdi==0.23.0"] websockets = ["websockets==15.0.1"] visualizer = ["websockets==15.0.1"] opentrons = ["opentrons-http-api-client"] -server = ["flask[async]==3.1.2"] inheco = ["hid==1.0.8"] agrow = ["pymodbus==3.6.8"] dev = [ - "PyLabRobot[fw,http,plate_reading,websockets,visualizer,opentrons,server,inheco,agrow]", + "PyLabRobot[fw,http,plate_reading,websockets,visualizer,opentrons,inheco,agrow]", "pytest==8.4.2", "pytest-timeout==2.4.0", "mypy==1.18.2", @@ -38,7 +37,6 @@ dev = [ all = ["PyLabRobot[dev]"] [project.scripts] -lh-server = "pylabrobot.server.liquid_handling_server:main" plr-gui = "pylabrobot.gui.gui:main" [project.urls]