Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
773a8c1
feat: explore pvi structure before building devices
shihab-dls Feb 9, 2026
2317b5f
tests: remove references to redundant helper function
shihab-dls Feb 10, 2026
7776da5
chore: add return type signature to added classes
shihab-dls Feb 10, 2026
1727010
fix: remove hardcoded timeout value on pvget of pvitree
shihab-dls Feb 10, 2026
0d6f5fa
tests: update panda.db to use new vector pvi structure and amend rege…
shihab-dls Feb 11, 2026
fda562a
docs: expose PviTree and SignalDetails for docs generation
shihab-dls Feb 11, 2026
91bad8b
Merge branch 'main' into pvi_structure
shihab-dls Feb 11, 2026
04002d4
chore: amend comments in panda.db
shihab-dls Feb 11, 2026
c65e603
chore: add inline comments and expose Entry
shihab-dls Feb 11, 2026
4a5a1c1
chore: remove Entry and explicitly type
shihab-dls Feb 11, 2026
150cef9
refactor: remove pvi_node parameter from PviTree
shihab-dls Feb 12, 2026
81c49c7
chore: remove SignalDetails.from_entry from gather_dict
shihab-dls Feb 12, 2026
e284882
chore: remove trailing comma and pop from entries
shihab-dls Feb 12, 2026
772e48e
chore: remove unused name parameter
shihab-dls Feb 16, 2026
77187f3
feat: support legacy fastcs vector entry
shihab-dls Feb 18, 2026
cdf18a1
chore[docs]: add comment explaining mixed pvi structure use in panda.db
shihab-dls Feb 18, 2026
e1ba902
feat: amend legacy entry format supported, and support vectors of sig…
shihab-dls Feb 19, 2026
345e706
Merge branch 'main' into pvi_structure
shihab-dls Feb 19, 2026
7ef99a7
chore[docs]: add docstrings to PviTree methods
shihab-dls Feb 20, 2026
746f9ab
feat: add support for type hinting signals on DeviceVectors
shihab-dls Feb 23, 2026
d138b37
fix: amend str dunder to allow vector children of type SignalDetails …
shihab-dls Feb 23, 2026
bdf9f8c
Merge branch 'main' into pvi_structure
shihab-dls Feb 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/ophyd_async/epics/core/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from ._epics_connector import EpicsDeviceConnector, PvSuffix
from ._epics_device import EpicsDevice
from ._pvi_connector import PviDeviceConnector
from ._pvi_connector import Entry, PviDeviceConnector, PviTree, SignalDetails
from ._signal import (
CaSignalBackend,
PvaSignalBackend,
Expand All @@ -14,6 +14,9 @@

__all__ = [
"PviDeviceConnector",
"PviTree",
"SignalDetails",
"Entry",
"EpicsDeviceConnector",
"PvSuffix",
"EpicsDevice",
Expand Down
297 changes: 220 additions & 77 deletions src/ophyd_async/epics/core/_pvi_connector.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
from __future__ import annotations

from typing import Literal, cast
import asyncio
import re

from pydantic import Field

from ophyd_async.core import (
ConfinedModel,
Device,
DeviceConnector,
DeviceFiller,
Expand All @@ -12,56 +16,16 @@
SignalRW,
SignalW,
SignalX,
gather_dict,
)

from ._epics_connector import fill_backend_with_prefix
from ._signal import PvaSignalBackend, pvget_with_timeout

# A PVI entry
# e.g., {"d": "Prefix:Device:PVI", "rw": "Prefix:A"}
Entry = dict[str, str]

OldPVIVector = list[Entry | None]
# The older PVI structure has vectors of the form
# structure[] ttlout
# (none)
# structure
# string d PANDABLOCKS_IOC:TTLOUT1:PVI
# structure
# string d PANDABLOCKS_IOC:TTLOUT2:PVI
# structure
# string d PANDABLOCKS_IOC:TTLOUT3:PVI


FastCSPVIVector = dict[Literal["d"], Entry]
# The newer pva FastCS PVI structure has vectors of the form
# structure ttlout
# structure d
# string v1 FASTCS_PANDA:Ttlout1:PVI
# string v2 FASTCS_PANDA:Ttlout2:PVI
# string v3 FASTCS_PANDA:Ttlout3:PVI
# string v4 FASTCS_PANDA:Ttlout4:PVI
Comment on lines -34 to -41
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It occurs to me that we could relatively easily still support this structure for backwards compatibility, which would make it easier for people to upgrade...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've pushed changes to support this, and amended panda.db to have a block that uses this legacy structure. Do you see a point in supporting:

# The older PVI structure has vectors of the form
# structure[] ttlout
#     (none)
#     structure
#         string d PANDABLOCKS_IOC:TTLOUT1:PVI
#     structure
#         string d PANDABLOCKS_IOC:TTLOUT2:PVI
#     structure
#         string d PANDABLOCKS_IOC:TTLOUT3:PVI

as well?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope, let's drop the old old style...



def _get_signal_details(entry: Entry) -> tuple[type[Signal], str, str]:
match entry:
case {"r": read_pv, "w": write_pv}:
return SignalRW, read_pv, write_pv
case {"r": read_pv}:
return SignalR, read_pv, read_pv
case {"w": write_pv}:
return SignalW, write_pv, write_pv
case {"rw": read_write_pv}:
return SignalRW, read_write_pv, read_write_pv
case {"x": execute_pv}:
return SignalX, execute_pv, execute_pv
case _:
raise TypeError(f"Can't process entry {entry}")


def _is_device_vector_entry(entry: Entry | OldPVIVector | FastCSPVIVector) -> bool:
return isinstance(entry, list) or (
entry.keys() == {"d"} and isinstance(entry["d"], dict)
)


class PviDeviceConnector(DeviceConnector):
"""Connect to PVI structure served over PVA.
Expand All @@ -78,6 +42,7 @@
"""

mock_device_vector_len: int = 2
pvi_tree: PviTree | None = None

def __init__(self, prefix: str = "", error_hint: str = "") -> None:
# TODO: what happens if we get a leading "pva://" here?
Expand All @@ -101,52 +66,230 @@
fill_backend_with_prefix(self.prefix, backend, annotations)
self.filler.check_created()

def _fill_child(self, name: str, entry: Entry, vector_index: int | None = None):
if set(entry) == {"d"}:
connector = self.filler.fill_child_device(name, vector_index=vector_index)
connector.pvi_pv = entry["d"]
else:
signal_type, read_pv, write_pv = _get_signal_details(entry)
backend = self.filler.fill_child_signal(name, signal_type, vector_index)
backend.read_pv = read_pv
backend.write_pv = write_pv

async def connect_mock(self, device: Device, mock: LazyMock):
self.filler.create_device_vector_entries_to_mock(self.mock_device_vector_len)
# Set the name of the device to name all children
device.set_name(device.name)
return await super().connect_mock(device, mock)

def _fill_vector_child(self, name: str, entry: OldPVIVector | FastCSPVIVector):
if isinstance(entry, list):
for i, e in enumerate(entry):
if e:
self._fill_child(name, e, i)
else:
for i_string, e in entry["d"].items():
self._fill_child(name, {"d": e}, int(i_string.lstrip("v")))

async def connect_real(
self, device: Device, timeout: float, force_reconnect: bool
) -> None:
pvi_structure = await pvget_with_timeout(self.pvi_pv, timeout)

entries: dict[str, Entry | OldPVIVector | FastCSPVIVector] = pvi_structure[
"value"
].todict()
# Fill based on what PVI gives us
for name, entry in entries.items():
if _is_device_vector_entry(entry):
self._fill_vector_child(
name, cast(OldPVIVector | FastCSPVIVector, entry)
)
if not self.pvi_tree:
# Top-level device, so discover PVI tree
self.pvi_tree = await PviTree.build_device_tree(
name=device.name, pvi_pv=self.pvi_pv, timeout=timeout
)
# Fill all signals
for signal_name, signal_details in self.pvi_tree.signals.items():
backend = self.filler.fill_child_signal(
signal_name, signal_details.signal_type, None
)
backend.read_pv = signal_details.read_pv
backend.write_pv = signal_details.write_pv
# Fill all sub devices
for device_name, device_sub_tree in self.pvi_tree.sub_devices.items():
if device_sub_tree.vector_children:
# This is a DeviceVector
for vector_child in device_sub_tree.vector_children:
# Vector children in a PVI structure are named "__#"
# where "#" is set as their PviTree root_node against a regex,
# thus guaranteed to be numeric, and so can cast to an int
connector = self.filler.fill_child_device(
device_name, vector_index=int(vector_child.root_node)
)
connector.pvi_tree = vector_child
connector.pvi_pv = vector_child.pvi_pv
else:
# This is a child
self._fill_child(name, cast(Entry, entry))
# This is a Device
connector = self.filler.fill_child_device(device_name)
connector.pvi_tree = device_sub_tree
connector.pvi_pv = device_sub_tree.pvi_pv

# Check that all the requested children have been filled
suffix = f"\n{self.error_hint}" if self.error_hint else ""
self.filler.check_filled(f"{self.pvi_pv}: {entries}{suffix}")
self.filler.check_filled(f"{self.pvi_pv}: {self.pvi_tree}{suffix}")
# Set the name of the device to name all children
device.set_name(device.name)
return await super().connect_real(device, timeout, force_reconnect)


class SignalDetails(ConfinedModel):
"""Representation of a Signal to be constructed."""

signal_type: type[Signal]
read_pv: str
write_pv: str

@classmethod
def from_entry(cls, entry: Entry) -> SignalDetails:
match entry:
case {"r": read_pv, "w": write_pv}:
return cls(signal_type=SignalRW, read_pv=read_pv, write_pv=write_pv)

case {"rw": pv}:
return cls(signal_type=SignalRW, read_pv=pv, write_pv=pv)

case {"r": read_pv}:
return cls(signal_type=SignalR, read_pv=read_pv, write_pv=read_pv)

case {"w": write_pv}:
return cls(signal_type=SignalW, read_pv=write_pv, write_pv=write_pv)

case {"x": execute_pv}:
return cls(signal_type=SignalX, read_pv=execute_pv, write_pv=execute_pv)

case _:
raise TypeError(f"Can't process entry {entry}")


class PviTree(ConfinedModel):
"""Representation of a PVI structure of devices and signals in a PVI query.

Example 1: A device with sub-devices and signals
--------------------------------------
For a PVI structure such as:

```json
{
"bit": {"d": "TEST-PANDA:Bits:PVI"},
"calc": {"d": "TEST-PANDA:Calc:PVI"},
"a": {"rw": "TEST-PANDA:Bits:A"}
}
```

From "TEST-PANDA:PVI", This would be represented as:

```python
PviTree(
pvi_pv="TEST-PANDA:PVI",
root_node="panda",
signals={
"a": SignalDetails(
signal_type=SignalRW,
read_pv="TEST-PANDA:Bits:A",
write_pv="TEST-PANDA:Bits:A")
},
sub_devices={
"bit": PviTree(...),
"calc": PviTree(...)
},
vector_children=[]
)
```

Example 2: A device with vector children
-----------------------------------------
If an entry like `"calc"` is a **DeviceVector**
(e.g., mirroring a fastCS controller vector), the PVI entries will look like this:

```json
{
"__1": {"d": "TEST-PANDA:Calc:2:PVI"},
"__2": {"d": "TEST-PANDA:Calc:1:PVI"}
}
```

This would be represented as:

```python
PviTree(
pvi_pv="TEST-PANDA:Calc:PVI",
root_node="calc",
signals={},
sub_devices={},
vector_children=[
PviTree(pvi_pv="TEST-PANDA:Calc:2:PVI", root_node="1", signals={}, ...),
PviTree(pvi_pv="TEST-PANDA:Calc:1:PVI", root_node="2", signals={}, ...)
]
)
```

:param pvi_pv:
The PVI PV of the device.

:param root_node:
The name of the device or signal.

:param signals:
A dictionary mapping signal names to `SignalDetails` objects.

:param sub_devices:
A dictionary mapping sub-device names to their corresponding `PviTree` objects.

:param vector_children:
A list of `PviTree` objects representing child devices of a vector device.
"""

pvi_pv: str
root_node: str
signals: dict[str, SignalDetails] = Field(default={})
sub_devices: dict[str, PviTree] = Field(default={})
vector_children: list[PviTree] = Field(default=[])

@classmethod
async def build_device_tree(cls, name: str, pvi_pv: str, timeout: float) -> PviTree:
"""Recursively build a PviTree from a top level device.

Starting from the top-level device, this classmethod performs
post-order traversal over the served PVI structure, populating
a PviTree from the bottom up.

:param name: Device name
:param pvi_pv: Device PVI PV
:param timeout: Timeout on pvget
"""
pvi_structure = await pvget_with_timeout(pvi_pv, timeout)
entries: dict[str, Entry] = pvi_structure["value"].todict()

vector_children: list[PviTree] = []

sub_trees, signal_details = await asyncio.gather(
gather_dict(
{
entry_name: cls.build_device_tree(entry_name, entry["d"], timeout)
for entry_name, entry in entries.items()
if set(entry) == {"d"}
}
),
gather_dict(
{
entry_name: SignalDetails.from_entry(entry)
for entry_name, entry in entries.items()
if set(entry) != {"d"}
}
),
)

# Filter vector children out of stand-alone devices
for child_name in list(sub_trees):
# Check if any sub-devices are named "__#" (e.g., "__1")
if m := re.match(r"^__(\d+)$", child_name):
sub_tree = sub_trees.pop(child_name)
sub_tree.root_node = m.group(1)
vector_children.append(sub_tree)

return PviTree(
pvi_pv=pvi_pv,
root_node=name,
signals=signal_details,
sub_devices=sub_trees,
vector_children=vector_children,
)

def __str__(self) -> str:
"""Print a readable top layer of the PviTree."""
sub_devices = {
child_name: child_tree.pvi_pv
for child_name, child_tree in self.sub_devices.items()
}
signals = {
signal_name: {
signal_details.signal_type: [
signal_details.read_pv,
signal_details.write_pv,
]
}
for signal_name, signal_details in self.signals.items()
}
return f"{self.root_node}: {self.pvi_pv}\n{sub_devices=}\n{signals=}"
6 changes: 4 additions & 2 deletions tests/system_tests/fastcs/panda/test_panda_connect.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,14 @@ def __init__(self, uri: str, name: str = ""):
@pytest.mark.timeout(15.0 if os.name == "nt" else 4.0)
async def test_panda_with_missing_blocks(panda_pva, panda_t):
panda = panda_t("PANDAQSRVI:", name="mypanda")

with pytest.raises(
RuntimeError,
match=re.escape(
"mypanda: cannot provision ['pcap'] from PANDAQSRVI:PVI: "
"{'pulse': [None, {'d': 'PANDAQSRVI:PULSE1:PVI'}],"
" 'seq': [None, {'d': 'PANDAQSRVI:SEQ1:PVI'}]}\nIs it ok?"
"mypanda: PANDAQSRVI:PVI\nsub_devices="
"{'pulse': 'PANDAQSRVI:PULSE:PVI', 'seq': 'PANDAQSRVI:SEQ:PVI'}\nsignals={}"
"\nIs it ok?"
),
):
await panda.connect()
Expand Down
Loading