Skip to content

Commit bcfaa67

Browse files
feat(robot-server): Notify clients whenever labware offsets change (#17817)
## Overview Does the server half of EXEC-1342. Client half forthcoming in a separate PR. ## Changelog Publish a notification to a new topic, `/labwareOffsets`, whenever an offset is added or deleted. ## Test Plan and Hands on Testing * [x] Try it out with Postman. ## Risk assessment Low.
1 parent 79395e9 commit bcfaa67

File tree

6 files changed

+93
-8
lines changed

6 files changed

+93
-8
lines changed

robot-server/robot_server/labware_offsets/fastapi_dependencies.py

+8-1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
import sqlalchemy
1414

1515
from robot_server.persistence.fastapi_dependencies import get_sql_engine
16+
from robot_server.service.notifications.publishers.labware_offsets_publisher import (
17+
LabwareOffsetsPublisher,
18+
get_labware_offsets_publisher,
19+
)
1620
from .store import LabwareOffsetStore
1721

1822

@@ -24,10 +28,13 @@
2428
async def get_labware_offset_store(
2529
app_state: Annotated[AppState, Depends(get_app_state)],
2630
sql_engine: Annotated[sqlalchemy.engine.Engine, Depends(get_sql_engine)],
31+
labware_offsets_publisher: Annotated[
32+
LabwareOffsetsPublisher, Depends(get_labware_offsets_publisher)
33+
],
2734
) -> LabwareOffsetStore:
2835
"""Get the server's singleton LabwareOffsetStore."""
2936
labware_offset_store = _labware_offset_store_accessor.get_from(app_state)
3037
if labware_offset_store is None:
31-
labware_offset_store = LabwareOffsetStore(sql_engine)
38+
labware_offset_store = LabwareOffsetStore(sql_engine, labware_offsets_publisher)
3239
_labware_offset_store_accessor.set_on(app_state, labware_offset_store)
3340
return labware_offset_store

robot-server/robot_server/labware_offsets/store.py

+16-2
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@
1717
labware_offset_table,
1818
labware_offset_location_sequence_components_table,
1919
)
20+
from robot_server.service.notifications.publishers.labware_offsets_publisher import (
21+
LabwareOffsetsPublisher,
22+
)
2023
from .models import (
2124
ANY_LOCATION,
2225
AnyLocation,
@@ -49,14 +52,19 @@ class IncomingStoredLabwareOffset:
4952
class LabwareOffsetStore:
5053
"""A persistent store for labware offsets, to support the `/labwareOffsets` endpoints."""
5154

52-
def __init__(self, sql_engine: sqlalchemy.engine.Engine) -> None:
55+
def __init__(
56+
self,
57+
sql_engine: sqlalchemy.engine.Engine,
58+
labware_offsets_publisher: LabwareOffsetsPublisher | None,
59+
) -> None:
5360
"""Initialize the store.
5461
5562
Params:
5663
sql_engine: The SQL database to use as backing storage. Assumed to already
5764
have all the proper tables set up.
5865
"""
5966
self._sql_engine = sql_engine
67+
self._labware_offsets_publisher = labware_offsets_publisher
6068

6169
def add(
6270
self,
@@ -79,6 +87,7 @@ def add(
7987
),
8088
location_components_to_insert,
8189
)
90+
self._publish_change_notification()
8291

8392
def get_all(self) -> list[StoredLabwareOffset]:
8493
"""Return all offsets from oldest to newest.
@@ -123,7 +132,7 @@ def delete(self, offset_id: str) -> StoredLabwareOffset:
123132
.where(labware_offset_table.c.offset_id == offset_id)
124133
.values(active=False)
125134
)
126-
135+
self._publish_change_notification()
127136
return next(_collate_sql_to_pydantic(offset_rows))
128137

129138
def delete_all(self) -> None:
@@ -132,6 +141,11 @@ def delete_all(self) -> None:
132141
transaction.execute(
133142
sqlalchemy.update(labware_offset_table).values(active=False)
134143
)
144+
self._publish_change_notification()
145+
146+
def _publish_change_notification(self) -> None:
147+
if self._labware_offsets_publisher:
148+
self._labware_offsets_publisher.publish_labware_offsets()
135149

136150

137151
class LabwareOffsetNotFoundError(KeyError):
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from typing import Annotated
2+
import fastapi
3+
from robot_server.service.notifications import topics
4+
from robot_server.service.notifications.notification_client import (
5+
NotificationClient,
6+
get_notification_client,
7+
)
8+
9+
10+
class LabwareOffsetsPublisher:
11+
"""Publishes clientData topics."""
12+
13+
def __init__(self, client: NotificationClient) -> None:
14+
self._client = client
15+
16+
def publish_labware_offsets(self) -> None:
17+
"""Publish the equivalent of `GET /labwareOffsets` or `POST /labwareOffsets/searches`."""
18+
self._client.publish_advise_refetch(topics.LABWARE_OFFSETS)
19+
20+
21+
async def get_labware_offsets_publisher(
22+
notification_client: Annotated[
23+
NotificationClient, fastapi.Depends(get_notification_client)
24+
],
25+
) -> LabwareOffsetsPublisher:
26+
"""Return a ClientDataPublisher for use by FastAPI endpoints."""
27+
return LabwareOffsetsPublisher(notification_client)

robot-server/robot_server/service/notifications/topics.py

+1
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ def _is_valid_topic_name_level(level: str) -> bool:
3030
RUNS = TopicName(f"{_TOPIC_BASE}/runs")
3131
DECK_CONFIGURATION = TopicName(f"{_TOPIC_BASE}/deck_configuration")
3232
RUNS_PRE_SERIALIZED_COMMANDS = TopicName(f"{_TOPIC_BASE}/runs/pre_serialized_commands")
33+
LABWARE_OFFSETS = TopicName(f"{_TOPIC_BASE}/labwareOffsets")
3334

3435

3536
def client_data(key: str) -> TopicName:

robot-server/tests/labware_offsets/test_store.py

+39-3
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from datetime import datetime, timezone
44
from typing import Sequence
55

6+
from decoy import Decoy
67
import pytest
78
import sqlalchemy
89

@@ -32,12 +33,24 @@
3233
StoredLabwareOffsetLocationSequenceComponents,
3334
UnknownLabwareOffsetLocationSequenceComponent,
3435
)
36+
from robot_server.service.notifications.publishers.labware_offsets_publisher import (
37+
LabwareOffsetsPublisher,
38+
)
39+
40+
41+
@pytest.fixture
42+
def mock_labware_offsets_publisher(decoy: Decoy) -> LabwareOffsetsPublisher:
43+
"""Return a mock in the shape of a LabwareOffsetsPublisher."""
44+
return decoy.mock(cls=LabwareOffsetsPublisher)
3545

3646

3747
@pytest.fixture
38-
def subject(sql_engine: sqlalchemy.engine.Engine) -> LabwareOffsetStore:
48+
def subject(
49+
sql_engine: sqlalchemy.engine.Engine,
50+
mock_labware_offsets_publisher: LabwareOffsetsPublisher,
51+
) -> LabwareOffsetStore:
3952
"""Return a test subject."""
40-
return LabwareOffsetStore(sql_engine)
53+
return LabwareOffsetStore(sql_engine, mock_labware_offsets_publisher)
4154

4255

4356
def test_empty_search(subject: LabwareOffsetStore) -> None:
@@ -260,7 +273,7 @@ def test_filter_fields(
260273
)
261274
]
262275
)
263-
assert sorted(results, key=lambda o: o.id,) == sorted(
276+
assert sorted(results, key=lambda o: o.id) == sorted(
264277
[
265278
StoredLabwareOffset(
266279
id=offsets[id_].id,
@@ -473,3 +486,26 @@ def test_handle_unknown(
473486
)
474487
)
475488
assert subject.search([SearchFilter(id="id-a")]) == [outgoing_offset]
489+
490+
491+
def test_notifications(
492+
subject: LabwareOffsetStore,
493+
decoy: Decoy,
494+
mock_labware_offsets_publisher: LabwareOffsetsPublisher,
495+
) -> None:
496+
"""It should publish notifications any time the set of labware offsets changes."""
497+
decoy.verify(mock_labware_offsets_publisher.publish_labware_offsets(), times=0)
498+
subject.add(
499+
IncomingStoredLabwareOffset(
500+
id="id",
501+
createdAt=datetime.now(timezone.utc),
502+
definitionUri="definitionUri",
503+
locationSequence=ANY_LOCATION,
504+
vector=LabwareOffsetVector(x=1, y=2, z=3),
505+
)
506+
)
507+
decoy.verify(mock_labware_offsets_publisher.publish_labware_offsets(), times=1)
508+
subject.delete("id")
509+
decoy.verify(mock_labware_offsets_publisher.publish_labware_offsets(), times=2)
510+
subject.delete_all()
511+
decoy.verify(mock_labware_offsets_publisher.publish_labware_offsets(), times=3)

robot-server/tests/labware_offsets/test_store_hypothesis.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ def test_round_trip(
121121
)
122122

123123
with TemporaryDirectory() as tmp_dir, make_sql_engine(Path(tmp_dir)) as sql_engine:
124-
subject = LabwareOffsetStore(sql_engine)
124+
subject = LabwareOffsetStore(sql_engine, labware_offsets_publisher=None)
125125
subject.add(offset_to_add)
126126
[offset_retrieved_by_get_all] = subject.get_all()
127127
[offset_retrieved_by_search] = subject.search([SearchFilter(id=id)])
@@ -228,7 +228,7 @@ def __init__(self) -> None:
228228
self._exit_stack = ExitStack()
229229
temp_dir = Path(self._exit_stack.enter_context(TemporaryDirectory()))
230230
sql_engine = self._exit_stack.enter_context(make_sql_engine(temp_dir))
231-
self._subject = LabwareOffsetStore(sql_engine)
231+
self._subject = LabwareOffsetStore(sql_engine, labware_offsets_publisher=None)
232232
self._simulated_model = SimulatedStore()
233233
self._added_ids = set[str]()
234234

0 commit comments

Comments
 (0)