From 7e29dc323d8db49436b58a90766959691b2c2314 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Tue, 1 Sep 2026 14:40:53 +0100 Subject: [PATCH] `Resource`: notify subscribers when a resource is placed or moved `location` was a plain attribute, so a resource could be assigned to a deck, moved to another carrier, or handed between machines without anything reaching the state channel. Position is set by assignment, including inside `assign_child_resource`, so there is no method to hang the notification on and it is caught at the attribute. The setter notifies only when the value actually changed and the resource has a parent, so building a resource and holding one outside a tree stay silent. `serialize_state` carries "location" alongside "rotation", and `load_state` reads it back. Co-authored-by: Claude Opus 5 (1M context) --- pylabrobot/resources/resource.py | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/pylabrobot/resources/resource.py b/pylabrobot/resources/resource.py index 9eecbc40a76..d3b6d9a3fda 100644 --- a/pylabrobot/resources/resource.py +++ b/pylabrobot/resources/resource.py @@ -178,7 +178,7 @@ def __init__( # need full isolation of mutable metadata values. self.metadata: Dict[str, Any] = dict(metadata) if metadata is not None else {} - self.location: Optional[Coordinate] = None + self._location: Optional[Coordinate] = None self.parent: Optional[Resource] = None self.children: List[Resource] = [] @@ -802,6 +802,23 @@ def find_resource( ) return results[0] if results else None + @property + def location(self) -> Optional[Coordinate]: + """Where this resource sits, relative to its parent.""" + return self._location + + @location.setter + def location(self, location: Optional[Coordinate]) -> None: + """Record a new position, and notify subscribers. + + Silent when the position does not change, and while the resource is outside a tree, where + there is nobody to tell. + """ + changed = location != self._location + self._location = location + if changed and self.parent is not None: + self._state_updated() + def rotate(self, x: float = 0, y: float = 0, z: float = 0): """Rotate counter-clockwise by the given number of degrees.""" @@ -1062,12 +1079,15 @@ def serialize_state(self) -> Dict[str, Any]: Use :meth:`pylabrobot.resources.resource.Resource.serialize_all_state` to serialize the state of this resource and all children. - The base implementation includes ``"rotation"`` so that subscribers - (e.g. the Visualizer) are notified of orientation changes through the + The base implementation includes ``"rotation"`` and ``"location"`` so that subscribers + (e.g. the Visualizer) are notified of orientation and position changes through the standard state channel. Subclasses overriding this method should merge in ``super().serialize_state()``. """ - return {"rotation": self.rotation.serialize()} + state: Dict[str, Any] = {"rotation": self.rotation.serialize()} + if self._location is not None: + state["location"] = self._location.serialize() + return state # Developer note: you probably don't need to override this method. Instead, override # `serialize_state`. @@ -1092,11 +1112,13 @@ def serialize_all_state(self) -> Dict[str, Dict[str, Any]]: def load_state(self, state: Dict[str, Any]) -> None: """Load state for this resource only. - The base implementation reads ``"rotation"`` if present. Subclasses + The base implementation reads ``"rotation"`` and ``"location"`` if present. Subclasses overriding this method should call ``super().load_state(state)``. """ if "rotation" in state: self.rotation = deserialize(state["rotation"]) + if "location" in state: + self.location = deserialize(state["location"]) # Developer note: you probably don't need to override this method. Instead, override `load_state`. def load_all_state(self, state: Dict[str, Dict[str, Any]]) -> None: