Skip to content

Commit b1d5439

Browse files
committed
Add depth parameter to get_devices
1 parent 8322701 commit b1d5439

File tree

15 files changed

+301
-39
lines changed

15 files changed

+301
-39
lines changed

docs/reference/openapi.yaml

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ components:
44
additionalProperties: false
55
description: Representation of a device
66
properties:
7+
address:
8+
title: Address
9+
type: string
710
name:
811
description: Name of the device
912
title: Name
@@ -17,6 +20,7 @@ components:
1720
required:
1821
- name
1922
- protocols
23+
- address
2024
title: DeviceModel
2125
type: object
2226
DeviceResponse:
@@ -340,7 +344,7 @@ components:
340344
type: object
341345
info:
342346
title: BlueAPI Control
343-
version: 0.0.10
347+
version: 0.0.11
344348
openapi: 3.1.0
345349
paths:
346350
/config/oidc:
@@ -361,13 +365,29 @@ paths:
361365
get:
362366
description: Retrieve information about all available devices.
363367
operationId: get_devices_devices_get
368+
parameters:
369+
- description: Maximum depth of children to return, -1 for all
370+
in: query
371+
name: max_depth
372+
required: false
373+
schema:
374+
default: 0
375+
minimum: 0
376+
title: Max Depth
377+
type: integer
364378
responses:
365379
'200':
366380
content:
367381
application/json:
368382
schema:
369383
$ref: '#/components/schemas/DeviceResponse'
370384
description: Successful Response
385+
'422':
386+
content:
387+
application/json:
388+
schema:
389+
$ref: '#/components/schemas/HTTPValidationError'
390+
description: Validation Error
371391
summary: Get Devices
372392
/devices/{name}:
373393
get:

src/blueapi/cli/cli.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,11 +164,19 @@ def get_plans(obj: dict) -> None:
164164

165165
@controller.command(name="devices")
166166
@check_connection
167+
@click.option(
168+
"-d",
169+
"--max_depth",
170+
type=click.IntRange(-1),
171+
required=False,
172+
help="Maximum depth of children to return: -1 for all",
173+
default=0,
174+
)
167175
@click.pass_obj
168-
def get_devices(obj: dict) -> None:
176+
def get_devices(obj: dict, max_depth: int) -> None:
169177
"""Get a list of devices available for the worker to use"""
170178
client: BlueapiClient = obj["client"]
171-
obj["fmt"].display(client.get_devices())
179+
obj["fmt"].display(client.get_devices(max_depth))
172180

173181

174182
@controller.command(name="listen")

src/blueapi/cli/format.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from blueapi.core.bluesky_types import DataEvent
1414
from blueapi.service.model import (
15+
DeviceModel,
1516
DeviceResponse,
1617
PlanResponse,
1718
PythonEnvironmentResponse,
@@ -62,7 +63,7 @@ def display_full(obj: Any, stream: Stream):
6263
print(indent(json.dumps(schema, indent=2), " "))
6364
case DeviceResponse(devices=devices):
6465
for dev in devices:
65-
print(dev.name)
66+
print(_format_name(dev))
6667
for proto in dev.protocols:
6768
print(f" {proto}")
6869
case DataEvent(name=name, doc=doc):
@@ -124,7 +125,7 @@ def display_compact(obj: Any, stream: Stream):
124125
print(f" {arg}={_describe_type(spec, req)}")
125126
case DeviceResponse(devices=devices):
126127
for dev in devices:
127-
print(dev.name)
128+
print(_format_name(dev))
128129
print(
129130
indent(
130131
textwrap.fill(
@@ -164,6 +165,12 @@ def display_compact(obj: Any, stream: Stream):
164165
FALLBACK(other, stream=stream)
165166

166167

168+
def _format_name(device: DeviceModel) -> str:
169+
if not device.address or device.address == device.name:
170+
return device.name
171+
return f"{device.name} @ {device.address}"
172+
173+
167174
def _describe_type(spec: dict[Any, Any], required: bool = False):
168175
disp = ""
169176
match spec.get("type"):

src/blueapi/client/client.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,16 +90,16 @@ def get_plan(self, name: str) -> PlanModel:
9090
"""
9191
return self._rest.get_plan(name)
9292

93-
@start_as_current_span(TRACER)
94-
def get_devices(self) -> DeviceResponse:
93+
@start_as_current_span(TRACER, "max_depth")
94+
def get_devices(self, max_depth: int) -> DeviceResponse:
9595
"""
9696
List devices available
9797
9898
Returns:
9999
DeviceResponse: Devices that can be used in plans
100100
"""
101101

102-
return self._rest.get_devices()
102+
return self._rest.get_devices(max_depth)
103103

104104
@start_as_current_span(TRACER, "name")
105105
def get_device(self, name: str) -> DeviceModel:

src/blueapi/client/rest.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,10 @@ def get_plans(self) -> PlanResponse:
7171
def get_plan(self, name: str) -> PlanModel:
7272
return self._request_and_deserialize(f"/plans/{name}", PlanModel)
7373

74-
def get_devices(self) -> DeviceResponse:
75-
return self._request_and_deserialize("/devices", DeviceResponse)
74+
def get_devices(self, max_depth: int) -> DeviceResponse:
75+
return self._request_and_deserialize(
76+
"/devices", DeviceResponse, params={"max_depth": max_depth}
77+
)
7678

7779
def get_device(self, name: str) -> DeviceModel:
7880
return self._request_and_deserialize(f"/devices/{name}", DeviceModel)

src/blueapi/core/device_lookup.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from typing import Any
22

3+
from ophyd_async.core import DeviceVector
4+
35
from .bluesky_types import Device, is_bluesky_compatible_device
46

57

@@ -28,6 +30,8 @@ def find_component(obj: Any, addr: list[str]) -> Device | None:
2830
# Otherwise, we error.
2931
if isinstance(obj, dict):
3032
component = obj.get(head)
33+
elif isinstance(obj, DeviceVector):
34+
component = obj.get(int(head))
3135
elif is_bluesky_compatible_device(obj):
3236
component = getattr(obj, head, None)
3337
else:

src/blueapi/service/interface.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,9 +177,14 @@ def get_plan(name: str) -> PlanModel:
177177
return PlanModel.from_plan(context().plans[name])
178178

179179

180-
def get_devices() -> list[DeviceModel]:
180+
def get_devices(max_depth: int) -> list[DeviceModel]:
181181
"""Get all available devices in the BlueskyContext"""
182-
return [DeviceModel.from_device(device) for device in context().devices.values()]
182+
return [
183+
model
184+
for device in context().devices.values()
185+
for model in DeviceModel.from_device_tree(device, max_depth)
186+
if model.protocols
187+
]
183188

184189

185190
def get_device(name: str) -> DeviceModel:

src/blueapi/service/main.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
Depends,
1212
FastAPI,
1313
HTTPException,
14+
Query,
1415
Request,
1516
Response,
1617
status,
@@ -54,7 +55,7 @@
5455
from .runner import WorkerDispatcher
5556

5657
#: API version to publish in OpenAPI schema
57-
REST_API_VERSION = "0.0.10"
58+
REST_API_VERSION = "0.0.11"
5859

5960
RUNNER: WorkerDispatcher | None = None
6061

@@ -231,9 +232,18 @@ def get_plan_by_name(
231232
@start_as_current_span(TRACER)
232233
def get_devices(
233234
runner: Annotated[WorkerDispatcher, Depends(_runner)],
235+
max_depth: Annotated[
236+
int,
237+
Query(
238+
description="Maximum depth of children to return, -1 for all",
239+
ge=0,
240+
# https://github.com/fastapi/fastapi/discussions/13473
241+
json_schema_extra={"description": None},
242+
),
243+
] = 0,
234244
) -> DeviceResponse:
235245
"""Retrieve information about all available devices."""
236-
devices = runner.run(interface.get_devices)
246+
devices = runner.run(interface.get_devices, max_depth)
237247
return DeviceResponse(devices=devices)
238248

239249

src/blueapi/service/model.py

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
from typing import Annotated, Any
55

66
from bluesky.protocols import HasName
7+
from ophyd import Device as SyncDevice
8+
from ophyd_async.core import Device as AsyncDevice
79
from pydantic import Field
810
from pydantic.json_schema import SkipJsonSchema
911

@@ -34,16 +36,69 @@ class DeviceModel(BlueapiBaseModel):
3436
protocols: list[ProtocolInfo] = Field(
3537
description="Protocols that a device conforms to, indicating its capabilities"
3638
)
39+
address: str
3740

3841
@classmethod
3942
def from_device(cls, device: Device) -> "DeviceModel":
4043
name = device.name if isinstance(device, HasName) else _UNKNOWN_NAME
41-
return cls(name=name, protocols=list(_protocol_info(device)))
44+
return cls(name=name, protocols=list(_protocol_info(device)), address=name)
45+
46+
@classmethod
47+
def from_device_tree(cls, root: Device, max_depth: int) -> list["DeviceModel"]:
48+
if isinstance(root, AsyncDevice):
49+
return [
50+
DeviceModel(
51+
name=device.name,
52+
protocols=list(_protocol_info(device)),
53+
address=address,
54+
)
55+
for address, device in _from_async_device(
56+
root, max_depth=max_depth
57+
).items()
58+
]
59+
if isinstance(root, SyncDevice):
60+
return [
61+
DeviceModel(
62+
name=device.name,
63+
protocols=list(_protocol_info(device)),
64+
address=address,
65+
)
66+
for address, device in _from_sync_device(
67+
root, max_depth=max_depth
68+
).items()
69+
]
70+
return [DeviceModel.from_device(root)]
71+
72+
73+
def _from_async_device(root: AsyncDevice, max_depth: int) -> dict[str, AsyncDevice]:
74+
depth = 0
75+
devices: dict[str, AsyncDevice] = {root.name: root}
76+
branches: dict[str, AsyncDevice] = {root.name: root}
77+
while branches and (max_depth == -1 or depth < max_depth):
78+
leaves: dict[str, AsyncDevice] = {}
79+
for addr, parent in branches.items():
80+
for suffix, child in parent.children():
81+
leaves[f"{addr}.{suffix}"] = child
82+
devices.update(leaves)
83+
branches = leaves
84+
depth += 1
85+
return devices
86+
87+
88+
def _from_sync_device(root: SyncDevice, max_depth: int) -> dict[str, SyncDevice]:
89+
return {
90+
root.name: root,
91+
**{
92+
k.dotted_name: k.item
93+
for k in root.walk_signals()
94+
if max_depth == -1 or len(k.ancestors) <= max_depth
95+
},
96+
}
4297

4398

4499
def _protocol_info(device: Device) -> Iterable[ProtocolInfo]:
45100
for protocol in BLUESKY_PROTOCOLS:
46-
if isinstance(device, protocol):
101+
if isinstance(device, protocol) and protocol is not AsyncDevice:
47102
yield ProtocolInfo(
48103
name=protocol.__name__,
49104
types=[arg.__name__ for arg in generic_bounds(device, protocol)],

tests/system_tests/test_blueapi_system.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ def test_get_non_existent_plan(client: BlueapiClient):
162162

163163

164164
def test_get_devices(client: BlueapiClient, expected_devices: DeviceResponse):
165-
retrieved_devices = client.get_devices()
165+
retrieved_devices = client.get_devices(max_depth=0)
166166
retrieved_devices.devices.sort(key=lambda x: x.name)
167167
expected_devices.devices.sort(key=lambda x: x.name)
168168

tests/unit_tests/client/test_client.py

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,26 @@
3535
PLAN = PlanModel(name="foo")
3636
DEVICES = DeviceResponse(
3737
devices=[
38-
DeviceModel(name="foo", protocols=[]),
39-
DeviceModel(name="bar", protocols=[]),
38+
DeviceModel(name="foo", protocols=[], address="foo"),
39+
DeviceModel(name="bar", protocols=[], address="bar"),
4040
]
4141
)
42-
DEVICE = DeviceModel(name="foo", protocols=[])
42+
DEVICES_AND_CHILDREN = DeviceResponse(
43+
devices=[
44+
DeviceModel(name="foo", protocols=[], address="foo"),
45+
DeviceModel(name="bar", protocols=[], address="bar"),
46+
DeviceModel(name="foo-bar", protocols=[], address="foo.bar"),
47+
]
48+
)
49+
DEVICES_AND_ALL_DESCENDENTS = DeviceResponse(
50+
devices=[
51+
DeviceModel(name="foo", protocols=[], address="foo"),
52+
DeviceModel(name="bar", protocols=[], address="bar"),
53+
DeviceModel(name="foo-bar", protocols=[], address="foo.bar"),
54+
DeviceModel(name="foo-bar-baz", protocols=[], address="foo.bar.baz"),
55+
]
56+
)
57+
DEVICE = DeviceModel(name="foo", protocols=[], address="foo")
4358
TASK = TrackableTask(task_id="foo", task=Task(name="bar", params={}))
4459
TASKS = TasksListResponse(tasks=[TASK])
4560
ACTIVE_TASK = WorkerTask(task_id="bar")
@@ -67,11 +82,18 @@
6782

6883
@pytest.fixture
6984
def mock_rest() -> BlueapiRestClient:
85+
def get_devices(max_depth: int) -> DeviceResponse:
86+
if max_depth == -1 or max_depth > 1:
87+
return DEVICES_AND_ALL_DESCENDENTS
88+
if max_depth == 1:
89+
return DEVICES_AND_CHILDREN
90+
return DEVICES
91+
7092
mock = Mock(spec=BlueapiRestClient)
7193

7294
mock.get_plans.return_value = PLANS
7395
mock.get_plan.return_value = PLAN
74-
mock.get_devices.return_value = DEVICES
96+
mock.get_devices.side_effect = get_devices
7597
mock.get_device.return_value = DEVICE
7698
mock.get_state.return_value = WorkerState.IDLE
7799
mock.get_task.return_value = TASK
@@ -121,7 +143,7 @@ def test_get_nonexistant_plan(
121143

122144

123145
def test_get_devices(client: BlueapiClient):
124-
assert client.get_devices() == DEVICES
146+
assert client.get_devices(max_depth=0) == DEVICES
125147

126148

127149
def test_get_device(client: BlueapiClient):
@@ -511,7 +533,7 @@ def test_get_plan_span_ok(exporter: JsonObjectSpanExporter, client: BlueapiClien
511533

512534
def test_get_devices_span_ok(exporter: JsonObjectSpanExporter, client: BlueapiClient):
513535
with asserting_span_exporter(exporter, "get_devices"):
514-
client.get_devices()
536+
client.get_devices(max_depth=0)
515537

516538

517539
def test_get_device_span_ok(exporter: JsonObjectSpanExporter, client: BlueapiClient):

0 commit comments

Comments
 (0)