From 4e64e07bcac81cc2ea69472d799c9a6f5fda9128 Mon Sep 17 00:00:00 2001 From: Keoni Gandall Date: Tue, 17 Feb 2026 11:23:59 -0800 Subject: [PATCH 1/2] Add remote deck server/client via ConnectRPC Serve a Deck's resource tree over HTTP so a LiquidHandler on another machine (or process) can use it as a drop-in replacement. The server wraps a real Deck and exposes 31 RPCs covering tree retrieval, spatial queries, volume/tip tracker mutations, and structure changes. The client (RemoteDeck) fetches the tree on connect and builds local proxy objects that pass isinstance checks (WellProxy IS-A Well, etc.) while delegating spatial computations and state mutations to the server. Design decisions: - ConnectRPC (connect-python) over plain REST because it gives us typed proto schemas, generated client/server stubs, and the Connect protocol works over standard HTTP/1.1 (no gRPC infrastructure needed). - Proxies inherit from real resource types so backends that do isinstance(resource, Well) etc. work unchanged. A _SpatialMixin factors out the 7 overridden spatial methods. - Immutable data (name, sizes, material properties, ordering) is held locally; mutable state (tracker volumes, tip presence, lid state, computed locations) always goes to the server to avoid staleness. - connect-python stubs are hand-written (matching their codegen pattern) to avoid a build-time protoc-gen-connect-python dependency; the protobuf pb2/pyi files are generated via grpc_tools.protoc. New optional dependency group: pip install PyLabRobot[remote] Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + pylabrobot/resources/remote/__init__.py | 6 + pylabrobot/resources/remote/client.py | 88 +++ .../resources/remote/deck_service.proto | 238 ++++++++ .../resources/remote/deck_service_connect.py | 426 +++++++++++++ .../resources/remote/deck_service_pb2.py | 100 ++++ .../resources/remote/deck_service_pb2.pyi | 296 +++++++++ pylabrobot/resources/remote/example/README.md | 27 + pylabrobot/resources/remote/example/client.py | 26 + pylabrobot/resources/remote/example/server.py | 14 + pylabrobot/resources/remote/proxies.py | 303 ++++++++++ .../resources/remote/remote_trackers.py | 159 +++++ pylabrobot/resources/remote/server.py | 341 +++++++++++ pylabrobot/resources/remote/tests/__init__.py | 0 .../remote/tests/remote_deck_tests.py | 566 ++++++++++++++++++ pyproject.toml | 3 +- 16 files changed, 2593 insertions(+), 1 deletion(-) create mode 100644 pylabrobot/resources/remote/__init__.py create mode 100644 pylabrobot/resources/remote/client.py create mode 100644 pylabrobot/resources/remote/deck_service.proto create mode 100644 pylabrobot/resources/remote/deck_service_connect.py create mode 100644 pylabrobot/resources/remote/deck_service_pb2.py create mode 100644 pylabrobot/resources/remote/deck_service_pb2.pyi create mode 100644 pylabrobot/resources/remote/example/README.md create mode 100644 pylabrobot/resources/remote/example/client.py create mode 100644 pylabrobot/resources/remote/example/server.py create mode 100644 pylabrobot/resources/remote/proxies.py create mode 100644 pylabrobot/resources/remote/remote_trackers.py create mode 100644 pylabrobot/resources/remote/server.py create mode 100644 pylabrobot/resources/remote/tests/__init__.py create mode 100644 pylabrobot/resources/remote/tests/remote_deck_tests.py diff --git a/.gitignore b/.gitignore index 10d2b64ea58..be217eab178 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ build/lib myenv env/* .venv +venv diff --git a/pylabrobot/resources/remote/__init__.py b/pylabrobot/resources/remote/__init__.py new file mode 100644 index 00000000000..3350e3c221e --- /dev/null +++ b/pylabrobot/resources/remote/__init__.py @@ -0,0 +1,6 @@ +"""Remote deck via ConnectRPC — serve a Deck over HTTP and access it as a drop-in replacement.""" + +from .client import RemoteDeck +from .server import DeckServiceImpl, create_app + +__all__ = ["RemoteDeck", "DeckServiceImpl", "create_app"] diff --git a/pylabrobot/resources/remote/client.py b/pylabrobot/resources/remote/client.py new file mode 100644 index 00000000000..5aaa0901ee7 --- /dev/null +++ b/pylabrobot/resources/remote/client.py @@ -0,0 +1,88 @@ +"""RemoteDeck — drop-in Deck replacement that loads from a ConnectRPC server.""" + +from __future__ import annotations + +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.deck import Deck +from pylabrobot.resources.resource import Resource + +from . import deck_service_pb2 as pb2 +from .deck_service_connect import DeckServiceClientSync +from .proxies import _SpatialMixin, create_proxy + + +class RemoteDeck(_SpatialMixin, Deck): + """Drop-in replacement for Deck that loads its resource tree from a ConnectRPC server. + + Usage:: + + deck = RemoteDeck.connect("http://localhost:8080") + lh = LiquidHandler(backend=STARBackend(), deck=deck) + await lh.setup() + """ + + def __init__(self, client: DeckServiceClientSync): + self._client = client + self._building = True # suppress RPC calls during initial tree build + + # Fetch the full tree from the server + tree = client.get_tree(pb2.GetTreeRequest()) + deck_data = tree.data + + # Initialize the real Deck base class with the server's dimensions + origin = Coordinate(0, 0, 0) + if deck_data.HasField("location"): + origin = Coordinate(deck_data.location.x, deck_data.location.y, deck_data.location.z) + + Deck.__init__( + self, + size_x=deck_data.size_x, + size_y=deck_data.size_y, + size_z=deck_data.size_z, + name=deck_data.name or "deck", + origin=origin, + ) + + # Recursively build proxy tree and assign as children + self._build_tree(tree, parent=self) + self._building = False + + def _build_tree(self, tree_node: pb2.ResourceTree, parent: Resource) -> None: + """Recursively create proxy objects and wire up parent/child.""" + for child_tree in tree_node.children: + child_data = child_tree.data + proxy = create_proxy(self._client, child_data) + loc = Coordinate(0, 0, 0) + if child_data.HasField("location"): + loc = Coordinate( + child_data.location.x, child_data.location.y, child_data.location.z) + # Use Resource.assign_child_resource directly to avoid RPC during build + Resource.assign_child_resource(parent, proxy, location=loc) + self._build_tree(child_tree, parent=proxy) + + @classmethod + def connect(cls, base_url: str = "http://localhost:8080") -> RemoteDeck: + """Connect to a remote deck server. + + Args: + base_url: The HTTP URL of the deck server (e.g. "http://localhost:8080") + """ + client = DeckServiceClientSync(address=base_url) + return cls(client) + + # --- Structure mutations go to server --- + + def assign_child_resource(self, resource, location=None, reassign=True): + super().assign_child_resource(resource, location=location, reassign=reassign) + if not self._building and location is not None: + self._client.assign_child(pb2.AssignChildRequest( + child_name=resource.name, + parent_name=self.name, + location=pb2.Coordinate(x=location.x, y=location.y, z=location.z), + )) + + def unassign_child_resource(self, resource): + super().unassign_child_resource(resource) + if not self._building: + self._client.unassign_child( + pb2.UnassignChildRequest(resource_name=resource.name)) diff --git a/pylabrobot/resources/remote/deck_service.proto b/pylabrobot/resources/remote/deck_service.proto new file mode 100644 index 00000000000..352888bebf7 --- /dev/null +++ b/pylabrobot/resources/remote/deck_service.proto @@ -0,0 +1,238 @@ +syntax = "proto3"; +package pylabrobot.resources.remote; + +// ============================================================ +// Messages +// ============================================================ + +message Empty {} + +message Coordinate { + double x = 1; + double y = 2; + double z = 3; +} + +message Rotation { + double x = 1; + double y = 2; + double z = 3; +} + +message Size { + double x = 1; + double y = 2; + double z = 3; +} + +message TipData { + string type = 1; // "Tip" or "HamiltonTip" + string name = 2; + bool has_filter = 3; + double total_tip_length = 4; + double maximal_volume = 5; + double fitting_depth = 6; + string tip_size = 7; // TipSize enum name, empty if plain Tip + string pickup_method = 8; // TipPickupMethod enum name, empty if plain Tip +} + +message ResourceData { + string name = 1; + string type = 2; // Python class name: "Well", "Plate", "TipSpot", etc. + double size_x = 3; + double size_y = 4; + double size_z = 5; + string category = 6; + string model = 7; + Coordinate location = 8; + Rotation rotation = 9; + string parent_name = 10; + + optional double material_z_thickness = 11; + optional double max_volume = 12; + + string well_bottom_type = 13; + string cross_section_type = 14; + + string plate_type = 15; + bool has_lid = 16; + + TipData prototype_tip = 17; + + map ordering = 18; + + optional double nesting_z_height = 19; +} + +message ResourceTree { + ResourceData data = 1; + repeated ResourceTree children = 2; +} + +message VolumeTrackerState { + double volume = 1; + double pending_volume = 2; + double max_volume = 3; + bool is_disabled = 4; +} + +message TipTrackerState { + bool has_tip = 1; + TipData tip = 2; + bool is_disabled = 3; +} + +// ============================================================ +// Requests +// ============================================================ + +message GetTreeRequest { + string root_name = 1; +} + +message ResourceByNameRequest { + string name = 1; +} + +message GetLocationWrtRequest { + string resource_name = 1; + string other_name = 2; + string anchor_x = 3; + string anchor_y = 4; + string anchor_z = 5; +} + +message GetAbsoluteLocationRequest { + string resource_name = 1; + string anchor_x = 2; + string anchor_y = 3; + string anchor_z = 4; +} + +message GetAbsoluteRotationRequest { + string resource_name = 1; +} + +message GetAbsoluteSizeRequest { + string resource_name = 1; +} + +message GetHighestPointRequest { + string resource_name = 1; +} + +message LocationWrtItem { + string resource_name = 1; + string other_name = 2; + string anchor_x = 3; + string anchor_y = 4; + string anchor_z = 5; +} + +message BatchGetLocationWrtRequest { + repeated LocationWrtItem items = 1; +} + +message BatchCoordinateResponse { + repeated Coordinate coordinates = 1; +} + +message ComputeVolumeHeightRequest { + string resource_name = 1; + double value = 2; +} + +message FloatResponse { + double value = 1; +} + +message BoolResponse { + bool value = 1; +} + +message GetTipRequest { + string tip_spot_name = 1; +} + +message TrackerOpRequest { + string resource_name = 1; + double volume = 2; +} + +message BatchTrackerOpRequest { + repeated TrackerOpRequest ops = 1; +} + +message TipTrackerOpRequest { + string tip_spot_name = 1; +} + +message CommitRollbackRequest { + repeated string resource_names = 1; +} + +message AssignChildRequest { + string child_name = 1; + string parent_name = 2; + Coordinate location = 3; +} + +message UnassignChildRequest { + string resource_name = 1; +} + +message HasLidRequest { + string plate_name = 1; +} + +// ============================================================ +// Service +// ============================================================ + +service DeckService { + // --- Tree --- + rpc GetTree(GetTreeRequest) returns (ResourceTree); + rpc GetResource(ResourceByNameRequest) returns (ResourceData); + rpc HasResource(ResourceByNameRequest) returns (BoolResponse); + rpc GetTrashArea(Empty) returns (ResourceData); + rpc GetTrashArea96(Empty) returns (ResourceData); + + // --- Spatial --- + rpc GetLocationWrt(GetLocationWrtRequest) returns (Coordinate); + rpc GetAbsoluteLocation(GetAbsoluteLocationRequest) returns (Coordinate); + rpc GetAbsoluteRotation(GetAbsoluteRotationRequest) returns (Rotation); + rpc GetAbsoluteSize(GetAbsoluteSizeRequest) returns (Size); + rpc GetHighestPoint(GetHighestPointRequest) returns (FloatResponse); + rpc BatchGetLocationWrt(BatchGetLocationWrtRequest) returns (BatchCoordinateResponse); + + // --- Computed methods --- + rpc ComputeVolumeFromHeight(ComputeVolumeHeightRequest) returns (FloatResponse); + rpc ComputeHeightFromVolume(ComputeVolumeHeightRequest) returns (FloatResponse); + rpc SupportsComputeHeightVolume(ResourceByNameRequest) returns (BoolResponse); + rpc HasLid(HasLidRequest) returns (BoolResponse); + + // --- Tip access --- + rpc GetTip(GetTipRequest) returns (TipData); + + // --- Volume tracker --- + rpc GetVolumeTrackerState(ResourceByNameRequest) returns (VolumeTrackerState); + rpc RemoveLiquid(TrackerOpRequest) returns (Empty); + rpc AddLiquid(TrackerOpRequest) returns (Empty); + rpc BatchRemoveLiquid(BatchTrackerOpRequest) returns (Empty); + rpc BatchAddLiquid(BatchTrackerOpRequest) returns (Empty); + + // --- Tip tracker --- + rpc GetTipTrackerState(ResourceByNameRequest) returns (TipTrackerState); + rpc RemoveTip(TipTrackerOpRequest) returns (Empty); + rpc AddTip(TipTrackerOpRequest) returns (Empty); + + // --- Commit / Rollback --- + rpc CommitVolumeTrackers(CommitRollbackRequest) returns (Empty); + rpc RollbackVolumeTrackers(CommitRollbackRequest) returns (Empty); + rpc CommitTipTrackers(CommitRollbackRequest) returns (Empty); + rpc RollbackTipTrackers(CommitRollbackRequest) returns (Empty); + + // --- Structure mutation --- + rpc AssignChild(AssignChildRequest) returns (Empty); + rpc UnassignChild(UnassignChildRequest) returns (Empty); +} diff --git a/pylabrobot/resources/remote/deck_service_connect.py b/pylabrobot/resources/remote/deck_service_connect.py new file mode 100644 index 00000000000..0cc33cee103 --- /dev/null +++ b/pylabrobot/resources/remote/deck_service_connect.py @@ -0,0 +1,426 @@ +"""ConnectRPC service stubs for DeckService. + +Hand-written to match the pattern generated by protoc-gen-connect-python. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Protocol + +from connectrpc.client import ConnectClient, ConnectClientSync +from connectrpc.code import Code +from connectrpc.errors import ConnectError +from connectrpc.method import IdempotencyLevel, MethodInfo +from connectrpc.server import ( + ConnectASGIApplication, + Endpoint, +) + +from . import deck_service_pb2 as pb2 + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator, Iterable, Mapping + + from connectrpc.interceptor import Interceptor + from connectrpc.request import Headers, RequestContext + + +_SVC = "pylabrobot.resources.remote.DeckService" + + +def _method(name: str, input_cls: type, output_cls: type, + idempotency: IdempotencyLevel = IdempotencyLevel.UNKNOWN) -> MethodInfo: + return MethodInfo(name=name, service_name=_SVC, + input=input_cls, output=output_cls, + idempotency_level=idempotency) + + +# All read-only RPCs use NO_SIDE_EFFECTS for idempotency. +_NS = IdempotencyLevel.NO_SIDE_EFFECTS + +# ============================================================ +# Method descriptors +# ============================================================ + +_GET_TREE = _method("GetTree", pb2.GetTreeRequest, pb2.ResourceTree, _NS) +_GET_RESOURCE = _method("GetResource", pb2.ResourceByNameRequest, pb2.ResourceData, _NS) +_HAS_RESOURCE = _method("HasResource", pb2.ResourceByNameRequest, pb2.BoolResponse, _NS) +_GET_TRASH_AREA = _method("GetTrashArea", pb2.Empty, pb2.ResourceData, _NS) +_GET_TRASH_AREA96 = _method("GetTrashArea96", pb2.Empty, pb2.ResourceData, _NS) + +_GET_LOCATION_WRT = _method("GetLocationWrt", pb2.GetLocationWrtRequest, pb2.Coordinate, _NS) +_GET_ABSOLUTE_LOCATION = _method("GetAbsoluteLocation", pb2.GetAbsoluteLocationRequest, pb2.Coordinate, _NS) +_GET_ABSOLUTE_ROTATION = _method("GetAbsoluteRotation", pb2.GetAbsoluteRotationRequest, pb2.Rotation, _NS) +_GET_ABSOLUTE_SIZE = _method("GetAbsoluteSize", pb2.GetAbsoluteSizeRequest, pb2.Size, _NS) +_GET_HIGHEST_POINT = _method("GetHighestPoint", pb2.GetHighestPointRequest, pb2.FloatResponse, _NS) +_BATCH_GET_LOCATION_WRT = _method("BatchGetLocationWrt", pb2.BatchGetLocationWrtRequest, pb2.BatchCoordinateResponse, _NS) + +_COMPUTE_VOLUME_FROM_HEIGHT = _method("ComputeVolumeFromHeight", pb2.ComputeVolumeHeightRequest, pb2.FloatResponse, _NS) +_COMPUTE_HEIGHT_FROM_VOLUME = _method("ComputeHeightFromVolume", pb2.ComputeVolumeHeightRequest, pb2.FloatResponse, _NS) +_SUPPORTS_COMPUTE_HEIGHT_VOLUME = _method("SupportsComputeHeightVolume", pb2.ResourceByNameRequest, pb2.BoolResponse, _NS) +_HAS_LID = _method("HasLid", pb2.HasLidRequest, pb2.BoolResponse, _NS) + +_GET_TIP = _method("GetTip", pb2.GetTipRequest, pb2.TipData, _NS) + +_GET_VOLUME_TRACKER_STATE = _method("GetVolumeTrackerState", pb2.ResourceByNameRequest, pb2.VolumeTrackerState, _NS) +_REMOVE_LIQUID = _method("RemoveLiquid", pb2.TrackerOpRequest, pb2.Empty) +_ADD_LIQUID = _method("AddLiquid", pb2.TrackerOpRequest, pb2.Empty) +_BATCH_REMOVE_LIQUID = _method("BatchRemoveLiquid", pb2.BatchTrackerOpRequest, pb2.Empty) +_BATCH_ADD_LIQUID = _method("BatchAddLiquid", pb2.BatchTrackerOpRequest, pb2.Empty) + +_GET_TIP_TRACKER_STATE = _method("GetTipTrackerState", pb2.ResourceByNameRequest, pb2.TipTrackerState, _NS) +_REMOVE_TIP = _method("RemoveTip", pb2.TipTrackerOpRequest, pb2.Empty) +_ADD_TIP = _method("AddTip", pb2.TipTrackerOpRequest, pb2.Empty) + +_COMMIT_VOLUME_TRACKERS = _method("CommitVolumeTrackers", pb2.CommitRollbackRequest, pb2.Empty) +_ROLLBACK_VOLUME_TRACKERS = _method("RollbackVolumeTrackers", pb2.CommitRollbackRequest, pb2.Empty) +_COMMIT_TIP_TRACKERS = _method("CommitTipTrackers", pb2.CommitRollbackRequest, pb2.Empty) +_ROLLBACK_TIP_TRACKERS = _method("RollbackTipTrackers", pb2.CommitRollbackRequest, pb2.Empty) + +_ASSIGN_CHILD = _method("AssignChild", pb2.AssignChildRequest, pb2.Empty) +_UNASSIGN_CHILD = _method("UnassignChild", pb2.UnassignChildRequest, pb2.Empty) + + +# ============================================================ +# Async service protocol +# ============================================================ + +class DeckService(Protocol): + async def get_tree(self, request: pb2.GetTreeRequest, ctx: RequestContext) -> pb2.ResourceTree: + raise ConnectError(Code.UNIMPLEMENTED, "Not implemented") + + async def get_resource(self, request: pb2.ResourceByNameRequest, ctx: RequestContext) -> pb2.ResourceData: + raise ConnectError(Code.UNIMPLEMENTED, "Not implemented") + + async def has_resource(self, request: pb2.ResourceByNameRequest, ctx: RequestContext) -> pb2.BoolResponse: + raise ConnectError(Code.UNIMPLEMENTED, "Not implemented") + + async def get_trash_area(self, request: pb2.Empty, ctx: RequestContext) -> pb2.ResourceData: + raise ConnectError(Code.UNIMPLEMENTED, "Not implemented") + + async def get_trash_area96(self, request: pb2.Empty, ctx: RequestContext) -> pb2.ResourceData: + raise ConnectError(Code.UNIMPLEMENTED, "Not implemented") + + async def get_location_wrt(self, request: pb2.GetLocationWrtRequest, ctx: RequestContext) -> pb2.Coordinate: + raise ConnectError(Code.UNIMPLEMENTED, "Not implemented") + + async def get_absolute_location(self, request: pb2.GetAbsoluteLocationRequest, ctx: RequestContext) -> pb2.Coordinate: + raise ConnectError(Code.UNIMPLEMENTED, "Not implemented") + + async def get_absolute_rotation(self, request: pb2.GetAbsoluteRotationRequest, ctx: RequestContext) -> pb2.Rotation: + raise ConnectError(Code.UNIMPLEMENTED, "Not implemented") + + async def get_absolute_size(self, request: pb2.GetAbsoluteSizeRequest, ctx: RequestContext) -> pb2.Size: + raise ConnectError(Code.UNIMPLEMENTED, "Not implemented") + + async def get_highest_point(self, request: pb2.GetHighestPointRequest, ctx: RequestContext) -> pb2.FloatResponse: + raise ConnectError(Code.UNIMPLEMENTED, "Not implemented") + + async def batch_get_location_wrt(self, request: pb2.BatchGetLocationWrtRequest, ctx: RequestContext) -> pb2.BatchCoordinateResponse: + raise ConnectError(Code.UNIMPLEMENTED, "Not implemented") + + async def compute_volume_from_height(self, request: pb2.ComputeVolumeHeightRequest, ctx: RequestContext) -> pb2.FloatResponse: + raise ConnectError(Code.UNIMPLEMENTED, "Not implemented") + + async def compute_height_from_volume(self, request: pb2.ComputeVolumeHeightRequest, ctx: RequestContext) -> pb2.FloatResponse: + raise ConnectError(Code.UNIMPLEMENTED, "Not implemented") + + async def supports_compute_height_volume(self, request: pb2.ResourceByNameRequest, ctx: RequestContext) -> pb2.BoolResponse: + raise ConnectError(Code.UNIMPLEMENTED, "Not implemented") + + async def has_lid(self, request: pb2.HasLidRequest, ctx: RequestContext) -> pb2.BoolResponse: + raise ConnectError(Code.UNIMPLEMENTED, "Not implemented") + + async def get_tip(self, request: pb2.GetTipRequest, ctx: RequestContext) -> pb2.TipData: + raise ConnectError(Code.UNIMPLEMENTED, "Not implemented") + + async def get_volume_tracker_state(self, request: pb2.ResourceByNameRequest, ctx: RequestContext) -> pb2.VolumeTrackerState: + raise ConnectError(Code.UNIMPLEMENTED, "Not implemented") + + async def remove_liquid(self, request: pb2.TrackerOpRequest, ctx: RequestContext) -> pb2.Empty: + raise ConnectError(Code.UNIMPLEMENTED, "Not implemented") + + async def add_liquid(self, request: pb2.TrackerOpRequest, ctx: RequestContext) -> pb2.Empty: + raise ConnectError(Code.UNIMPLEMENTED, "Not implemented") + + async def batch_remove_liquid(self, request: pb2.BatchTrackerOpRequest, ctx: RequestContext) -> pb2.Empty: + raise ConnectError(Code.UNIMPLEMENTED, "Not implemented") + + async def batch_add_liquid(self, request: pb2.BatchTrackerOpRequest, ctx: RequestContext) -> pb2.Empty: + raise ConnectError(Code.UNIMPLEMENTED, "Not implemented") + + async def get_tip_tracker_state(self, request: pb2.ResourceByNameRequest, ctx: RequestContext) -> pb2.TipTrackerState: + raise ConnectError(Code.UNIMPLEMENTED, "Not implemented") + + async def remove_tip(self, request: pb2.TipTrackerOpRequest, ctx: RequestContext) -> pb2.Empty: + raise ConnectError(Code.UNIMPLEMENTED, "Not implemented") + + async def add_tip(self, request: pb2.TipTrackerOpRequest, ctx: RequestContext) -> pb2.Empty: + raise ConnectError(Code.UNIMPLEMENTED, "Not implemented") + + async def commit_volume_trackers(self, request: pb2.CommitRollbackRequest, ctx: RequestContext) -> pb2.Empty: + raise ConnectError(Code.UNIMPLEMENTED, "Not implemented") + + async def rollback_volume_trackers(self, request: pb2.CommitRollbackRequest, ctx: RequestContext) -> pb2.Empty: + raise ConnectError(Code.UNIMPLEMENTED, "Not implemented") + + async def commit_tip_trackers(self, request: pb2.CommitRollbackRequest, ctx: RequestContext) -> pb2.Empty: + raise ConnectError(Code.UNIMPLEMENTED, "Not implemented") + + async def rollback_tip_trackers(self, request: pb2.CommitRollbackRequest, ctx: RequestContext) -> pb2.Empty: + raise ConnectError(Code.UNIMPLEMENTED, "Not implemented") + + async def assign_child(self, request: pb2.AssignChildRequest, ctx: RequestContext) -> pb2.Empty: + raise ConnectError(Code.UNIMPLEMENTED, "Not implemented") + + async def unassign_child(self, request: pb2.UnassignChildRequest, ctx: RequestContext) -> pb2.Empty: + raise ConnectError(Code.UNIMPLEMENTED, "Not implemented") + + +# ============================================================ +# ASGI application +# ============================================================ + +class DeckServiceASGIApplication(ConnectASGIApplication[DeckService]): + def __init__( + self, + service: DeckService | AsyncGenerator[DeckService], + *, + interceptors: Iterable[Interceptor] = (), + read_max_bytes: int | None = None, + ) -> None: + super().__init__( + service=service, + endpoints=lambda svc: { + f"/{_SVC}/GetTree": Endpoint.unary(method=_GET_TREE, function=svc.get_tree), + f"/{_SVC}/GetResource": Endpoint.unary(method=_GET_RESOURCE, function=svc.get_resource), + f"/{_SVC}/HasResource": Endpoint.unary(method=_HAS_RESOURCE, function=svc.has_resource), + f"/{_SVC}/GetTrashArea": Endpoint.unary(method=_GET_TRASH_AREA, function=svc.get_trash_area), + f"/{_SVC}/GetTrashArea96": Endpoint.unary(method=_GET_TRASH_AREA96, function=svc.get_trash_area96), + f"/{_SVC}/GetLocationWrt": Endpoint.unary(method=_GET_LOCATION_WRT, function=svc.get_location_wrt), + f"/{_SVC}/GetAbsoluteLocation": Endpoint.unary(method=_GET_ABSOLUTE_LOCATION, function=svc.get_absolute_location), + f"/{_SVC}/GetAbsoluteRotation": Endpoint.unary(method=_GET_ABSOLUTE_ROTATION, function=svc.get_absolute_rotation), + f"/{_SVC}/GetAbsoluteSize": Endpoint.unary(method=_GET_ABSOLUTE_SIZE, function=svc.get_absolute_size), + f"/{_SVC}/GetHighestPoint": Endpoint.unary(method=_GET_HIGHEST_POINT, function=svc.get_highest_point), + f"/{_SVC}/BatchGetLocationWrt": Endpoint.unary(method=_BATCH_GET_LOCATION_WRT, function=svc.batch_get_location_wrt), + f"/{_SVC}/ComputeVolumeFromHeight": Endpoint.unary(method=_COMPUTE_VOLUME_FROM_HEIGHT, function=svc.compute_volume_from_height), + f"/{_SVC}/ComputeHeightFromVolume": Endpoint.unary(method=_COMPUTE_HEIGHT_FROM_VOLUME, function=svc.compute_height_from_volume), + f"/{_SVC}/SupportsComputeHeightVolume": Endpoint.unary(method=_SUPPORTS_COMPUTE_HEIGHT_VOLUME, function=svc.supports_compute_height_volume), + f"/{_SVC}/HasLid": Endpoint.unary(method=_HAS_LID, function=svc.has_lid), + f"/{_SVC}/GetTip": Endpoint.unary(method=_GET_TIP, function=svc.get_tip), + f"/{_SVC}/GetVolumeTrackerState": Endpoint.unary(method=_GET_VOLUME_TRACKER_STATE, function=svc.get_volume_tracker_state), + f"/{_SVC}/RemoveLiquid": Endpoint.unary(method=_REMOVE_LIQUID, function=svc.remove_liquid), + f"/{_SVC}/AddLiquid": Endpoint.unary(method=_ADD_LIQUID, function=svc.add_liquid), + f"/{_SVC}/BatchRemoveLiquid": Endpoint.unary(method=_BATCH_REMOVE_LIQUID, function=svc.batch_remove_liquid), + f"/{_SVC}/BatchAddLiquid": Endpoint.unary(method=_BATCH_ADD_LIQUID, function=svc.batch_add_liquid), + f"/{_SVC}/GetTipTrackerState": Endpoint.unary(method=_GET_TIP_TRACKER_STATE, function=svc.get_tip_tracker_state), + f"/{_SVC}/RemoveTip": Endpoint.unary(method=_REMOVE_TIP, function=svc.remove_tip), + f"/{_SVC}/AddTip": Endpoint.unary(method=_ADD_TIP, function=svc.add_tip), + f"/{_SVC}/CommitVolumeTrackers": Endpoint.unary(method=_COMMIT_VOLUME_TRACKERS, function=svc.commit_volume_trackers), + f"/{_SVC}/RollbackVolumeTrackers": Endpoint.unary(method=_ROLLBACK_VOLUME_TRACKERS, function=svc.rollback_volume_trackers), + f"/{_SVC}/CommitTipTrackers": Endpoint.unary(method=_COMMIT_TIP_TRACKERS, function=svc.commit_tip_trackers), + f"/{_SVC}/RollbackTipTrackers": Endpoint.unary(method=_ROLLBACK_TIP_TRACKERS, function=svc.rollback_tip_trackers), + f"/{_SVC}/AssignChild": Endpoint.unary(method=_ASSIGN_CHILD, function=svc.assign_child), + f"/{_SVC}/UnassignChild": Endpoint.unary(method=_UNASSIGN_CHILD, function=svc.unassign_child), + }, + interceptors=interceptors, + read_max_bytes=read_max_bytes, + ) + + @property + def path(self) -> str: + return f"/{_SVC}" + + +# ============================================================ +# Async client +# ============================================================ + +class DeckServiceClient(ConnectClient): + + async def get_tree(self, request: pb2.GetTreeRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None, use_get: bool = False) -> pb2.ResourceTree: + return await self.execute_unary(request=request, method=_GET_TREE, headers=headers, timeout_ms=timeout_ms, use_get=use_get) + + async def get_resource(self, request: pb2.ResourceByNameRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None, use_get: bool = False) -> pb2.ResourceData: + return await self.execute_unary(request=request, method=_GET_RESOURCE, headers=headers, timeout_ms=timeout_ms, use_get=use_get) + + async def has_resource(self, request: pb2.ResourceByNameRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None, use_get: bool = False) -> pb2.BoolResponse: + return await self.execute_unary(request=request, method=_HAS_RESOURCE, headers=headers, timeout_ms=timeout_ms, use_get=use_get) + + async def get_trash_area(self, request: pb2.Empty, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None, use_get: bool = False) -> pb2.ResourceData: + return await self.execute_unary(request=request, method=_GET_TRASH_AREA, headers=headers, timeout_ms=timeout_ms, use_get=use_get) + + async def get_trash_area96(self, request: pb2.Empty, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None, use_get: bool = False) -> pb2.ResourceData: + return await self.execute_unary(request=request, method=_GET_TRASH_AREA96, headers=headers, timeout_ms=timeout_ms, use_get=use_get) + + async def get_location_wrt(self, request: pb2.GetLocationWrtRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None, use_get: bool = False) -> pb2.Coordinate: + return await self.execute_unary(request=request, method=_GET_LOCATION_WRT, headers=headers, timeout_ms=timeout_ms, use_get=use_get) + + async def get_absolute_location(self, request: pb2.GetAbsoluteLocationRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None, use_get: bool = False) -> pb2.Coordinate: + return await self.execute_unary(request=request, method=_GET_ABSOLUTE_LOCATION, headers=headers, timeout_ms=timeout_ms, use_get=use_get) + + async def get_absolute_rotation(self, request: pb2.GetAbsoluteRotationRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None, use_get: bool = False) -> pb2.Rotation: + return await self.execute_unary(request=request, method=_GET_ABSOLUTE_ROTATION, headers=headers, timeout_ms=timeout_ms, use_get=use_get) + + async def get_absolute_size(self, request: pb2.GetAbsoluteSizeRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None, use_get: bool = False) -> pb2.Size: + return await self.execute_unary(request=request, method=_GET_ABSOLUTE_SIZE, headers=headers, timeout_ms=timeout_ms, use_get=use_get) + + async def get_highest_point(self, request: pb2.GetHighestPointRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None, use_get: bool = False) -> pb2.FloatResponse: + return await self.execute_unary(request=request, method=_GET_HIGHEST_POINT, headers=headers, timeout_ms=timeout_ms, use_get=use_get) + + async def batch_get_location_wrt(self, request: pb2.BatchGetLocationWrtRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None, use_get: bool = False) -> pb2.BatchCoordinateResponse: + return await self.execute_unary(request=request, method=_BATCH_GET_LOCATION_WRT, headers=headers, timeout_ms=timeout_ms, use_get=use_get) + + async def compute_volume_from_height(self, request: pb2.ComputeVolumeHeightRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None) -> pb2.FloatResponse: + return await self.execute_unary(request=request, method=_COMPUTE_VOLUME_FROM_HEIGHT, headers=headers, timeout_ms=timeout_ms) + + async def compute_height_from_volume(self, request: pb2.ComputeVolumeHeightRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None) -> pb2.FloatResponse: + return await self.execute_unary(request=request, method=_COMPUTE_HEIGHT_FROM_VOLUME, headers=headers, timeout_ms=timeout_ms) + + async def supports_compute_height_volume(self, request: pb2.ResourceByNameRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None, use_get: bool = False) -> pb2.BoolResponse: + return await self.execute_unary(request=request, method=_SUPPORTS_COMPUTE_HEIGHT_VOLUME, headers=headers, timeout_ms=timeout_ms, use_get=use_get) + + async def has_lid(self, request: pb2.HasLidRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None, use_get: bool = False) -> pb2.BoolResponse: + return await self.execute_unary(request=request, method=_HAS_LID, headers=headers, timeout_ms=timeout_ms, use_get=use_get) + + async def get_tip(self, request: pb2.GetTipRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None, use_get: bool = False) -> pb2.TipData: + return await self.execute_unary(request=request, method=_GET_TIP, headers=headers, timeout_ms=timeout_ms, use_get=use_get) + + async def get_volume_tracker_state(self, request: pb2.ResourceByNameRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None, use_get: bool = False) -> pb2.VolumeTrackerState: + return await self.execute_unary(request=request, method=_GET_VOLUME_TRACKER_STATE, headers=headers, timeout_ms=timeout_ms, use_get=use_get) + + async def remove_liquid(self, request: pb2.TrackerOpRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None) -> pb2.Empty: + return await self.execute_unary(request=request, method=_REMOVE_LIQUID, headers=headers, timeout_ms=timeout_ms) + + async def add_liquid(self, request: pb2.TrackerOpRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None) -> pb2.Empty: + return await self.execute_unary(request=request, method=_ADD_LIQUID, headers=headers, timeout_ms=timeout_ms) + + async def batch_remove_liquid(self, request: pb2.BatchTrackerOpRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None) -> pb2.Empty: + return await self.execute_unary(request=request, method=_BATCH_REMOVE_LIQUID, headers=headers, timeout_ms=timeout_ms) + + async def batch_add_liquid(self, request: pb2.BatchTrackerOpRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None) -> pb2.Empty: + return await self.execute_unary(request=request, method=_BATCH_ADD_LIQUID, headers=headers, timeout_ms=timeout_ms) + + async def get_tip_tracker_state(self, request: pb2.ResourceByNameRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None, use_get: bool = False) -> pb2.TipTrackerState: + return await self.execute_unary(request=request, method=_GET_TIP_TRACKER_STATE, headers=headers, timeout_ms=timeout_ms, use_get=use_get) + + async def remove_tip(self, request: pb2.TipTrackerOpRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None) -> pb2.Empty: + return await self.execute_unary(request=request, method=_REMOVE_TIP, headers=headers, timeout_ms=timeout_ms) + + async def add_tip(self, request: pb2.TipTrackerOpRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None) -> pb2.Empty: + return await self.execute_unary(request=request, method=_ADD_TIP, headers=headers, timeout_ms=timeout_ms) + + async def commit_volume_trackers(self, request: pb2.CommitRollbackRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None) -> pb2.Empty: + return await self.execute_unary(request=request, method=_COMMIT_VOLUME_TRACKERS, headers=headers, timeout_ms=timeout_ms) + + async def rollback_volume_trackers(self, request: pb2.CommitRollbackRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None) -> pb2.Empty: + return await self.execute_unary(request=request, method=_ROLLBACK_VOLUME_TRACKERS, headers=headers, timeout_ms=timeout_ms) + + async def commit_tip_trackers(self, request: pb2.CommitRollbackRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None) -> pb2.Empty: + return await self.execute_unary(request=request, method=_COMMIT_TIP_TRACKERS, headers=headers, timeout_ms=timeout_ms) + + async def rollback_tip_trackers(self, request: pb2.CommitRollbackRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None) -> pb2.Empty: + return await self.execute_unary(request=request, method=_ROLLBACK_TIP_TRACKERS, headers=headers, timeout_ms=timeout_ms) + + async def assign_child(self, request: pb2.AssignChildRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None) -> pb2.Empty: + return await self.execute_unary(request=request, method=_ASSIGN_CHILD, headers=headers, timeout_ms=timeout_ms) + + async def unassign_child(self, request: pb2.UnassignChildRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None) -> pb2.Empty: + return await self.execute_unary(request=request, method=_UNASSIGN_CHILD, headers=headers, timeout_ms=timeout_ms) + + +# ============================================================ +# Sync client +# ============================================================ + +class DeckServiceClientSync(ConnectClientSync): + + def get_tree(self, request: pb2.GetTreeRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None, use_get: bool = False) -> pb2.ResourceTree: + return self.execute_unary(request=request, method=_GET_TREE, headers=headers, timeout_ms=timeout_ms, use_get=use_get) + + def get_resource(self, request: pb2.ResourceByNameRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None, use_get: bool = False) -> pb2.ResourceData: + return self.execute_unary(request=request, method=_GET_RESOURCE, headers=headers, timeout_ms=timeout_ms, use_get=use_get) + + def has_resource(self, request: pb2.ResourceByNameRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None, use_get: bool = False) -> pb2.BoolResponse: + return self.execute_unary(request=request, method=_HAS_RESOURCE, headers=headers, timeout_ms=timeout_ms, use_get=use_get) + + def get_trash_area(self, request: pb2.Empty, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None, use_get: bool = False) -> pb2.ResourceData: + return self.execute_unary(request=request, method=_GET_TRASH_AREA, headers=headers, timeout_ms=timeout_ms, use_get=use_get) + + def get_trash_area96(self, request: pb2.Empty, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None, use_get: bool = False) -> pb2.ResourceData: + return self.execute_unary(request=request, method=_GET_TRASH_AREA96, headers=headers, timeout_ms=timeout_ms, use_get=use_get) + + def get_location_wrt(self, request: pb2.GetLocationWrtRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None, use_get: bool = False) -> pb2.Coordinate: + return self.execute_unary(request=request, method=_GET_LOCATION_WRT, headers=headers, timeout_ms=timeout_ms, use_get=use_get) + + def get_absolute_location(self, request: pb2.GetAbsoluteLocationRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None, use_get: bool = False) -> pb2.Coordinate: + return self.execute_unary(request=request, method=_GET_ABSOLUTE_LOCATION, headers=headers, timeout_ms=timeout_ms, use_get=use_get) + + def get_absolute_rotation(self, request: pb2.GetAbsoluteRotationRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None, use_get: bool = False) -> pb2.Rotation: + return self.execute_unary(request=request, method=_GET_ABSOLUTE_ROTATION, headers=headers, timeout_ms=timeout_ms, use_get=use_get) + + def get_absolute_size(self, request: pb2.GetAbsoluteSizeRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None, use_get: bool = False) -> pb2.Size: + return self.execute_unary(request=request, method=_GET_ABSOLUTE_SIZE, headers=headers, timeout_ms=timeout_ms, use_get=use_get) + + def get_highest_point(self, request: pb2.GetHighestPointRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None, use_get: bool = False) -> pb2.FloatResponse: + return self.execute_unary(request=request, method=_GET_HIGHEST_POINT, headers=headers, timeout_ms=timeout_ms, use_get=use_get) + + def batch_get_location_wrt(self, request: pb2.BatchGetLocationWrtRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None, use_get: bool = False) -> pb2.BatchCoordinateResponse: + return self.execute_unary(request=request, method=_BATCH_GET_LOCATION_WRT, headers=headers, timeout_ms=timeout_ms, use_get=use_get) + + def compute_volume_from_height(self, request: pb2.ComputeVolumeHeightRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None) -> pb2.FloatResponse: + return self.execute_unary(request=request, method=_COMPUTE_VOLUME_FROM_HEIGHT, headers=headers, timeout_ms=timeout_ms) + + def compute_height_from_volume(self, request: pb2.ComputeVolumeHeightRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None) -> pb2.FloatResponse: + return self.execute_unary(request=request, method=_COMPUTE_HEIGHT_FROM_VOLUME, headers=headers, timeout_ms=timeout_ms) + + def supports_compute_height_volume(self, request: pb2.ResourceByNameRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None, use_get: bool = False) -> pb2.BoolResponse: + return self.execute_unary(request=request, method=_SUPPORTS_COMPUTE_HEIGHT_VOLUME, headers=headers, timeout_ms=timeout_ms, use_get=use_get) + + def has_lid(self, request: pb2.HasLidRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None, use_get: bool = False) -> pb2.BoolResponse: + return self.execute_unary(request=request, method=_HAS_LID, headers=headers, timeout_ms=timeout_ms, use_get=use_get) + + def get_tip(self, request: pb2.GetTipRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None, use_get: bool = False) -> pb2.TipData: + return self.execute_unary(request=request, method=_GET_TIP, headers=headers, timeout_ms=timeout_ms, use_get=use_get) + + def get_volume_tracker_state(self, request: pb2.ResourceByNameRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None, use_get: bool = False) -> pb2.VolumeTrackerState: + return self.execute_unary(request=request, method=_GET_VOLUME_TRACKER_STATE, headers=headers, timeout_ms=timeout_ms, use_get=use_get) + + def remove_liquid(self, request: pb2.TrackerOpRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None) -> pb2.Empty: + return self.execute_unary(request=request, method=_REMOVE_LIQUID, headers=headers, timeout_ms=timeout_ms) + + def add_liquid(self, request: pb2.TrackerOpRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None) -> pb2.Empty: + return self.execute_unary(request=request, method=_ADD_LIQUID, headers=headers, timeout_ms=timeout_ms) + + def batch_remove_liquid(self, request: pb2.BatchTrackerOpRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None) -> pb2.Empty: + return self.execute_unary(request=request, method=_BATCH_REMOVE_LIQUID, headers=headers, timeout_ms=timeout_ms) + + def batch_add_liquid(self, request: pb2.BatchTrackerOpRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None) -> pb2.Empty: + return self.execute_unary(request=request, method=_BATCH_ADD_LIQUID, headers=headers, timeout_ms=timeout_ms) + + def get_tip_tracker_state(self, request: pb2.ResourceByNameRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None, use_get: bool = False) -> pb2.TipTrackerState: + return self.execute_unary(request=request, method=_GET_TIP_TRACKER_STATE, headers=headers, timeout_ms=timeout_ms, use_get=use_get) + + def remove_tip(self, request: pb2.TipTrackerOpRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None) -> pb2.Empty: + return self.execute_unary(request=request, method=_REMOVE_TIP, headers=headers, timeout_ms=timeout_ms) + + def add_tip(self, request: pb2.TipTrackerOpRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None) -> pb2.Empty: + return self.execute_unary(request=request, method=_ADD_TIP, headers=headers, timeout_ms=timeout_ms) + + def commit_volume_trackers(self, request: pb2.CommitRollbackRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None) -> pb2.Empty: + return self.execute_unary(request=request, method=_COMMIT_VOLUME_TRACKERS, headers=headers, timeout_ms=timeout_ms) + + def rollback_volume_trackers(self, request: pb2.CommitRollbackRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None) -> pb2.Empty: + return self.execute_unary(request=request, method=_ROLLBACK_VOLUME_TRACKERS, headers=headers, timeout_ms=timeout_ms) + + def commit_tip_trackers(self, request: pb2.CommitRollbackRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None) -> pb2.Empty: + return self.execute_unary(request=request, method=_COMMIT_TIP_TRACKERS, headers=headers, timeout_ms=timeout_ms) + + def rollback_tip_trackers(self, request: pb2.CommitRollbackRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None) -> pb2.Empty: + return self.execute_unary(request=request, method=_ROLLBACK_TIP_TRACKERS, headers=headers, timeout_ms=timeout_ms) + + def assign_child(self, request: pb2.AssignChildRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None) -> pb2.Empty: + return self.execute_unary(request=request, method=_ASSIGN_CHILD, headers=headers, timeout_ms=timeout_ms) + + def unassign_child(self, request: pb2.UnassignChildRequest, *, headers: Headers | Mapping[str, str] | None = None, timeout_ms: int | None = None) -> pb2.Empty: + return self.execute_unary(request=request, method=_UNASSIGN_CHILD, headers=headers, timeout_ms=timeout_ms) diff --git a/pylabrobot/resources/remote/deck_service_pb2.py b/pylabrobot/resources/remote/deck_service_pb2.py new file mode 100644 index 00000000000..2412501787e --- /dev/null +++ b/pylabrobot/resources/remote/deck_service_pb2.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: deck_service.proto +# Protobuf Python Version: 6.31.1 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 6, + 31, + 1, + '', + 'deck_service.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x12\x64\x65\x63k_service.proto\x12\x1bpylabrobot.resources.remote\"\x07\n\x05\x45mpty\"-\n\nCoordinate\x12\t\n\x01x\x18\x01 \x01(\x01\x12\t\n\x01y\x18\x02 \x01(\x01\x12\t\n\x01z\x18\x03 \x01(\x01\"+\n\x08Rotation\x12\t\n\x01x\x18\x01 \x01(\x01\x12\t\n\x01y\x18\x02 \x01(\x01\x12\t\n\x01z\x18\x03 \x01(\x01\"\'\n\x04Size\x12\t\n\x01x\x18\x01 \x01(\x01\x12\t\n\x01y\x18\x02 \x01(\x01\x12\t\n\x01z\x18\x03 \x01(\x01\"\xab\x01\n\x07TipData\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x12\n\nhas_filter\x18\x03 \x01(\x08\x12\x18\n\x10total_tip_length\x18\x04 \x01(\x01\x12\x16\n\x0emaximal_volume\x18\x05 \x01(\x01\x12\x15\n\rfitting_depth\x18\x06 \x01(\x01\x12\x10\n\x08tip_size\x18\x07 \x01(\t\x12\x15\n\rpickup_method\x18\x08 \x01(\t\"\xb0\x05\n\x0cResourceData\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0c\n\x04type\x18\x02 \x01(\t\x12\x0e\n\x06size_x\x18\x03 \x01(\x01\x12\x0e\n\x06size_y\x18\x04 \x01(\x01\x12\x0e\n\x06size_z\x18\x05 \x01(\x01\x12\x10\n\x08\x63\x61tegory\x18\x06 \x01(\t\x12\r\n\x05model\x18\x07 \x01(\t\x12\x39\n\x08location\x18\x08 \x01(\x0b\x32\'.pylabrobot.resources.remote.Coordinate\x12\x37\n\x08rotation\x18\t \x01(\x0b\x32%.pylabrobot.resources.remote.Rotation\x12\x13\n\x0bparent_name\x18\n \x01(\t\x12!\n\x14material_z_thickness\x18\x0b \x01(\x01H\x00\x88\x01\x01\x12\x17\n\nmax_volume\x18\x0c \x01(\x01H\x01\x88\x01\x01\x12\x18\n\x10well_bottom_type\x18\r \x01(\t\x12\x1a\n\x12\x63ross_section_type\x18\x0e \x01(\t\x12\x12\n\nplate_type\x18\x0f \x01(\t\x12\x0f\n\x07has_lid\x18\x10 \x01(\x08\x12;\n\rprototype_tip\x18\x11 \x01(\x0b\x32$.pylabrobot.resources.remote.TipData\x12I\n\x08ordering\x18\x12 \x03(\x0b\x32\x37.pylabrobot.resources.remote.ResourceData.OrderingEntry\x12\x1d\n\x10nesting_z_height\x18\x13 \x01(\x01H\x02\x88\x01\x01\x1a/\n\rOrderingEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x42\x17\n\x15_material_z_thicknessB\r\n\x0b_max_volumeB\x13\n\x11_nesting_z_height\"\x84\x01\n\x0cResourceTree\x12\x37\n\x04\x64\x61ta\x18\x01 \x01(\x0b\x32).pylabrobot.resources.remote.ResourceData\x12;\n\x08\x63hildren\x18\x02 \x03(\x0b\x32).pylabrobot.resources.remote.ResourceTree\"e\n\x12VolumeTrackerState\x12\x0e\n\x06volume\x18\x01 \x01(\x01\x12\x16\n\x0epending_volume\x18\x02 \x01(\x01\x12\x12\n\nmax_volume\x18\x03 \x01(\x01\x12\x13\n\x0bis_disabled\x18\x04 \x01(\x08\"j\n\x0fTipTrackerState\x12\x0f\n\x07has_tip\x18\x01 \x01(\x08\x12\x31\n\x03tip\x18\x02 \x01(\x0b\x32$.pylabrobot.resources.remote.TipData\x12\x13\n\x0bis_disabled\x18\x03 \x01(\x08\"#\n\x0eGetTreeRequest\x12\x11\n\troot_name\x18\x01 \x01(\t\"%\n\x15ResourceByNameRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\"x\n\x15GetLocationWrtRequest\x12\x15\n\rresource_name\x18\x01 \x01(\t\x12\x12\n\nother_name\x18\x02 \x01(\t\x12\x10\n\x08\x61nchor_x\x18\x03 \x01(\t\x12\x10\n\x08\x61nchor_y\x18\x04 \x01(\t\x12\x10\n\x08\x61nchor_z\x18\x05 \x01(\t\"i\n\x1aGetAbsoluteLocationRequest\x12\x15\n\rresource_name\x18\x01 \x01(\t\x12\x10\n\x08\x61nchor_x\x18\x02 \x01(\t\x12\x10\n\x08\x61nchor_y\x18\x03 \x01(\t\x12\x10\n\x08\x61nchor_z\x18\x04 \x01(\t\"3\n\x1aGetAbsoluteRotationRequest\x12\x15\n\rresource_name\x18\x01 \x01(\t\"/\n\x16GetAbsoluteSizeRequest\x12\x15\n\rresource_name\x18\x01 \x01(\t\"/\n\x16GetHighestPointRequest\x12\x15\n\rresource_name\x18\x01 \x01(\t\"r\n\x0fLocationWrtItem\x12\x15\n\rresource_name\x18\x01 \x01(\t\x12\x12\n\nother_name\x18\x02 \x01(\t\x12\x10\n\x08\x61nchor_x\x18\x03 \x01(\t\x12\x10\n\x08\x61nchor_y\x18\x04 \x01(\t\x12\x10\n\x08\x61nchor_z\x18\x05 \x01(\t\"Y\n\x1a\x42\x61tchGetLocationWrtRequest\x12;\n\x05items\x18\x01 \x03(\x0b\x32,.pylabrobot.resources.remote.LocationWrtItem\"W\n\x17\x42\x61tchCoordinateResponse\x12<\n\x0b\x63oordinates\x18\x01 \x03(\x0b\x32\'.pylabrobot.resources.remote.Coordinate\"B\n\x1a\x43omputeVolumeHeightRequest\x12\x15\n\rresource_name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x01\"\x1e\n\rFloatResponse\x12\r\n\x05value\x18\x01 \x01(\x01\"\x1d\n\x0c\x42oolResponse\x12\r\n\x05value\x18\x01 \x01(\x08\"&\n\rGetTipRequest\x12\x15\n\rtip_spot_name\x18\x01 \x01(\t\"9\n\x10TrackerOpRequest\x12\x15\n\rresource_name\x18\x01 \x01(\t\x12\x0e\n\x06volume\x18\x02 \x01(\x01\"S\n\x15\x42\x61tchTrackerOpRequest\x12:\n\x03ops\x18\x01 \x03(\x0b\x32-.pylabrobot.resources.remote.TrackerOpRequest\",\n\x13TipTrackerOpRequest\x12\x15\n\rtip_spot_name\x18\x01 \x01(\t\"/\n\x15\x43ommitRollbackRequest\x12\x16\n\x0eresource_names\x18\x01 \x03(\t\"x\n\x12\x41ssignChildRequest\x12\x12\n\nchild_name\x18\x01 \x01(\t\x12\x13\n\x0bparent_name\x18\x02 \x01(\t\x12\x39\n\x08location\x18\x03 \x01(\x0b\x32\'.pylabrobot.resources.remote.Coordinate\"-\n\x14UnassignChildRequest\x12\x15\n\rresource_name\x18\x01 \x01(\t\"#\n\rHasLidRequest\x12\x12\n\nplate_name\x18\x01 \x01(\t2\xe9\x19\n\x0b\x44\x65\x63kService\x12\x61\n\x07GetTree\x12+.pylabrobot.resources.remote.GetTreeRequest\x1a).pylabrobot.resources.remote.ResourceTree\x12l\n\x0bGetResource\x12\x32.pylabrobot.resources.remote.ResourceByNameRequest\x1a).pylabrobot.resources.remote.ResourceData\x12l\n\x0bHasResource\x12\x32.pylabrobot.resources.remote.ResourceByNameRequest\x1a).pylabrobot.resources.remote.BoolResponse\x12]\n\x0cGetTrashArea\x12\".pylabrobot.resources.remote.Empty\x1a).pylabrobot.resources.remote.ResourceData\x12_\n\x0eGetTrashArea96\x12\".pylabrobot.resources.remote.Empty\x1a).pylabrobot.resources.remote.ResourceData\x12m\n\x0eGetLocationWrt\x12\x32.pylabrobot.resources.remote.GetLocationWrtRequest\x1a\'.pylabrobot.resources.remote.Coordinate\x12w\n\x13GetAbsoluteLocation\x12\x37.pylabrobot.resources.remote.GetAbsoluteLocationRequest\x1a\'.pylabrobot.resources.remote.Coordinate\x12u\n\x13GetAbsoluteRotation\x12\x37.pylabrobot.resources.remote.GetAbsoluteRotationRequest\x1a%.pylabrobot.resources.remote.Rotation\x12i\n\x0fGetAbsoluteSize\x12\x33.pylabrobot.resources.remote.GetAbsoluteSizeRequest\x1a!.pylabrobot.resources.remote.Size\x12r\n\x0fGetHighestPoint\x12\x33.pylabrobot.resources.remote.GetHighestPointRequest\x1a*.pylabrobot.resources.remote.FloatResponse\x12\x84\x01\n\x13\x42\x61tchGetLocationWrt\x12\x37.pylabrobot.resources.remote.BatchGetLocationWrtRequest\x1a\x34.pylabrobot.resources.remote.BatchCoordinateResponse\x12~\n\x17\x43omputeVolumeFromHeight\x12\x37.pylabrobot.resources.remote.ComputeVolumeHeightRequest\x1a*.pylabrobot.resources.remote.FloatResponse\x12~\n\x17\x43omputeHeightFromVolume\x12\x37.pylabrobot.resources.remote.ComputeVolumeHeightRequest\x1a*.pylabrobot.resources.remote.FloatResponse\x12|\n\x1bSupportsComputeHeightVolume\x12\x32.pylabrobot.resources.remote.ResourceByNameRequest\x1a).pylabrobot.resources.remote.BoolResponse\x12_\n\x06HasLid\x12*.pylabrobot.resources.remote.HasLidRequest\x1a).pylabrobot.resources.remote.BoolResponse\x12Z\n\x06GetTip\x12*.pylabrobot.resources.remote.GetTipRequest\x1a$.pylabrobot.resources.remote.TipData\x12|\n\x15GetVolumeTrackerState\x12\x32.pylabrobot.resources.remote.ResourceByNameRequest\x1a/.pylabrobot.resources.remote.VolumeTrackerState\x12\x61\n\x0cRemoveLiquid\x12-.pylabrobot.resources.remote.TrackerOpRequest\x1a\".pylabrobot.resources.remote.Empty\x12^\n\tAddLiquid\x12-.pylabrobot.resources.remote.TrackerOpRequest\x1a\".pylabrobot.resources.remote.Empty\x12k\n\x11\x42\x61tchRemoveLiquid\x12\x32.pylabrobot.resources.remote.BatchTrackerOpRequest\x1a\".pylabrobot.resources.remote.Empty\x12h\n\x0e\x42\x61tchAddLiquid\x12\x32.pylabrobot.resources.remote.BatchTrackerOpRequest\x1a\".pylabrobot.resources.remote.Empty\x12v\n\x12GetTipTrackerState\x12\x32.pylabrobot.resources.remote.ResourceByNameRequest\x1a,.pylabrobot.resources.remote.TipTrackerState\x12\x61\n\tRemoveTip\x12\x30.pylabrobot.resources.remote.TipTrackerOpRequest\x1a\".pylabrobot.resources.remote.Empty\x12^\n\x06\x41\x64\x64Tip\x12\x30.pylabrobot.resources.remote.TipTrackerOpRequest\x1a\".pylabrobot.resources.remote.Empty\x12n\n\x14\x43ommitVolumeTrackers\x12\x32.pylabrobot.resources.remote.CommitRollbackRequest\x1a\".pylabrobot.resources.remote.Empty\x12p\n\x16RollbackVolumeTrackers\x12\x32.pylabrobot.resources.remote.CommitRollbackRequest\x1a\".pylabrobot.resources.remote.Empty\x12k\n\x11\x43ommitTipTrackers\x12\x32.pylabrobot.resources.remote.CommitRollbackRequest\x1a\".pylabrobot.resources.remote.Empty\x12m\n\x13RollbackTipTrackers\x12\x32.pylabrobot.resources.remote.CommitRollbackRequest\x1a\".pylabrobot.resources.remote.Empty\x12\x62\n\x0b\x41ssignChild\x12/.pylabrobot.resources.remote.AssignChildRequest\x1a\".pylabrobot.resources.remote.Empty\x12\x66\n\rUnassignChild\x12\x31.pylabrobot.resources.remote.UnassignChildRequest\x1a\".pylabrobot.resources.remote.Emptyb\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'deck_service_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + DESCRIPTOR._loaded_options = None + _globals['_RESOURCEDATA_ORDERINGENTRY']._loaded_options = None + _globals['_RESOURCEDATA_ORDERINGENTRY']._serialized_options = b'8\001' + _globals['_EMPTY']._serialized_start=51 + _globals['_EMPTY']._serialized_end=58 + _globals['_COORDINATE']._serialized_start=60 + _globals['_COORDINATE']._serialized_end=105 + _globals['_ROTATION']._serialized_start=107 + _globals['_ROTATION']._serialized_end=150 + _globals['_SIZE']._serialized_start=152 + _globals['_SIZE']._serialized_end=191 + _globals['_TIPDATA']._serialized_start=194 + _globals['_TIPDATA']._serialized_end=365 + _globals['_RESOURCEDATA']._serialized_start=368 + _globals['_RESOURCEDATA']._serialized_end=1056 + _globals['_RESOURCEDATA_ORDERINGENTRY']._serialized_start=948 + _globals['_RESOURCEDATA_ORDERINGENTRY']._serialized_end=995 + _globals['_RESOURCETREE']._serialized_start=1059 + _globals['_RESOURCETREE']._serialized_end=1191 + _globals['_VOLUMETRACKERSTATE']._serialized_start=1193 + _globals['_VOLUMETRACKERSTATE']._serialized_end=1294 + _globals['_TIPTRACKERSTATE']._serialized_start=1296 + _globals['_TIPTRACKERSTATE']._serialized_end=1402 + _globals['_GETTREEREQUEST']._serialized_start=1404 + _globals['_GETTREEREQUEST']._serialized_end=1439 + _globals['_RESOURCEBYNAMEREQUEST']._serialized_start=1441 + _globals['_RESOURCEBYNAMEREQUEST']._serialized_end=1478 + _globals['_GETLOCATIONWRTREQUEST']._serialized_start=1480 + _globals['_GETLOCATIONWRTREQUEST']._serialized_end=1600 + _globals['_GETABSOLUTELOCATIONREQUEST']._serialized_start=1602 + _globals['_GETABSOLUTELOCATIONREQUEST']._serialized_end=1707 + _globals['_GETABSOLUTEROTATIONREQUEST']._serialized_start=1709 + _globals['_GETABSOLUTEROTATIONREQUEST']._serialized_end=1760 + _globals['_GETABSOLUTESIZEREQUEST']._serialized_start=1762 + _globals['_GETABSOLUTESIZEREQUEST']._serialized_end=1809 + _globals['_GETHIGHESTPOINTREQUEST']._serialized_start=1811 + _globals['_GETHIGHESTPOINTREQUEST']._serialized_end=1858 + _globals['_LOCATIONWRTITEM']._serialized_start=1860 + _globals['_LOCATIONWRTITEM']._serialized_end=1974 + _globals['_BATCHGETLOCATIONWRTREQUEST']._serialized_start=1976 + _globals['_BATCHGETLOCATIONWRTREQUEST']._serialized_end=2065 + _globals['_BATCHCOORDINATERESPONSE']._serialized_start=2067 + _globals['_BATCHCOORDINATERESPONSE']._serialized_end=2154 + _globals['_COMPUTEVOLUMEHEIGHTREQUEST']._serialized_start=2156 + _globals['_COMPUTEVOLUMEHEIGHTREQUEST']._serialized_end=2222 + _globals['_FLOATRESPONSE']._serialized_start=2224 + _globals['_FLOATRESPONSE']._serialized_end=2254 + _globals['_BOOLRESPONSE']._serialized_start=2256 + _globals['_BOOLRESPONSE']._serialized_end=2285 + _globals['_GETTIPREQUEST']._serialized_start=2287 + _globals['_GETTIPREQUEST']._serialized_end=2325 + _globals['_TRACKEROPREQUEST']._serialized_start=2327 + _globals['_TRACKEROPREQUEST']._serialized_end=2384 + _globals['_BATCHTRACKEROPREQUEST']._serialized_start=2386 + _globals['_BATCHTRACKEROPREQUEST']._serialized_end=2469 + _globals['_TIPTRACKEROPREQUEST']._serialized_start=2471 + _globals['_TIPTRACKEROPREQUEST']._serialized_end=2515 + _globals['_COMMITROLLBACKREQUEST']._serialized_start=2517 + _globals['_COMMITROLLBACKREQUEST']._serialized_end=2564 + _globals['_ASSIGNCHILDREQUEST']._serialized_start=2566 + _globals['_ASSIGNCHILDREQUEST']._serialized_end=2686 + _globals['_UNASSIGNCHILDREQUEST']._serialized_start=2688 + _globals['_UNASSIGNCHILDREQUEST']._serialized_end=2733 + _globals['_HASLIDREQUEST']._serialized_start=2735 + _globals['_HASLIDREQUEST']._serialized_end=2770 + _globals['_DECKSERVICE']._serialized_start=2773 + _globals['_DECKSERVICE']._serialized_end=6078 +# @@protoc_insertion_point(module_scope) diff --git a/pylabrobot/resources/remote/deck_service_pb2.pyi b/pylabrobot/resources/remote/deck_service_pb2.pyi new file mode 100644 index 00000000000..3bae00481be --- /dev/null +++ b/pylabrobot/resources/remote/deck_service_pb2.pyi @@ -0,0 +1,296 @@ +from google.protobuf.internal import containers as _containers +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from collections.abc import Iterable as _Iterable, Mapping as _Mapping +from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union + +DESCRIPTOR: _descriptor.FileDescriptor + +class Empty(_message.Message): + __slots__ = () + def __init__(self) -> None: ... + +class Coordinate(_message.Message): + __slots__ = ("x", "y", "z") + X_FIELD_NUMBER: _ClassVar[int] + Y_FIELD_NUMBER: _ClassVar[int] + Z_FIELD_NUMBER: _ClassVar[int] + x: float + y: float + z: float + def __init__(self, x: _Optional[float] = ..., y: _Optional[float] = ..., z: _Optional[float] = ...) -> None: ... + +class Rotation(_message.Message): + __slots__ = ("x", "y", "z") + X_FIELD_NUMBER: _ClassVar[int] + Y_FIELD_NUMBER: _ClassVar[int] + Z_FIELD_NUMBER: _ClassVar[int] + x: float + y: float + z: float + def __init__(self, x: _Optional[float] = ..., y: _Optional[float] = ..., z: _Optional[float] = ...) -> None: ... + +class Size(_message.Message): + __slots__ = ("x", "y", "z") + X_FIELD_NUMBER: _ClassVar[int] + Y_FIELD_NUMBER: _ClassVar[int] + Z_FIELD_NUMBER: _ClassVar[int] + x: float + y: float + z: float + def __init__(self, x: _Optional[float] = ..., y: _Optional[float] = ..., z: _Optional[float] = ...) -> None: ... + +class TipData(_message.Message): + __slots__ = ("type", "name", "has_filter", "total_tip_length", "maximal_volume", "fitting_depth", "tip_size", "pickup_method") + TYPE_FIELD_NUMBER: _ClassVar[int] + NAME_FIELD_NUMBER: _ClassVar[int] + HAS_FILTER_FIELD_NUMBER: _ClassVar[int] + TOTAL_TIP_LENGTH_FIELD_NUMBER: _ClassVar[int] + MAXIMAL_VOLUME_FIELD_NUMBER: _ClassVar[int] + FITTING_DEPTH_FIELD_NUMBER: _ClassVar[int] + TIP_SIZE_FIELD_NUMBER: _ClassVar[int] + PICKUP_METHOD_FIELD_NUMBER: _ClassVar[int] + type: str + name: str + has_filter: bool + total_tip_length: float + maximal_volume: float + fitting_depth: float + tip_size: str + pickup_method: str + def __init__(self, type: _Optional[str] = ..., name: _Optional[str] = ..., has_filter: bool = ..., total_tip_length: _Optional[float] = ..., maximal_volume: _Optional[float] = ..., fitting_depth: _Optional[float] = ..., tip_size: _Optional[str] = ..., pickup_method: _Optional[str] = ...) -> None: ... + +class ResourceData(_message.Message): + __slots__ = ("name", "type", "size_x", "size_y", "size_z", "category", "model", "location", "rotation", "parent_name", "material_z_thickness", "max_volume", "well_bottom_type", "cross_section_type", "plate_type", "has_lid", "prototype_tip", "ordering", "nesting_z_height") + class OrderingEntry(_message.Message): + __slots__ = ("key", "value") + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... + NAME_FIELD_NUMBER: _ClassVar[int] + TYPE_FIELD_NUMBER: _ClassVar[int] + SIZE_X_FIELD_NUMBER: _ClassVar[int] + SIZE_Y_FIELD_NUMBER: _ClassVar[int] + SIZE_Z_FIELD_NUMBER: _ClassVar[int] + CATEGORY_FIELD_NUMBER: _ClassVar[int] + MODEL_FIELD_NUMBER: _ClassVar[int] + LOCATION_FIELD_NUMBER: _ClassVar[int] + ROTATION_FIELD_NUMBER: _ClassVar[int] + PARENT_NAME_FIELD_NUMBER: _ClassVar[int] + MATERIAL_Z_THICKNESS_FIELD_NUMBER: _ClassVar[int] + MAX_VOLUME_FIELD_NUMBER: _ClassVar[int] + WELL_BOTTOM_TYPE_FIELD_NUMBER: _ClassVar[int] + CROSS_SECTION_TYPE_FIELD_NUMBER: _ClassVar[int] + PLATE_TYPE_FIELD_NUMBER: _ClassVar[int] + HAS_LID_FIELD_NUMBER: _ClassVar[int] + PROTOTYPE_TIP_FIELD_NUMBER: _ClassVar[int] + ORDERING_FIELD_NUMBER: _ClassVar[int] + NESTING_Z_HEIGHT_FIELD_NUMBER: _ClassVar[int] + name: str + type: str + size_x: float + size_y: float + size_z: float + category: str + model: str + location: Coordinate + rotation: Rotation + parent_name: str + material_z_thickness: float + max_volume: float + well_bottom_type: str + cross_section_type: str + plate_type: str + has_lid: bool + prototype_tip: TipData + ordering: _containers.ScalarMap[str, str] + nesting_z_height: float + def __init__(self, name: _Optional[str] = ..., type: _Optional[str] = ..., size_x: _Optional[float] = ..., size_y: _Optional[float] = ..., size_z: _Optional[float] = ..., category: _Optional[str] = ..., model: _Optional[str] = ..., location: _Optional[_Union[Coordinate, _Mapping]] = ..., rotation: _Optional[_Union[Rotation, _Mapping]] = ..., parent_name: _Optional[str] = ..., material_z_thickness: _Optional[float] = ..., max_volume: _Optional[float] = ..., well_bottom_type: _Optional[str] = ..., cross_section_type: _Optional[str] = ..., plate_type: _Optional[str] = ..., has_lid: bool = ..., prototype_tip: _Optional[_Union[TipData, _Mapping]] = ..., ordering: _Optional[_Mapping[str, str]] = ..., nesting_z_height: _Optional[float] = ...) -> None: ... + +class ResourceTree(_message.Message): + __slots__ = ("data", "children") + DATA_FIELD_NUMBER: _ClassVar[int] + CHILDREN_FIELD_NUMBER: _ClassVar[int] + data: ResourceData + children: _containers.RepeatedCompositeFieldContainer[ResourceTree] + def __init__(self, data: _Optional[_Union[ResourceData, _Mapping]] = ..., children: _Optional[_Iterable[_Union[ResourceTree, _Mapping]]] = ...) -> None: ... + +class VolumeTrackerState(_message.Message): + __slots__ = ("volume", "pending_volume", "max_volume", "is_disabled") + VOLUME_FIELD_NUMBER: _ClassVar[int] + PENDING_VOLUME_FIELD_NUMBER: _ClassVar[int] + MAX_VOLUME_FIELD_NUMBER: _ClassVar[int] + IS_DISABLED_FIELD_NUMBER: _ClassVar[int] + volume: float + pending_volume: float + max_volume: float + is_disabled: bool + def __init__(self, volume: _Optional[float] = ..., pending_volume: _Optional[float] = ..., max_volume: _Optional[float] = ..., is_disabled: bool = ...) -> None: ... + +class TipTrackerState(_message.Message): + __slots__ = ("has_tip", "tip", "is_disabled") + HAS_TIP_FIELD_NUMBER: _ClassVar[int] + TIP_FIELD_NUMBER: _ClassVar[int] + IS_DISABLED_FIELD_NUMBER: _ClassVar[int] + has_tip: bool + tip: TipData + is_disabled: bool + def __init__(self, has_tip: bool = ..., tip: _Optional[_Union[TipData, _Mapping]] = ..., is_disabled: bool = ...) -> None: ... + +class GetTreeRequest(_message.Message): + __slots__ = ("root_name",) + ROOT_NAME_FIELD_NUMBER: _ClassVar[int] + root_name: str + def __init__(self, root_name: _Optional[str] = ...) -> None: ... + +class ResourceByNameRequest(_message.Message): + __slots__ = ("name",) + NAME_FIELD_NUMBER: _ClassVar[int] + name: str + def __init__(self, name: _Optional[str] = ...) -> None: ... + +class GetLocationWrtRequest(_message.Message): + __slots__ = ("resource_name", "other_name", "anchor_x", "anchor_y", "anchor_z") + RESOURCE_NAME_FIELD_NUMBER: _ClassVar[int] + OTHER_NAME_FIELD_NUMBER: _ClassVar[int] + ANCHOR_X_FIELD_NUMBER: _ClassVar[int] + ANCHOR_Y_FIELD_NUMBER: _ClassVar[int] + ANCHOR_Z_FIELD_NUMBER: _ClassVar[int] + resource_name: str + other_name: str + anchor_x: str + anchor_y: str + anchor_z: str + def __init__(self, resource_name: _Optional[str] = ..., other_name: _Optional[str] = ..., anchor_x: _Optional[str] = ..., anchor_y: _Optional[str] = ..., anchor_z: _Optional[str] = ...) -> None: ... + +class GetAbsoluteLocationRequest(_message.Message): + __slots__ = ("resource_name", "anchor_x", "anchor_y", "anchor_z") + RESOURCE_NAME_FIELD_NUMBER: _ClassVar[int] + ANCHOR_X_FIELD_NUMBER: _ClassVar[int] + ANCHOR_Y_FIELD_NUMBER: _ClassVar[int] + ANCHOR_Z_FIELD_NUMBER: _ClassVar[int] + resource_name: str + anchor_x: str + anchor_y: str + anchor_z: str + def __init__(self, resource_name: _Optional[str] = ..., anchor_x: _Optional[str] = ..., anchor_y: _Optional[str] = ..., anchor_z: _Optional[str] = ...) -> None: ... + +class GetAbsoluteRotationRequest(_message.Message): + __slots__ = ("resource_name",) + RESOURCE_NAME_FIELD_NUMBER: _ClassVar[int] + resource_name: str + def __init__(self, resource_name: _Optional[str] = ...) -> None: ... + +class GetAbsoluteSizeRequest(_message.Message): + __slots__ = ("resource_name",) + RESOURCE_NAME_FIELD_NUMBER: _ClassVar[int] + resource_name: str + def __init__(self, resource_name: _Optional[str] = ...) -> None: ... + +class GetHighestPointRequest(_message.Message): + __slots__ = ("resource_name",) + RESOURCE_NAME_FIELD_NUMBER: _ClassVar[int] + resource_name: str + def __init__(self, resource_name: _Optional[str] = ...) -> None: ... + +class LocationWrtItem(_message.Message): + __slots__ = ("resource_name", "other_name", "anchor_x", "anchor_y", "anchor_z") + RESOURCE_NAME_FIELD_NUMBER: _ClassVar[int] + OTHER_NAME_FIELD_NUMBER: _ClassVar[int] + ANCHOR_X_FIELD_NUMBER: _ClassVar[int] + ANCHOR_Y_FIELD_NUMBER: _ClassVar[int] + ANCHOR_Z_FIELD_NUMBER: _ClassVar[int] + resource_name: str + other_name: str + anchor_x: str + anchor_y: str + anchor_z: str + def __init__(self, resource_name: _Optional[str] = ..., other_name: _Optional[str] = ..., anchor_x: _Optional[str] = ..., anchor_y: _Optional[str] = ..., anchor_z: _Optional[str] = ...) -> None: ... + +class BatchGetLocationWrtRequest(_message.Message): + __slots__ = ("items",) + ITEMS_FIELD_NUMBER: _ClassVar[int] + items: _containers.RepeatedCompositeFieldContainer[LocationWrtItem] + def __init__(self, items: _Optional[_Iterable[_Union[LocationWrtItem, _Mapping]]] = ...) -> None: ... + +class BatchCoordinateResponse(_message.Message): + __slots__ = ("coordinates",) + COORDINATES_FIELD_NUMBER: _ClassVar[int] + coordinates: _containers.RepeatedCompositeFieldContainer[Coordinate] + def __init__(self, coordinates: _Optional[_Iterable[_Union[Coordinate, _Mapping]]] = ...) -> None: ... + +class ComputeVolumeHeightRequest(_message.Message): + __slots__ = ("resource_name", "value") + RESOURCE_NAME_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + resource_name: str + value: float + def __init__(self, resource_name: _Optional[str] = ..., value: _Optional[float] = ...) -> None: ... + +class FloatResponse(_message.Message): + __slots__ = ("value",) + VALUE_FIELD_NUMBER: _ClassVar[int] + value: float + def __init__(self, value: _Optional[float] = ...) -> None: ... + +class BoolResponse(_message.Message): + __slots__ = ("value",) + VALUE_FIELD_NUMBER: _ClassVar[int] + value: bool + def __init__(self, value: bool = ...) -> None: ... + +class GetTipRequest(_message.Message): + __slots__ = ("tip_spot_name",) + TIP_SPOT_NAME_FIELD_NUMBER: _ClassVar[int] + tip_spot_name: str + def __init__(self, tip_spot_name: _Optional[str] = ...) -> None: ... + +class TrackerOpRequest(_message.Message): + __slots__ = ("resource_name", "volume") + RESOURCE_NAME_FIELD_NUMBER: _ClassVar[int] + VOLUME_FIELD_NUMBER: _ClassVar[int] + resource_name: str + volume: float + def __init__(self, resource_name: _Optional[str] = ..., volume: _Optional[float] = ...) -> None: ... + +class BatchTrackerOpRequest(_message.Message): + __slots__ = ("ops",) + OPS_FIELD_NUMBER: _ClassVar[int] + ops: _containers.RepeatedCompositeFieldContainer[TrackerOpRequest] + def __init__(self, ops: _Optional[_Iterable[_Union[TrackerOpRequest, _Mapping]]] = ...) -> None: ... + +class TipTrackerOpRequest(_message.Message): + __slots__ = ("tip_spot_name",) + TIP_SPOT_NAME_FIELD_NUMBER: _ClassVar[int] + tip_spot_name: str + def __init__(self, tip_spot_name: _Optional[str] = ...) -> None: ... + +class CommitRollbackRequest(_message.Message): + __slots__ = ("resource_names",) + RESOURCE_NAMES_FIELD_NUMBER: _ClassVar[int] + resource_names: _containers.RepeatedScalarFieldContainer[str] + def __init__(self, resource_names: _Optional[_Iterable[str]] = ...) -> None: ... + +class AssignChildRequest(_message.Message): + __slots__ = ("child_name", "parent_name", "location") + CHILD_NAME_FIELD_NUMBER: _ClassVar[int] + PARENT_NAME_FIELD_NUMBER: _ClassVar[int] + LOCATION_FIELD_NUMBER: _ClassVar[int] + child_name: str + parent_name: str + location: Coordinate + def __init__(self, child_name: _Optional[str] = ..., parent_name: _Optional[str] = ..., location: _Optional[_Union[Coordinate, _Mapping]] = ...) -> None: ... + +class UnassignChildRequest(_message.Message): + __slots__ = ("resource_name",) + RESOURCE_NAME_FIELD_NUMBER: _ClassVar[int] + resource_name: str + def __init__(self, resource_name: _Optional[str] = ...) -> None: ... + +class HasLidRequest(_message.Message): + __slots__ = ("plate_name",) + PLATE_NAME_FIELD_NUMBER: _ClassVar[int] + plate_name: str + def __init__(self, plate_name: _Optional[str] = ...) -> None: ... diff --git a/pylabrobot/resources/remote/example/README.md b/pylabrobot/resources/remote/example/README.md new file mode 100644 index 00000000000..8e8d0b1fe83 --- /dev/null +++ b/pylabrobot/resources/remote/example/README.md @@ -0,0 +1,27 @@ +# Remote Deck Example + +Serve a deck over HTTP so a `LiquidHandler` on another machine (or process) can use it as a drop-in replacement. + +## Setup + +``` +pip install connect-python uvicorn +``` + +## Run + +Terminal 1 — start the server: + +``` +python server.py +``` + +Terminal 2 — run the client: + +``` +python client.py +``` + +The client connects to the server, fetches the full resource tree, and runs a +pick-up / aspirate / dispense / drop cycle through `STARBackend` exactly as if +the deck were local. diff --git a/pylabrobot/resources/remote/example/client.py b/pylabrobot/resources/remote/example/client.py new file mode 100644 index 00000000000..b8cdb516779 --- /dev/null +++ b/pylabrobot/resources/remote/example/client.py @@ -0,0 +1,26 @@ +"""Connect to a remote deck server and run a liquid handling workflow.""" + +import asyncio + +from pylabrobot.liquid_handling import LiquidHandler +from pylabrobot.liquid_handling.backends import STARBackend +from pylabrobot.resources.remote.client import RemoteDeck + + +async def main(): + deck = RemoteDeck.connect("http://localhost:8080") + lh = LiquidHandler(backend=STARBackend(), deck=deck) + await lh.setup() + + tips = deck.get_resource("tips") + plate = deck.get_resource("plate") + + await lh.pick_up_tips(tips["A1:H1"]) + await lh.aspirate(plate["A1:H1"], vols=[100.0] * 8) + await lh.dispense(plate["A2:H2"], vols=[100.0] * 8) + await lh.drop_tips(tips["A1:H1"]) + + await lh.stop() + + +asyncio.run(main()) diff --git a/pylabrobot/resources/remote/example/server.py b/pylabrobot/resources/remote/example/server.py new file mode 100644 index 00000000000..4ba279cd0a4 --- /dev/null +++ b/pylabrobot/resources/remote/example/server.py @@ -0,0 +1,14 @@ +"""Start a remote deck server.""" + +import uvicorn + +from pylabrobot.resources import Cor_96_wellplate_360ul_Fb, hamilton_96_tiprack_1000uL_filter +from pylabrobot.resources.hamilton import STARDeck +from pylabrobot.resources.remote.server import create_app + +deck = STARDeck() +deck.assign_child_resource(hamilton_96_tiprack_1000uL_filter(name="tips"), rails=3) +deck.assign_child_resource(Cor_96_wellplate_360ul_Fb(name="plate"), rails=9) + +app = create_app(deck) +uvicorn.run(app, host="0.0.0.0", port=8080) diff --git a/pylabrobot/resources/remote/proxies.py b/pylabrobot/resources/remote/proxies.py new file mode 100644 index 00000000000..db65a8b44ee --- /dev/null +++ b/pylabrobot/resources/remote/proxies.py @@ -0,0 +1,303 @@ +"""Proxy classes that pass isinstance checks and delegate spatial/state ops to the server.""" + +from __future__ import annotations + +from collections import OrderedDict +from typing import TYPE_CHECKING, Dict, Optional + +from pylabrobot.resources.container import Container +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.plate import Lid, Plate +from pylabrobot.resources.resource import Resource +from pylabrobot.resources.rotation import Rotation +from pylabrobot.resources.tip import Tip +from pylabrobot.resources.tip_rack import TipRack, TipSpot +from pylabrobot.resources.trash import Trash +from pylabrobot.resources.well import Well + +from . import deck_service_pb2 as pb2 +from .remote_trackers import RemoteTipTracker, RemoteVolumeTracker, _tip_from_proto + +if TYPE_CHECKING: + from .deck_service_connect import DeckServiceClientSync + + +# ============================================================ +# Spatial method mixin — avoids repeating the same overrides +# ============================================================ + +class _SpatialMixin: + """Mixin that overrides spatial methods to delegate to the remote server. + + Requires ``self._client`` and ``self.name`` to be set. + """ + + _client: DeckServiceClientSync + name: str + + def get_location_wrt(self, other, x="l", y="f", z="b"): + resp = self._client.get_location_wrt(pb2.GetLocationWrtRequest( + resource_name=self.name, other_name=other.name, + anchor_x=str(x), anchor_y=str(y), anchor_z=str(z), + )) + return Coordinate(resp.x, resp.y, resp.z) + + def get_absolute_location(self, x="l", y="f", z="b"): + resp = self._client.get_absolute_location(pb2.GetAbsoluteLocationRequest( + resource_name=self.name, anchor_x=str(x), anchor_y=str(y), anchor_z=str(z), + )) + return Coordinate(resp.x, resp.y, resp.z) + + def get_absolute_rotation(self): + resp = self._client.get_absolute_rotation( + pb2.GetAbsoluteRotationRequest(resource_name=self.name)) + return Rotation(resp.x, resp.y, resp.z) + + def get_absolute_size_x(self): + resp = self._client.get_absolute_size( + pb2.GetAbsoluteSizeRequest(resource_name=self.name)) + return resp.x + + def get_absolute_size_y(self): + resp = self._client.get_absolute_size( + pb2.GetAbsoluteSizeRequest(resource_name=self.name)) + return resp.y + + def get_absolute_size_z(self): + resp = self._client.get_absolute_size( + pb2.GetAbsoluteSizeRequest(resource_name=self.name)) + return resp.z + + def get_highest_known_point(self): + resp = self._client.get_highest_point( + pb2.GetHighestPointRequest(resource_name=self.name)) + return resp.value + + +# ============================================================ +# Proxy classes +# ============================================================ + +class ResourceProxy(_SpatialMixin, Resource): + """Base proxy. Holds immutable data locally, delegates spatial + state to server.""" + + def __init__(self, client: DeckServiceClientSync, data: pb2.ResourceData): + Resource.__init__( + self, + name=data.name, + size_x=data.size_x, + size_y=data.size_y, + size_z=data.size_z, + category=data.category or None, + model=data.model or None, + ) + self._client = client + + +class ContainerProxy(_SpatialMixin, Container): + """Proxy for Container — adds remote volume tracker.""" + + def __init__(self, client: DeckServiceClientSync, data: pb2.ResourceData): + Container.__init__( + self, + name=data.name, + size_x=data.size_x, + size_y=data.size_y, + size_z=data.size_z, + material_z_thickness=data.material_z_thickness if data.HasField("material_z_thickness") else None, + max_volume=data.max_volume if data.HasField("max_volume") else None, + category=data.category or None, + model=data.model or None, + ) + self._client = client + self.tracker = RemoteVolumeTracker(client, self.name) + + def compute_volume_from_height(self, height: float) -> float: + resp = self._client.compute_volume_from_height( + pb2.ComputeVolumeHeightRequest(resource_name=self.name, value=height)) + return resp.value + + def compute_height_from_volume(self, volume: float) -> float: + resp = self._client.compute_height_from_volume( + pb2.ComputeVolumeHeightRequest(resource_name=self.name, value=volume)) + return resp.value + + def supports_compute_height_volume_functions(self) -> bool: + resp = self._client.supports_compute_height_volume( + pb2.ResourceByNameRequest(name=self.name)) + return resp.value + + +class WellProxy(_SpatialMixin, Well): + """Proxy for Well — passes isinstance(x, Well) and isinstance(x, Container).""" + + def __init__(self, client: DeckServiceClientSync, data: pb2.ResourceData): + Well.__init__( + self, + name=data.name, + size_x=data.size_x, + size_y=data.size_y, + size_z=data.size_z, + material_z_thickness=data.material_z_thickness if data.HasField("material_z_thickness") else None, + max_volume=data.max_volume if data.HasField("max_volume") else None, + bottom_type=data.well_bottom_type or "unknown", + cross_section_type=data.cross_section_type or "circle", + category=data.category or "well", + model=data.model or None, + ) + self._client = client + self.tracker = RemoteVolumeTracker(client, self.name) + + def compute_volume_from_height(self, height: float) -> float: + resp = self._client.compute_volume_from_height( + pb2.ComputeVolumeHeightRequest(resource_name=self.name, value=height)) + return resp.value + + def compute_height_from_volume(self, volume: float) -> float: + resp = self._client.compute_height_from_volume( + pb2.ComputeVolumeHeightRequest(resource_name=self.name, value=volume)) + return resp.value + + def supports_compute_height_volume_functions(self) -> bool: + resp = self._client.supports_compute_height_volume( + pb2.ResourceByNameRequest(name=self.name)) + return resp.value + + +class TipSpotProxy(_SpatialMixin, TipSpot): + """Proxy for TipSpot — tip creation and tracking go to server.""" + + def __init__(self, client: DeckServiceClientSync, data: pb2.ResourceData): + prototype = data.prototype_tip + + def make_tip(name: str) -> Tip: + return _tip_from_proto(pb2.TipData( + type=prototype.type, + name=name, + has_filter=prototype.has_filter, + total_tip_length=prototype.total_tip_length, + maximal_volume=prototype.maximal_volume, + fitting_depth=prototype.fitting_depth, + tip_size=prototype.tip_size, + pickup_method=prototype.pickup_method, + )) + + # TipSpot.__init__ swaps size_x and size_y internally, so we pass them + # in the original (pre-swap) order. Since the server already stores the + # post-swap values, we reverse the swap here. + TipSpot.__init__( + self, + name=data.name, + size_x=data.size_y, # reverse the swap + size_y=data.size_x, # reverse the swap + size_z=data.size_z, + make_tip=make_tip, + category=data.category or "tip_spot", + ) + self._client = client + self.tracker = RemoteTipTracker(client, self.name) + + def get_tip(self) -> Tip: + """Always get the tip from the server (authoritative state).""" + resp = self._client.get_tip(pb2.GetTipRequest(tip_spot_name=self.name)) + return _tip_from_proto(resp) + + +class PlateProxy(_SpatialMixin, Plate): + """Proxy for Plate — has_lid() goes to server (lid can be moved).""" + + def __init__(self, client: DeckServiceClientSync, data: pb2.ResourceData, + ordering: Dict[str, str]): + Plate.__init__( + self, + name=data.name, + size_x=data.size_x, + size_y=data.size_y, + size_z=data.size_z, + ordering=OrderedDict(ordering), + plate_type=data.plate_type or "skirted", + category=data.category or "plate", + model=data.model or None, + ) + self._client = client + + def has_lid(self) -> bool: + resp = self._client.has_lid(pb2.HasLidRequest(plate_name=self.name)) + return resp.value + + +class TipRackProxy(_SpatialMixin, TipRack): + """Proxy for TipRack.""" + + def __init__(self, client: DeckServiceClientSync, data: pb2.ResourceData, + ordering: Dict[str, str]): + TipRack.__init__( + self, + name=data.name, + size_x=data.size_x, + size_y=data.size_y, + size_z=data.size_z, + ordering=OrderedDict(ordering), + category=data.category or "tip_rack", + model=data.model or None, + ) + self._client = client + + +class TrashProxy(_SpatialMixin, Trash): + """Proxy for Trash.""" + + def __init__(self, client: DeckServiceClientSync, data: pb2.ResourceData): + Trash.__init__( + self, + name=data.name, + size_x=data.size_x, + size_y=data.size_y, + size_z=data.size_z, + material_z_thickness=data.material_z_thickness if data.HasField("material_z_thickness") else 0, + max_volume=data.max_volume if data.HasField("max_volume") else float("inf"), + category=data.category or "trash", + model=data.model or None, + ) + self._client = client + self.tracker = RemoteVolumeTracker(client, self.name) + + +class LidProxy(_SpatialMixin, Lid): + """Proxy for Lid.""" + + def __init__(self, client: DeckServiceClientSync, data: pb2.ResourceData): + Lid.__init__( + self, + name=data.name, + size_x=data.size_x, + size_y=data.size_y, + size_z=data.size_z, + nesting_z_height=data.nesting_z_height if data.HasField("nesting_z_height") else 0, + category=data.category or "lid", + model=data.model or None, + ) + self._client = client + + +# ============================================================ +# Proxy factory +# ============================================================ + +_PROXY_MAP = { + "Well": WellProxy, + "Plate": PlateProxy, + "TipSpot": TipSpotProxy, + "TipRack": TipRackProxy, + "Trash": TrashProxy, + "Container": ContainerProxy, + "Lid": LidProxy, +} + + +def create_proxy(client: DeckServiceClientSync, data: pb2.ResourceData) -> Resource: + """Create the appropriate proxy object from a ResourceData message.""" + cls = _PROXY_MAP.get(data.type, ResourceProxy) + if cls in (PlateProxy, TipRackProxy): + return cls(client, data, ordering=dict(data.ordering)) + return cls(client, data) diff --git a/pylabrobot/resources/remote/remote_trackers.py b/pylabrobot/resources/remote/remote_trackers.py new file mode 100644 index 00000000000..2c15157ae2e --- /dev/null +++ b/pylabrobot/resources/remote/remote_trackers.py @@ -0,0 +1,159 @@ +"""Remote tracker implementations that delegate to the DeckService server.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +from pylabrobot.resources.tip import Tip + +from . import deck_service_pb2 as pb2 + +if TYPE_CHECKING: + from .deck_service_connect import DeckServiceClientSync + + +def _tip_from_proto(tip_data: pb2.TipData) -> Tip: + """Construct a Tip or HamiltonTip from a TipData protobuf message.""" + if tip_data.type == "HamiltonTip" and tip_data.tip_size: + from pylabrobot.resources.hamilton.tip_creators import HamiltonTip + return HamiltonTip( + has_filter=tip_data.has_filter, + total_tip_length=tip_data.total_tip_length, + maximal_volume=tip_data.maximal_volume, + tip_size=tip_data.tip_size, + pickup_method=tip_data.pickup_method, + name=tip_data.name or None, + ) + return Tip( + has_filter=tip_data.has_filter, + total_tip_length=tip_data.total_tip_length, + maximal_volume=tip_data.maximal_volume, + fitting_depth=tip_data.fitting_depth, + name=tip_data.name or None, + ) + + +class RemoteVolumeTracker: + """Drop-in replacement for VolumeTracker that delegates to the server.""" + + def __init__(self, client: DeckServiceClientSync, resource_name: str): + self._client = client + self._resource_name = resource_name + + def _get_state(self) -> pb2.VolumeTrackerState: + return self._client.get_volume_tracker_state( + pb2.ResourceByNameRequest(name=self._resource_name)) + + @property + def is_disabled(self) -> bool: + return self._get_state().is_disabled + + def get_used_volume(self) -> float: + state = self._get_state() + return state.pending_volume + + def get_free_volume(self) -> float: + state = self._get_state() + return state.max_volume - state.pending_volume + + def remove_liquid(self, volume: float) -> None: + self._client.remove_liquid( + pb2.TrackerOpRequest(resource_name=self._resource_name, volume=volume)) + + def add_liquid(self, volume: float) -> None: + self._client.add_liquid( + pb2.TrackerOpRequest(resource_name=self._resource_name, volume=volume)) + + def set_volume(self, volume: float) -> None: + # Not directly supported as a single RPC; approximate via add/remove. + # For correctness, the server should ideally handle this. + pass + + def commit(self) -> None: + self._client.commit_volume_trackers( + pb2.CommitRollbackRequest(resource_names=[self._resource_name])) + + def rollback(self) -> None: + self._client.rollback_volume_trackers( + pb2.CommitRollbackRequest(resource_names=[self._resource_name])) + + def serialize(self) -> dict: + state = self._get_state() + return { + "volume": state.volume, + "pending_volume": state.pending_volume, + "max_volume": state.max_volume, + "is_disabled": state.is_disabled, + } + + def disable(self) -> None: + pass # Not supported remotely; trackers are managed by the server. + + def enable(self) -> None: + pass # Not supported remotely; trackers are managed by the server. + + def register_callback(self, callback) -> None: + pass # Callbacks are server-side only. + + +class RemoteTipTracker: + """Drop-in replacement for TipTracker that delegates to the server.""" + + def __init__(self, client: DeckServiceClientSync, resource_name: str): + self._client = client + self._resource_name = resource_name + + def _get_state(self) -> pb2.TipTrackerState: + return self._client.get_tip_tracker_state( + pb2.ResourceByNameRequest(name=self._resource_name)) + + @property + def is_disabled(self) -> bool: + return self._get_state().is_disabled + + @property + def has_tip(self) -> bool: + return self._get_state().has_tip + + def get_tip(self) -> Tip: + state = self._get_state() + if not state.has_tip: + from pylabrobot.resources.tip_tracker import NoTipError + raise NoTipError(f"No tip on {self._resource_name}") + return _tip_from_proto(state.tip) + + def remove_tip(self, commit: bool = False) -> None: + self._client.remove_tip( + pb2.TipTrackerOpRequest(tip_spot_name=self._resource_name)) + if commit: + self.commit() + + def add_tip(self, tip: Optional[Tip] = None, origin=None, commit: bool = True) -> None: + self._client.add_tip( + pb2.TipTrackerOpRequest(tip_spot_name=self._resource_name)) + if commit: + self.commit() + + def commit(self) -> None: + self._client.commit_tip_trackers( + pb2.CommitRollbackRequest(resource_names=[self._resource_name])) + + def rollback(self) -> None: + self._client.rollback_tip_trackers( + pb2.CommitRollbackRequest(resource_names=[self._resource_name])) + + def serialize(self) -> dict: + state = self._get_state() + return {"has_tip": state.has_tip, "is_disabled": state.is_disabled} + + def disable(self) -> None: + pass + + def enable(self) -> None: + pass + + def register_callback(self, callback) -> None: + pass + + def clear(self) -> None: + pass diff --git a/pylabrobot/resources/remote/server.py b/pylabrobot/resources/remote/server.py new file mode 100644 index 00000000000..885f1ea4b95 --- /dev/null +++ b/pylabrobot/resources/remote/server.py @@ -0,0 +1,341 @@ +"""DeckService server implementation — wraps a real Deck object.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pylabrobot.resources.container import Container +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.plate import Lid, Plate +from pylabrobot.resources.resource import Resource +from pylabrobot.resources.tip import Tip +from pylabrobot.resources.tip_rack import TipRack, TipSpot +from pylabrobot.resources.trash import Trash +from pylabrobot.resources.well import Well + +from . import deck_service_pb2 as pb2 +from .deck_service_connect import DeckService, DeckServiceASGIApplication + +if TYPE_CHECKING: + from pylabrobot.resources.deck import Deck + from connectrpc.request import RequestContext + + +# ============================================================ +# Conversion helpers +# ============================================================ + +def _tip_to_proto(tip: Tip) -> pb2.TipData: + """Convert a Tip (or HamiltonTip) to a TipData protobuf message.""" + from pylabrobot.resources.hamilton.tip_creators import HamiltonTip + td = pb2.TipData( + type=tip.__class__.__name__, + name=tip.name or "", + has_filter=tip.has_filter, + total_tip_length=tip.total_tip_length, + maximal_volume=tip.maximal_volume, + fitting_depth=tip.fitting_depth, + ) + if isinstance(tip, HamiltonTip): + td.tip_size = tip.tip_size.name + td.pickup_method = tip.pickup_method.name + return td + + +def _resource_to_data(resource: Resource) -> pb2.ResourceData: + """Convert a single resource to a ResourceData protobuf message.""" + data = pb2.ResourceData( + name=resource.name, + type=resource.__class__.__name__, + size_x=resource._size_x, + size_y=resource._size_y, + size_z=resource._size_z, + category=resource.category or "", + model=resource.model or "", + ) + + # Location relative to parent + if resource.location is not None: + data.location.CopyFrom(pb2.Coordinate( + x=resource.location.x, y=resource.location.y, z=resource.location.z)) + + # Rotation + if resource.rotation is not None: + data.rotation.CopyFrom(pb2.Rotation( + x=resource.rotation.x, y=resource.rotation.y, z=resource.rotation.z)) + + # Parent name + if resource.parent is not None: + data.parent_name = resource.parent.name + + # Container fields + if isinstance(resource, Container): + if resource._material_z_thickness is not None: + data.material_z_thickness = resource._material_z_thickness + if resource.max_volume is not None: + data.max_volume = resource.max_volume + + # Well fields + if isinstance(resource, Well): + data.well_bottom_type = resource.bottom_type.value if hasattr(resource.bottom_type, 'value') else str(resource.bottom_type) + data.cross_section_type = resource.cross_section_type.value if hasattr(resource.cross_section_type, 'value') else str(resource.cross_section_type) + + # Plate fields + if isinstance(resource, Plate): + data.plate_type = resource.plate_type + data.has_lid = resource.has_lid() + # Include ordering + for key, val in resource._ordering.items(): + data.ordering[key] = val + + # TipRack fields + if isinstance(resource, TipRack): + for key, val in resource._ordering.items(): + data.ordering[key] = val + + # TipSpot fields + if isinstance(resource, TipSpot): + prototype = resource.make_tip() + data.prototype_tip.CopyFrom(_tip_to_proto(prototype)) + + # Lid fields + if isinstance(resource, Lid): + data.nesting_z_height = resource.nesting_z_height + + # Trash fields + if isinstance(resource, Trash): + if resource._material_z_thickness is not None: + data.material_z_thickness = resource._material_z_thickness + if resource.max_volume is not None and resource.max_volume != float("inf"): + data.max_volume = resource.max_volume + + return data + + +def _resource_to_tree(resource: Resource) -> pb2.ResourceTree: + """Recursively convert a resource and its children to a ResourceTree.""" + tree = pb2.ResourceTree(data=_resource_to_data(resource)) + for child in resource.children: + tree.children.append(_resource_to_tree(child)) + return tree + + +# ============================================================ +# Service implementation +# ============================================================ + +class DeckServiceImpl(DeckService): + """ConnectRPC service that wraps a real Deck object.""" + + def __init__(self, deck: Deck): + self._deck = deck + + # --- Tree --- + + async def get_tree(self, request: pb2.GetTreeRequest, ctx: RequestContext) -> pb2.ResourceTree: + if request.root_name: + root = self._deck.get_resource(request.root_name) + else: + root = self._deck + return _resource_to_tree(root) + + async def get_resource(self, request: pb2.ResourceByNameRequest, ctx: RequestContext) -> pb2.ResourceData: + resource = self._deck.get_resource(request.name) + return _resource_to_data(resource) + + async def has_resource(self, request: pb2.ResourceByNameRequest, ctx: RequestContext) -> pb2.BoolResponse: + return pb2.BoolResponse(value=self._deck.has_resource(request.name)) + + async def get_trash_area(self, request: pb2.Empty, ctx: RequestContext) -> pb2.ResourceData: + trash = self._deck.get_trash_area() + return _resource_to_data(trash) + + async def get_trash_area96(self, request: pb2.Empty, ctx: RequestContext) -> pb2.ResourceData: + trash = self._deck.get_trash_area96() + return _resource_to_data(trash) + + # --- Spatial --- + + async def get_location_wrt(self, request: pb2.GetLocationWrtRequest, ctx: RequestContext) -> pb2.Coordinate: + resource = self._deck.get_resource(request.resource_name) + other = self._deck.get_resource(request.other_name) + coord = resource.get_location_wrt( + other, x=request.anchor_x, y=request.anchor_y, z=request.anchor_z) + return pb2.Coordinate(x=coord.x, y=coord.y, z=coord.z) + + async def get_absolute_location(self, request: pb2.GetAbsoluteLocationRequest, ctx: RequestContext) -> pb2.Coordinate: + resource = self._deck.get_resource(request.resource_name) + coord = resource.get_absolute_location( + x=request.anchor_x, y=request.anchor_y, z=request.anchor_z) + return pb2.Coordinate(x=coord.x, y=coord.y, z=coord.z) + + async def get_absolute_rotation(self, request: pb2.GetAbsoluteRotationRequest, ctx: RequestContext) -> pb2.Rotation: + resource = self._deck.get_resource(request.resource_name) + rot = resource.get_absolute_rotation() + return pb2.Rotation(x=rot.x, y=rot.y, z=rot.z) + + async def get_absolute_size(self, request: pb2.GetAbsoluteSizeRequest, ctx: RequestContext) -> pb2.Size: + resource = self._deck.get_resource(request.resource_name) + return pb2.Size( + x=resource.get_absolute_size_x(), + y=resource.get_absolute_size_y(), + z=resource.get_absolute_size_z(), + ) + + async def get_highest_point(self, request: pb2.GetHighestPointRequest, ctx: RequestContext) -> pb2.FloatResponse: + resource = self._deck.get_resource(request.resource_name) + return pb2.FloatResponse(value=resource.get_highest_known_point()) + + async def batch_get_location_wrt(self, request: pb2.BatchGetLocationWrtRequest, ctx: RequestContext) -> pb2.BatchCoordinateResponse: + coords = [] + for item in request.items: + resource = self._deck.get_resource(item.resource_name) + other = self._deck.get_resource(item.other_name) + coord = resource.get_location_wrt( + other, x=item.anchor_x, y=item.anchor_y, z=item.anchor_z) + coords.append(pb2.Coordinate(x=coord.x, y=coord.y, z=coord.z)) + return pb2.BatchCoordinateResponse(coordinates=coords) + + # --- Computed methods --- + + async def compute_volume_from_height(self, request: pb2.ComputeVolumeHeightRequest, ctx: RequestContext) -> pb2.FloatResponse: + resource = self._deck.get_resource(request.resource_name) + return pb2.FloatResponse(value=resource.compute_volume_from_height(request.value)) + + async def compute_height_from_volume(self, request: pb2.ComputeVolumeHeightRequest, ctx: RequestContext) -> pb2.FloatResponse: + resource = self._deck.get_resource(request.resource_name) + return pb2.FloatResponse(value=resource.compute_height_from_volume(request.value)) + + async def supports_compute_height_volume(self, request: pb2.ResourceByNameRequest, ctx: RequestContext) -> pb2.BoolResponse: + resource = self._deck.get_resource(request.name) + return pb2.BoolResponse(value=resource.supports_compute_height_volume_functions()) + + async def has_lid(self, request: pb2.HasLidRequest, ctx: RequestContext) -> pb2.BoolResponse: + plate = self._deck.get_resource(request.plate_name) + return pb2.BoolResponse(value=plate.has_lid()) + + # --- Tip access --- + + async def get_tip(self, request: pb2.GetTipRequest, ctx: RequestContext) -> pb2.TipData: + tip_spot = self._deck.get_resource(request.tip_spot_name) + tip = tip_spot.get_tip() + return _tip_to_proto(tip) + + # --- Volume tracker --- + + async def get_volume_tracker_state(self, request: pb2.ResourceByNameRequest, ctx: RequestContext) -> pb2.VolumeTrackerState: + resource = self._deck.get_resource(request.name) + tracker = resource.tracker + return pb2.VolumeTrackerState( + volume=tracker.volume, + pending_volume=tracker.pending_volume, + max_volume=tracker.max_volume, + is_disabled=tracker.is_disabled, + ) + + async def remove_liquid(self, request: pb2.TrackerOpRequest, ctx: RequestContext) -> pb2.Empty: + resource = self._deck.get_resource(request.resource_name) + resource.tracker.remove_liquid(request.volume) + return pb2.Empty() + + async def add_liquid(self, request: pb2.TrackerOpRequest, ctx: RequestContext) -> pb2.Empty: + resource = self._deck.get_resource(request.resource_name) + resource.tracker.add_liquid(request.volume) + return pb2.Empty() + + async def batch_remove_liquid(self, request: pb2.BatchTrackerOpRequest, ctx: RequestContext) -> pb2.Empty: + for op in request.ops: + resource = self._deck.get_resource(op.resource_name) + resource.tracker.remove_liquid(op.volume) + return pb2.Empty() + + async def batch_add_liquid(self, request: pb2.BatchTrackerOpRequest, ctx: RequestContext) -> pb2.Empty: + for op in request.ops: + resource = self._deck.get_resource(op.resource_name) + resource.tracker.add_liquid(op.volume) + return pb2.Empty() + + # --- Tip tracker --- + + async def get_tip_tracker_state(self, request: pb2.ResourceByNameRequest, ctx: RequestContext) -> pb2.TipTrackerState: + resource = self._deck.get_resource(request.name) + tracker = resource.tracker + state = pb2.TipTrackerState( + has_tip=tracker.has_tip, + is_disabled=tracker.is_disabled, + ) + if tracker.has_tip: + state.tip.CopyFrom(_tip_to_proto(tracker.get_tip())) + return state + + async def remove_tip(self, request: pb2.TipTrackerOpRequest, ctx: RequestContext) -> pb2.Empty: + resource = self._deck.get_resource(request.tip_spot_name) + resource.tracker.remove_tip() + return pb2.Empty() + + async def add_tip(self, request: pb2.TipTrackerOpRequest, ctx: RequestContext) -> pb2.Empty: + resource = self._deck.get_resource(request.tip_spot_name) + tip_spot = resource + tip_spot.tracker.add_tip(tip_spot.make_tip(), origin=tip_spot) + return pb2.Empty() + + # --- Commit / Rollback --- + + async def commit_volume_trackers(self, request: pb2.CommitRollbackRequest, ctx: RequestContext) -> pb2.Empty: + for name in request.resource_names: + resource = self._deck.get_resource(name) + resource.tracker.commit() + return pb2.Empty() + + async def rollback_volume_trackers(self, request: pb2.CommitRollbackRequest, ctx: RequestContext) -> pb2.Empty: + for name in request.resource_names: + resource = self._deck.get_resource(name) + resource.tracker.rollback() + return pb2.Empty() + + async def commit_tip_trackers(self, request: pb2.CommitRollbackRequest, ctx: RequestContext) -> pb2.Empty: + for name in request.resource_names: + resource = self._deck.get_resource(name) + resource.tracker.commit() + return pb2.Empty() + + async def rollback_tip_trackers(self, request: pb2.CommitRollbackRequest, ctx: RequestContext) -> pb2.Empty: + for name in request.resource_names: + resource = self._deck.get_resource(name) + resource.tracker.rollback() + return pb2.Empty() + + # --- Structure mutation --- + + async def assign_child(self, request: pb2.AssignChildRequest, ctx: RequestContext) -> pb2.Empty: + child = self._deck.get_resource(request.child_name) + parent = self._deck.get_resource(request.parent_name) + loc = Coordinate(request.location.x, request.location.y, request.location.z) + parent.assign_child_resource(child, location=loc) + return pb2.Empty() + + async def unassign_child(self, request: pb2.UnassignChildRequest, ctx: RequestContext) -> pb2.Empty: + resource = self._deck.get_resource(request.resource_name) + if resource.parent is not None: + resource.parent.unassign_child_resource(resource) + return pb2.Empty() + + +# ============================================================ +# ASGI app factory +# ============================================================ + +def create_app(deck: Deck) -> DeckServiceASGIApplication: + """Create an ASGI application for the given deck. + + Usage:: + + import uvicorn + from pylabrobot.resources import Deck + from pylabrobot.resources.remote.server import create_app + + deck = Deck.load_from_json_file("hamilton-layout.json") + app = create_app(deck) + uvicorn.run(app, host="0.0.0.0", port=8080) + """ + return DeckServiceASGIApplication(DeckServiceImpl(deck)) diff --git a/pylabrobot/resources/remote/tests/__init__.py b/pylabrobot/resources/remote/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/resources/remote/tests/remote_deck_tests.py b/pylabrobot/resources/remote/tests/remote_deck_tests.py new file mode 100644 index 00000000000..b650b9e8d8e --- /dev/null +++ b/pylabrobot/resources/remote/tests/remote_deck_tests.py @@ -0,0 +1,566 @@ +"""Tests for the remote deck system (server, client, proxies, trackers).""" + +import threading +import time +import unittest +from collections import OrderedDict + +import uvicorn + +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.container import Container +from pylabrobot.resources.deck import Deck +from pylabrobot.resources.hamilton.tip_creators import ( + HamiltonTip, + hamilton_tip_300uL, + hamilton_tip_1000uL_filter, +) +from pylabrobot.resources.plate import Lid, Plate +from pylabrobot.resources.resource import Resource +from pylabrobot.resources.rotation import Rotation +from pylabrobot.resources.tip import Tip +from pylabrobot.resources.tip_rack import TipRack, TipSpot +from pylabrobot.resources.tip_tracker import set_tip_tracking +from pylabrobot.resources.trash import Trash +from pylabrobot.resources.volume_tracker import set_volume_tracking +from pylabrobot.resources.well import Well + +from pylabrobot.resources.remote import deck_service_pb2 as pb2 +from pylabrobot.resources.remote.client import RemoteDeck +from pylabrobot.resources.remote.proxies import ( + ContainerProxy, + LidProxy, + PlateProxy, + ResourceProxy, + TipRackProxy, + TipSpotProxy, + TrashProxy, + WellProxy, + create_proxy, +) +from pylabrobot.resources.remote.remote_trackers import ( + RemoteTipTracker, + RemoteVolumeTracker, + _tip_from_proto, +) +from pylabrobot.resources.remote.server import ( + _resource_to_data, + _resource_to_tree, + _tip_to_proto, + create_app, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_deck() -> Deck: + """Build a small deck with a plate (8 wells), tip rack (8 spots), and trash.""" + deck = Deck(size_x=1000, size_y=500, size_z=200, name="test_deck") + + wells = OrderedDict() + for i in range(8): + key = f"A{i + 1}" + wells[key] = Well( + name=key, size_x=9.0, size_y=9.0, size_z=10.5, + bottom_type="flat", cross_section_type="circle", + max_volume=300.0, material_z_thickness=0.5, + ) + wells[key].location = Coordinate(x=i * 10, y=0, z=0) + plate = Plate( + name="plate_01", size_x=127.0, size_y=85.0, size_z=14.0, + ordered_items=wells, plate_type="skirted", + ) + deck.assign_child_resource(plate, location=Coordinate(100, 50, 0)) + + spots = OrderedDict() + for i in range(8): + key = f"A{i + 1}" + spots[key] = TipSpot( + name=key, size_x=9.0, size_y=9.0, + make_tip=hamilton_tip_300uL, size_z=0.0, + ) + spots[key].location = Coordinate(x=i * 10, y=0, z=0) + tip_rack = TipRack( + name="tip_rack_01", size_x=122.0, size_y=82.0, size_z=60.0, + ordered_items=spots, with_tips=True, + ) + deck.assign_child_resource(tip_rack, location=Coordinate(300, 50, 0)) + + trash = Trash(name="trash_01", size_x=100.0, size_y=100.0, size_z=50.0) + deck.assign_child_resource(trash, location=Coordinate(500, 50, 0)) + + return deck + + +class _ServerFixture: + """Spin up a uvicorn server in a background thread and tear it down after.""" + + def __init__(self, deck: Deck, port: int): + self.deck = deck + self.port = port + self._server: uvicorn.Server | None = None + self._thread: threading.Thread | None = None + + def start(self): + app = create_app(self.deck) + config = uvicorn.Config(app, host="127.0.0.1", port=self.port, log_level="error") + self._server = uvicorn.Server(config) + self._thread = threading.Thread(target=self._server.run, daemon=True) + self._thread.start() + time.sleep(0.8) + + def stop(self): + if self._server is not None: + self._server.should_exit = True + if self._thread is not None: + self._thread.join(timeout=3) + + @property + def url(self) -> str: + return f"http://127.0.0.1:{self.port}" + + +# --------------------------------------------------------------------------- +# Unit tests: serialization helpers +# --------------------------------------------------------------------------- + +class TestTipSerialization(unittest.TestCase): + """Test _tip_to_proto / _tip_from_proto round-trip.""" + + def test_plain_tip_round_trip(self): + tip = Tip(has_filter=False, total_tip_length=59.9, + maximal_volume=400.0, fitting_depth=8.0, name="t1") + proto = _tip_to_proto(tip) + self.assertEqual(proto.type, "Tip") + self.assertEqual(proto.name, "t1") + recovered = _tip_from_proto(proto) + self.assertIsInstance(recovered, Tip) + self.assertNotIsInstance(recovered, HamiltonTip) + self.assertEqual(recovered.maximal_volume, 400.0) + self.assertEqual(recovered.fitting_depth, 8.0) + + def test_hamilton_tip_round_trip(self): + tip = hamilton_tip_300uL(name="ht1") + proto = _tip_to_proto(tip) + self.assertEqual(proto.type, "HamiltonTip") + self.assertEqual(proto.tip_size, "STANDARD_VOLUME") + self.assertEqual(proto.pickup_method, "OUT_OF_RACK") + recovered = _tip_from_proto(proto) + self.assertIsInstance(recovered, HamiltonTip) + self.assertEqual(recovered.maximal_volume, tip.maximal_volume) + self.assertEqual(recovered.tip_size.name, "STANDARD_VOLUME") + + def test_hamilton_1000uL_filter_round_trip(self): + tip = hamilton_tip_1000uL_filter(name="ht2") + proto = _tip_to_proto(tip) + self.assertEqual(proto.tip_size, "HIGH_VOLUME") + recovered = _tip_from_proto(proto) + self.assertIsInstance(recovered, HamiltonTip) + self.assertEqual(recovered.has_filter, True) + + +class TestResourceToData(unittest.TestCase): + """Test _resource_to_data for each resource type.""" + + def test_well(self): + w = Well(name="w", size_x=9, size_y=9, size_z=10, + bottom_type="flat", cross_section_type="circle", + max_volume=300, material_z_thickness=0.5) + data = _resource_to_data(w) + self.assertEqual(data.type, "Well") + self.assertEqual(data.well_bottom_type, "flat") + self.assertEqual(data.cross_section_type, "circle") + self.assertAlmostEqual(data.max_volume, 300.0) + self.assertAlmostEqual(data.material_z_thickness, 0.5) + + def test_plate_ordering(self): + wells = OrderedDict() + for k in ("A1", "A2"): + wells[k] = Well(name=k, size_x=9, size_y=9, size_z=10) + wells[k].location = Coordinate(0, 0, 0) + plate = Plate(name="p", size_x=127, size_y=85, size_z=14, + ordered_items=wells, plate_type="non-skirted") + data = _resource_to_data(plate) + self.assertEqual(data.type, "Plate") + self.assertEqual(data.plate_type, "non-skirted") + self.assertIn("A1", data.ordering) + self.assertEqual(data.ordering["A1"], "p_A1") + + def test_tipspot_prototype(self): + ts = TipSpot(name="ts", size_x=9, size_y=9, make_tip=hamilton_tip_300uL) + data = _resource_to_data(ts) + self.assertEqual(data.type, "TipSpot") + self.assertEqual(data.prototype_tip.type, "HamiltonTip") + self.assertGreater(data.prototype_tip.maximal_volume, 0) + + def test_trash(self): + t = Trash(name="tr", size_x=100, size_y=100, size_z=50) + data = _resource_to_data(t) + self.assertEqual(data.type, "Trash") + + def test_lid(self): + lid = Lid(name="lid", size_x=127, size_y=85, size_z=10, nesting_z_height=5) + data = _resource_to_data(lid) + self.assertEqual(data.type, "Lid") + self.assertAlmostEqual(data.nesting_z_height, 5.0) + + +class TestResourceToTree(unittest.TestCase): + """Test recursive tree serialization.""" + + def test_deck_tree(self): + deck = _make_deck() + tree = _resource_to_tree(deck) + self.assertEqual(tree.data.name, "test_deck") + self.assertEqual(tree.data.type, "Deck") + child_names = [c.data.name for c in tree.children] + self.assertIn("plate_01", child_names) + self.assertIn("tip_rack_01", child_names) + self.assertIn("trash_01", child_names) + + def test_plate_children_in_tree(self): + deck = _make_deck() + tree = _resource_to_tree(deck) + plate_tree = [c for c in tree.children if c.data.name == "plate_01"][0] + self.assertEqual(len(plate_tree.children), 8) + self.assertTrue(all(c.data.type == "Well" for c in plate_tree.children)) + + +# --------------------------------------------------------------------------- +# Unit tests: proxy isinstance checks +# --------------------------------------------------------------------------- + +class TestProxyIsinstance(unittest.TestCase): + """Proxy objects must pass the isinstance checks that backends rely on.""" + + def _data(self, **kw) -> pb2.ResourceData: + defaults = dict(name="x", size_x=1, size_y=1, size_z=1) + defaults.update(kw) + return pb2.ResourceData(**defaults) + + def test_well_proxy(self): + proxy = create_proxy(None, self._data(type="Well", category="well")) + self.assertIsInstance(proxy, Well) + self.assertIsInstance(proxy, Container) + self.assertIsInstance(proxy, Resource) + + def test_plate_proxy(self): + proxy = create_proxy(None, self._data(type="Plate", ordering={"A1": "x_A1"})) + self.assertIsInstance(proxy, Plate) + self.assertIsInstance(proxy, Resource) + + def test_tipspot_proxy(self): + proto_tip = pb2.TipData(type="Tip", has_filter=False, + total_tip_length=50, maximal_volume=300, + fitting_depth=8, name="t") + proxy = create_proxy(None, self._data(type="TipSpot", prototype_tip=proto_tip)) + self.assertIsInstance(proxy, TipSpot) + self.assertIsInstance(proxy, Resource) + + def test_tiprack_proxy(self): + proxy = create_proxy(None, self._data(type="TipRack", ordering={"A1": "x_A1"})) + self.assertIsInstance(proxy, TipRack) + + def test_trash_proxy(self): + proxy = create_proxy(None, self._data(type="Trash")) + self.assertIsInstance(proxy, Trash) + self.assertIsInstance(proxy, Container) + + def test_lid_proxy(self): + proxy = create_proxy(None, self._data(type="Lid", nesting_z_height=5)) + self.assertIsInstance(proxy, Lid) + + def test_unknown_type_falls_back_to_resource(self): + proxy = create_proxy(None, self._data(type="SomeCarrier")) + self.assertIsInstance(proxy, Resource) + self.assertNotIsInstance(proxy, Plate) + + +# --------------------------------------------------------------------------- +# Integration tests: full server ↔ client round-trip +# --------------------------------------------------------------------------- + +_PORT = 18_123 # avoid collisions with common ports + + +class TestRemoteDeckConnection(unittest.TestCase): + """Connect to a server, verify the tree is built correctly.""" + + @classmethod + def setUpClass(cls): + set_volume_tracking(True) + set_tip_tracking(True) + cls.local_deck = _make_deck() + cls.fixture = _ServerFixture(cls.local_deck, _PORT) + cls.fixture.start() + cls.remote_deck = RemoteDeck.connect(cls.fixture.url) + + @classmethod + def tearDownClass(cls): + cls.fixture.stop() + + # -- tree structure -- + + def test_deck_name_and_size(self): + self.assertEqual(self.remote_deck.name, "test_deck") + self.assertAlmostEqual(self.remote_deck._size_x, 1000) + + def test_children_names(self): + names = sorted(c.name for c in self.remote_deck.children) + self.assertEqual(names, ["plate_01", "tip_rack_01", "trash_01"]) + + def test_all_resources_count(self): + # deck itself is not counted, but plate(1) + 8 wells + tip_rack(1) + 8 spots + trash(1) = 19 + self.assertEqual(len(self.remote_deck.get_all_resources()), 19) + + def test_get_resource_deep(self): + well = self.remote_deck.get_resource("plate_01_A1") + self.assertIsInstance(well, Well) + + def test_has_resource(self): + self.assertTrue(self.remote_deck.has_resource("plate_01")) + self.assertTrue(self.remote_deck.has_resource("tip_rack_01_A1")) + self.assertFalse(self.remote_deck.has_resource("nonexistent")) + + # -- isinstance -- + + def test_isinstance_plate(self): + self.assertIsInstance(self.remote_deck.get_resource("plate_01"), Plate) + + def test_isinstance_well(self): + self.assertIsInstance(self.remote_deck.get_resource("plate_01_A1"), Well) + + def test_isinstance_tiprack(self): + self.assertIsInstance(self.remote_deck.get_resource("tip_rack_01"), TipRack) + + def test_isinstance_tipspot(self): + self.assertIsInstance(self.remote_deck.get_resource("tip_rack_01_A1"), TipSpot) + + def test_isinstance_trash(self): + self.assertIsInstance(self.remote_deck.get_resource("trash_01"), Trash) + + # -- bracket notation -- + + def test_plate_getitem(self): + plate = self.remote_deck.get_resource("plate_01") + items = plate["A1:A4"] + self.assertEqual(len(items), 4) + self.assertTrue(all(isinstance(w, Well) for w in items)) + + def test_tiprack_getitem(self): + tr = self.remote_deck.get_resource("tip_rack_01") + items = tr["A1:A4"] + self.assertEqual(len(items), 4) + self.assertTrue(all(isinstance(t, TipSpot) for t in items)) + + +class TestRemoteSpatialRPCs(unittest.TestCase): + """Spatial method results must match local computation.""" + + @classmethod + def setUpClass(cls): + cls.local_deck = _make_deck() + cls.fixture = _ServerFixture(cls.local_deck, _PORT + 1) + cls.fixture.start() + cls.remote_deck = RemoteDeck.connect(cls.fixture.url) + + @classmethod + def tearDownClass(cls): + cls.fixture.stop() + + def _assert_coord_eq(self, a: Coordinate, b: Coordinate, places=3): + self.assertAlmostEqual(a.x, b.x, places=places) + self.assertAlmostEqual(a.y, b.y, places=places) + self.assertAlmostEqual(a.z, b.z, places=places) + + def test_get_absolute_location(self): + for name in ("plate_01_A1", "tip_rack_01_A1", "trash_01"): + local = self.local_deck.get_resource(name).get_absolute_location() + remote = self.remote_deck.get_resource(name).get_absolute_location() + self._assert_coord_eq(local, remote) + + def test_get_absolute_location_centered(self): + local = self.local_deck.get_resource("plate_01_A1").get_absolute_location("c", "c", "b") + remote = self.remote_deck.get_resource("plate_01_A1").get_absolute_location("c", "c", "b") + self._assert_coord_eq(local, remote) + + def test_get_location_wrt_deck(self): + local_well = self.local_deck.get_resource("plate_01_A1") + remote_well = self.remote_deck.get_resource("plate_01_A1") + local = local_well.get_location_wrt(self.local_deck, "c", "c", "b") + remote = remote_well.get_location_wrt(self.remote_deck, "c", "c", "b") + self._assert_coord_eq(local, remote) + + def test_get_absolute_rotation(self): + remote_well = self.remote_deck.get_resource("plate_01_A1") + rot = remote_well.get_absolute_rotation() + self.assertIsInstance(rot, Rotation) + self.assertAlmostEqual(rot.x, 0) + self.assertAlmostEqual(rot.y, 0) + self.assertAlmostEqual(rot.z, 0) + + def test_get_absolute_size(self): + local_well = self.local_deck.get_resource("plate_01_A1") + remote_well = self.remote_deck.get_resource("plate_01_A1") + self.assertAlmostEqual(local_well.get_absolute_size_x(), remote_well.get_absolute_size_x()) + self.assertAlmostEqual(local_well.get_absolute_size_y(), remote_well.get_absolute_size_y()) + self.assertAlmostEqual(local_well.get_absolute_size_z(), remote_well.get_absolute_size_z()) + + def test_get_highest_known_point(self): + local_val = self.local_deck.get_highest_known_point() + remote_val = self.remote_deck.get_highest_known_point() + self.assertAlmostEqual(local_val, remote_val) + + +class TestRemoteTrackers(unittest.TestCase): + """Volume and tip tracker RPCs.""" + + @classmethod + def setUpClass(cls): + set_volume_tracking(True) + set_tip_tracking(True) + cls.local_deck = _make_deck() + # Give the first well some initial volume + cls.local_deck.get_resource("plate_01_A1").tracker.set_volume(200.0) + cls.fixture = _ServerFixture(cls.local_deck, _PORT + 2) + cls.fixture.start() + cls.remote_deck = RemoteDeck.connect(cls.fixture.url) + + @classmethod + def tearDownClass(cls): + cls.fixture.stop() + + # -- volume tracker -- + + def test_volume_tracker_initial_state(self): + well = self.remote_deck.get_resource("plate_01_A1") + self.assertAlmostEqual(well.tracker.get_used_volume(), 200.0) + self.assertAlmostEqual(well.tracker.get_free_volume(), 100.0) + + def test_volume_tracker_is_disabled(self): + well = self.remote_deck.get_resource("plate_01_A1") + self.assertFalse(well.tracker.is_disabled) + + def test_volume_remove_and_commit(self): + well = self.remote_deck.get_resource("plate_01_A2") + # A2 starts at 0. Add 100, commit, then remove 40, commit. + self.local_deck.get_resource("plate_01_A2").tracker.set_volume(100.0) + self.assertAlmostEqual(well.tracker.get_used_volume(), 100.0) + + well.tracker.remove_liquid(40.0) + self.assertAlmostEqual(well.tracker.get_used_volume(), 60.0) + well.tracker.commit() + self.assertAlmostEqual(well.tracker.get_used_volume(), 60.0) + + def test_volume_add_and_rollback(self): + well = self.remote_deck.get_resource("plate_01_A3") + self.local_deck.get_resource("plate_01_A3").tracker.set_volume(50.0) + well.tracker.add_liquid(20.0) + self.assertAlmostEqual(well.tracker.get_used_volume(), 70.0) + well.tracker.rollback() + self.assertAlmostEqual(well.tracker.get_used_volume(), 50.0) + + # -- tip tracker -- + + def test_tip_tracker_has_tip(self): + ts = self.remote_deck.get_resource("tip_rack_01_A1") + self.assertTrue(ts.tracker.has_tip) + + def test_tip_tracker_is_disabled(self): + ts = self.remote_deck.get_resource("tip_rack_01_A1") + self.assertFalse(ts.tracker.is_disabled) + + def test_tip_remove_and_add(self): + ts = self.remote_deck.get_resource("tip_rack_01_A2") + self.assertTrue(ts.tracker.has_tip) + ts.tracker.remove_tip() + ts.tracker.commit() + self.assertFalse(ts.tracker.has_tip) + ts.tracker.add_tip() + # add_tip commits by default + self.assertTrue(ts.tracker.has_tip) + + def test_get_tip_returns_hamilton_tip(self): + ts = self.remote_deck.get_resource("tip_rack_01_A1") + tip = ts.get_tip() + self.assertIsInstance(tip, HamiltonTip) + self.assertAlmostEqual(tip.maximal_volume, 400.0) + self.assertFalse(tip.has_filter) + + +class TestRemotePlateFeatures(unittest.TestCase): + """Plate-specific RPCs (has_lid, etc.).""" + + @classmethod + def setUpClass(cls): + cls.local_deck = _make_deck() + cls.fixture = _ServerFixture(cls.local_deck, _PORT + 3) + cls.fixture.start() + cls.remote_deck = RemoteDeck.connect(cls.fixture.url) + + @classmethod + def tearDownClass(cls): + cls.fixture.stop() + + def test_has_lid_false(self): + plate = self.remote_deck.get_resource("plate_01") + self.assertFalse(plate.has_lid()) + + def test_has_lid_true_after_adding(self): + plate = self.remote_deck.get_resource("plate_01") + lid = Lid(name="test_lid", size_x=127, size_y=85, size_z=10, nesting_z_height=5) + self.local_deck.get_resource("plate_01").assign_child_resource(lid) + self.assertTrue(plate.has_lid()) + # Clean up + self.local_deck.get_resource("plate_01").unassign_child_resource(lid) + + def test_well_material_z_thickness_local(self): + well = self.remote_deck.get_resource("plate_01_A1") + self.assertAlmostEqual(well.material_z_thickness, 0.5) + + +# --------------------------------------------------------------------------- +# Integration test: full LiquidHandler cycle through remote deck +# --------------------------------------------------------------------------- + +class TestRemoteDeckWithLiquidHandler(unittest.IsolatedAsyncioTestCase): + """Run a full pick_up → aspirate → dispense → drop cycle through a RemoteDeck.""" + + @classmethod + def setUpClass(cls): + set_volume_tracking(False) + set_tip_tracking(False) + cls.local_deck = _make_deck() + cls.fixture = _ServerFixture(cls.local_deck, _PORT + 4) + cls.fixture.start() + + @classmethod + def tearDownClass(cls): + cls.fixture.stop() + + async def test_full_cycle(self): + try: + from pylabrobot.liquid_handling import LiquidHandler + from pylabrobot.liquid_handling.backends.chatterbox import LiquidHandlerChatterboxBackend + except ImportError: + self.skipTest("liquid_handling extras not installed") + + deck = RemoteDeck.connect(self.fixture.url) + lh = LiquidHandler(LiquidHandlerChatterboxBackend(num_channels=8), deck=deck) + await lh.setup() + + tips = deck.get_resource("tip_rack_01") + plate = deck.get_resource("plate_01") + + await lh.pick_up_tips(tips["A1"]) + await lh.aspirate(plate["A1"], vols=[50.0]) + await lh.dispense(plate["A2"], vols=[50.0]) + await lh.drop_tips(tips["A1"]) + + await lh.stop() + + +if __name__ == "__main__": + unittest.main() diff --git a/pyproject.toml b/pyproject.toml index ea5b46582a0..ec214bd310e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,9 +21,10 @@ opentrons = ["opentrons-http-api-client"] inheco = ["hid==1.0.8"] agrow = ["pymodbus==3.6.8"] sila = ["zeroconf>=0.131.0"] +remote = ["connect-python>=0.8.1", "uvicorn>=0.34.0"] gui = ["flask[async]==3.1.2"] dev = [ - "PyLabRobot[fw,http,plate_reading,websockets,visualizer,opentrons,inheco,agrow,gui,sila]", + "PyLabRobot[fw,http,plate_reading,websockets,visualizer,opentrons,inheco,agrow,gui,sila,remote]", "pytest==8.4.2", "pytest-timeout==2.4.0", "mypy==1.18.2", From 2bf14928ca9c167e064b2a9781c3180bfa1685c9 Mon Sep 17 00:00:00 2001 From: Keoni Gandall Date: Wed, 18 Feb 2026 13:35:45 -0800 Subject: [PATCH 2/2] added data --- .../resources/remote/deck_service.proto | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/pylabrobot/resources/remote/deck_service.proto b/pylabrobot/resources/remote/deck_service.proto index 352888bebf7..6b12e7c50b1 100644 --- a/pylabrobot/resources/remote/deck_service.proto +++ b/pylabrobot/resources/remote/deck_service.proto @@ -191,48 +191,86 @@ message HasLidRequest { service DeckService { // --- Tree --- + + // Return the full resource tree rooted at the given resource (or the deck if empty). rpc GetTree(GetTreeRequest) returns (ResourceTree); + // Return the flat data for a single resource by name. rpc GetResource(ResourceByNameRequest) returns (ResourceData); + // Check whether a resource with the given name exists on the deck. rpc HasResource(ResourceByNameRequest) returns (BoolResponse); + // Return the single-channel trash area resource. rpc GetTrashArea(Empty) returns (ResourceData); + // Return the 96-channel trash area resource. rpc GetTrashArea96(Empty) returns (ResourceData); // --- Spatial --- + + // Get the location of a resource relative to another resource, with anchor offsets. rpc GetLocationWrt(GetLocationWrtRequest) returns (Coordinate); + // Get the absolute (deck-space) location of a resource, with anchor offsets. rpc GetAbsoluteLocation(GetAbsoluteLocationRequest) returns (Coordinate); + // Get the cumulative rotation of a resource in deck-space. rpc GetAbsoluteRotation(GetAbsoluteRotationRequest) returns (Rotation); + // Get the rotation-aware bounding-box size of a resource. rpc GetAbsoluteSize(GetAbsoluteSizeRequest) returns (Size); + // Get the highest known z-coordinate point of a resource and its children. rpc GetHighestPoint(GetHighestPointRequest) returns (FloatResponse); + // Batch version of GetLocationWrt for multiple resource pairs at once. rpc BatchGetLocationWrt(BatchGetLocationWrtRequest) returns (BatchCoordinateResponse); // --- Computed methods --- + + // Compute the liquid volume in a well given a liquid height. rpc ComputeVolumeFromHeight(ComputeVolumeHeightRequest) returns (FloatResponse); + // Compute the liquid height in a well given a liquid volume. rpc ComputeHeightFromVolume(ComputeVolumeHeightRequest) returns (FloatResponse); + // Check whether a resource supports height/volume conversion functions. rpc SupportsComputeHeightVolume(ResourceByNameRequest) returns (BoolResponse); + // Check whether a plate currently has a lid attached. rpc HasLid(HasLidRequest) returns (BoolResponse); // --- Tip access --- + + // Get the tip currently sitting on a tip spot. rpc GetTip(GetTipRequest) returns (TipData); // --- Volume tracker --- + + // Get the current volume tracker state (volume, pending, max, disabled) for a container. rpc GetVolumeTrackerState(ResourceByNameRequest) returns (VolumeTrackerState); + // Record a pending liquid removal from a container's volume tracker. rpc RemoveLiquid(TrackerOpRequest) returns (Empty); + // Record a pending liquid addition to a container's volume tracker. rpc AddLiquid(TrackerOpRequest) returns (Empty); + // Batch version of RemoveLiquid for multiple containers at once. rpc BatchRemoveLiquid(BatchTrackerOpRequest) returns (Empty); + // Batch version of AddLiquid for multiple containers at once. rpc BatchAddLiquid(BatchTrackerOpRequest) returns (Empty); // --- Tip tracker --- + + // Get the current tip tracker state (has_tip, tip data, disabled) for a tip spot. rpc GetTipTrackerState(ResourceByNameRequest) returns (TipTrackerState); + // Remove the tip from a tip spot's tracker (e.g. after a pick-up). rpc RemoveTip(TipTrackerOpRequest) returns (Empty); + // Add a new tip to a tip spot's tracker (e.g. after a return). rpc AddTip(TipTrackerOpRequest) returns (Empty); // --- Commit / Rollback --- + + // Commit pending volume tracker changes for the given containers. rpc CommitVolumeTrackers(CommitRollbackRequest) returns (Empty); + // Roll back pending volume tracker changes for the given containers. rpc RollbackVolumeTrackers(CommitRollbackRequest) returns (Empty); + // Commit pending tip tracker changes for the given tip spots. rpc CommitTipTrackers(CommitRollbackRequest) returns (Empty); + // Roll back pending tip tracker changes for the given tip spots. rpc RollbackTipTrackers(CommitRollbackRequest) returns (Empty); // --- Structure mutation --- + + // Re-parent a child resource under a new parent at the given location. rpc AssignChild(AssignChildRequest) returns (Empty); + // Remove a resource from its current parent. rpc UnassignChild(UnassignChildRequest) returns (Empty); }