Source code for ophyd_async.epics.core._pvi_connector

from __future__ import annotations

import inspect
import re
from collections.abc import Mapping

from pydantic import (
    Field,
    field_validator,
)

from ophyd_async.core import (
    ConfinedModel,
    Device,
    DeviceFiller,
    DeviceMap,
    DeviceVector,
    LazyMock,
    Signal,
    SignalR,
    SignalRW,
    SignalW,
    TriggerableCommand,
    gather_dict,
)

from ._epics_connector import _PvPrefixDeviceConnector, fill_children_with_prefix
from ._signal import PvaCommandBackend, PvaSignalBackend, pvget_with_timeout
from ._util import EpicsCommandBackend


[docs] class PviDeviceConnector(_PvPrefixDeviceConnector): """Connect a `Device` to a PVI structure served over PVA. At construction, create the type-hinted signals, commands and sub-devices from the `Device`'s annotations. At connection, discover the served PVI tree and fill in each child, checking the type-hinted ones and adding any extra ones the tree provides. :param prefix: The PV prefix of the device; "PVI" is appended to it to get the PVI PV. :param error_hint: If given, appended to the error message when a type-hinted child is missing from the served tree. """ mock_device_vector_children: list[int] = [1, 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? super().__init__(prefix) self.error_hint = error_hint
[docs] def set_prefix(self, prefix: str) -> None: super().set_prefix(prefix) self.pvi_pv = prefix + "PVI"
[docs] def create_children_from_annotations(self, device: Device) -> None: # Per the DeviceConnector contract this may be called more than once; # having built the filler, subsequent calls are a no-op. if hasattr(self, "filler"): return def command_backend_factory( sig: inspect.Signature | None, ) -> EpicsCommandBackend: # EPICS only supports void/void commands (a plain PV put); a typed # Command[P, T] annotation is a mistake on an EPICS device. if sig is not None: raise TypeError( f"{device.name}: EPICS only supports TriggerableCommand /" " Command[[], None]; typed Command with parameters is not" " yet supported over EPICS" ) return PvaCommandBackend() self.filler = DeviceFiller( device=device, signal_backend_factory=PvaSignalBackend, device_connector_factory=PviDeviceConnector, command_backend_factory=command_backend_factory, ) # Address any PvSuffix-annotated children now; everything else is # addressed from the served PVI tree at connect, which also cross-checks # the ones addressed here. fill_children_with_prefix(self.prefix, self.filler, filled=False) self.filler.check_created()
[docs] async def connect_mock(self, device: Device, mock: LazyMock) -> None: # A DeviceVector gets fabricated mock children; a DeviceMap does not (its # entries come from the served PVI tree, so in mock mode it stays empty). if isinstance(device, DeviceVector): self.filler.create_device_dict_entries_to_mock( self.mock_device_vector_children ) device.set_name(device.name) return await super().connect_mock(device, mock)
[docs] async def connect_real( self, device: Device, timeout: float, force_reconnect: bool ) -> None: if self.pvi_tree is None: # Top-level device: discover the served PVI tree. self.pvi_tree = await PviTree.build_device_tree( pvi_pv=self.pvi_pv, timeout=timeout ) tree = self.pvi_tree # A DeviceMap is filled from its node's normal named entries, keyed by # name. Anything else fills its named entries as attributes, plus any # "__N" entries into the int-keyed DeviceVector that it is. is_map = isinstance(device, DeviceMap) if is_map and tree.vector_children: raise TypeError( f"{self.pvi_pv}: {device.name} is annotated as a DeviceMap, but " f"PVI serves integer-keyed entries {sorted(tree.vector_children)} " "here, which only a DeviceVector can hold" ) self._fill_named_entries(tree, as_map=is_map) if not is_map: self._fill_vector_children(device, tree) suffix = f"\n{self.error_hint}" if self.error_hint else "" self.filler.check_filled(f"{self.pvi_pv}: {tree}{suffix}") device.set_name(device.name) return await super().connect_real(device, timeout, force_reconnect)
def _fill_named_entries(self, tree: PviTree, *, as_map: bool) -> None: """Fill a node's normal (non-"__N") entries. `as_map=True` adds each entry to a `DeviceMap` keyed by its name; `as_map=False` sets it as a plain attribute on the device. """ for name, details in tree.signals.items(): self._fill_signal(name, details, map_key=name if as_map else None) for name, execute_pv in tree.commands.items(): self._fill_command(name, execute_pv, map_key=name if as_map else None) for name, sub_tree in tree.sub_devices.items(): # A sub-tree with "__N" entries is an (undeclared) DeviceVector; # for declared children the filler already knows the type. device_type = DeviceVector if sub_tree.vector_children else Device self._fill_sub_device( name, sub_tree, device_type=device_type, map_key=name if as_map else None, ) def _fill_vector_children(self, device: Device, tree: PviTree) -> None: """Fill the int-keyed "__N" entries into the `DeviceVector` `device`. `PviTree` validates the entries are all one kind, so the child value type selects how to fill it. """ for key, child in tree.vector_children.items(): if isinstance(child, SignalDetails): self._fill_signal(device.name, child, map_key=key) elif isinstance(child, PviTree): self._fill_sub_device(device.name, child, map_key=key) else: # a bare execute-PV str is a command self._fill_command(device.name, child, map_key=key) def _check_agrees_with_pvi(self, name: str, annotated: str, served: str) -> None: """Raise if a child's PvSuffix annotation disagrees with the PVI tree. `annotated` is empty for the children PVI alone addresses, which is the usual case; a child that was given a `PvSuffix` as well must agree with what the tree says, or one of the two is wrong. """ if annotated and annotated != served: raise TypeError( f"{self.pvi_pv}: {name} is addressed at {annotated} by its " f"PvSuffix annotation, but PVI serves it at {served}" ) def _fill_signal( self, name: str, details: SignalDetails, map_key: int | str | None = None ) -> None: backend = self.filler.fill_child_signal(name, details.signal_type, map_key) self._check_agrees_with_pvi(name, backend.read_pv, details.read_pv) self._check_agrees_with_pvi(name, backend.write_pv, details.write_pv) backend.read_pv = details.read_pv backend.write_pv = details.write_pv def _fill_command( self, name: str, execute_pv: str, map_key: int | str | None = None ) -> None: backend = self.filler.fill_child_command(name, TriggerableCommand, map_key) self._check_agrees_with_pvi(name, backend.write_pv, execute_pv) backend.write_pv = execute_pv def _fill_sub_device( self, name: str, sub_tree: PviTree, *, device_type: type[Device] = Device, map_key: int | str | None = None, ) -> None: connector = self.filler.fill_child_device( name, device_type=device_type, map_key=map_key ) # An unaddressed connector still has the "PVI" its empty prefix makes, # so only cross-check the ones a PvSuffix gave a prefix to. self._check_agrees_with_pvi( name, connector.pvi_pv if connector.prefix else "", sub_tree.pvi_pv ) connector.pvi_tree = sub_tree connector.pvi_pv = sub_tree.pvi_pv
[docs] class SignalDetails(ConfinedModel): """Representation of a Signal to be constructed.""" signal_type: type[Signal] read_pv: str write_pv: str
[docs] @classmethod def from_entry(cls, entry: dict[str, str]) -> 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 _: raise TypeError(f"Can't process entry {entry}")
[docs] 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", 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", signals={}, sub_devices={}, vector_children=[ PviTree(pvi_pv="TEST-PANDA:Calc:2:PVI", signals={}, ...), PviTree(pvi_pv="TEST-PANDA:Calc:1:PVI", signals={}, ...) ] ) ``` This is similar for vectors of signals, where `vector_children` would instead be populated with `SignalDetails` Example 3: A device with legacy vector children ----------------------------------------- Legacy PVI vector structure is supported, for backwards compatability with pandablocks-ioc, where vector children are represented as: ``` { "calc": [None, {"d": "TEST-PANDA:Calc1:PVI"}, {"d": "TEST-PANDA:Calc2:PVI"}], } ``` generate the same PviTree as in Example 2, excluding a PVI PV. :param pvi_pv: The PVI PV of the device. :param signals: A mapping of signal names to `SignalDetails` objects. :param commands: A mapping of command names to their execute PV strings (from "x" PVI entries). :param sub_devices: A mapping of sub-device names to their corresponding `PviTree` objects. :param vector_children: A mapping of int to `PviTree` objects representing child devices of a vector device. """ pvi_pv: str = Field(default="") signals: Mapping[str, SignalDetails] = Field(default_factory=dict) commands: Mapping[str, str] = Field(default_factory=dict) sub_devices: Mapping[str, PviTree] = Field(default_factory=dict) # A `str` vector_child is the execute PV of a DeviceVector of commands # (a "__N" entry whose only key is "x"). vector_children: Mapping[int, PviTree | SignalDetails | str] = Field( default_factory=dict )
[docs] @classmethod async def build_device_tree(cls, 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) # An example entry is: {"d": "Prefix:Device:PVI", "rw": "Prefix:A"} # these entries are stored under the parent PVI structure name # for example, {"device": {"d": "Prefix:Device:PVI", "rw": "Prefix:A"}} entries: dict[str, dict[str, str]] = pvi_structure["value"].todict() # Separate "x" (command) entries before processing signals commands: dict[str, str] = { entry_name: entries.pop(entry_name)["x"] for entry_name in list(entries) if not isinstance(entries[entry_name], list) and set(entries[entry_name]) == {"x"} } signal_details = { entry_name: SignalDetails.from_entry(entries.pop(entry_name)) for entry_name in list(entries) if not isinstance(entries[entry_name], list) and set(entries[entry_name]) != {"d"} } sub_trees = await gather_dict( { entry_name: cls._handle_legacy_entry(entry, timeout) if isinstance(entry, list) # Found a legacy entry, try to handle else cls.build_device_tree(entry["d"], timeout) for entry_name, entry in entries.items() } ) vector_children: dict[int, PviTree | SignalDetails | str] = {} # Filter DeviceVector children ("__N" integer entries) out of the # stand-alone devices/signals/commands ("commands" holds bare execute-PV # strings for "__N" entries here, i.e. a DeviceVector of commands). There # is deliberately no string-keyed equivalent: a DeviceMap is only ever # created from an explicit `DeviceMap[...]` annotation, and is filled from # a node's normal named entries (see PviDeviceConnector._fill_device_map). for processed_entries in (sub_trees, signal_details, commands): for child_name in list(processed_entries): if m := re.match(r"^__(\d+)$", child_name): vector_children[int(m.group(1))] = processed_entries.pop(child_name) return PviTree( pvi_pv=pvi_pv, signals=signal_details, commands=commands, sub_devices=sub_trees, vector_children=vector_children, )
@classmethod async def _handle_legacy_entry( cls, legacy_entry: list[None | dict[str, str]], timeout: float ) -> PviTree: """Handle legacy vector entries. Cannot be converted to map as have no str keys. For example; ``` { "calc": [None, {"d": "TEST-PANDA:Calc1:PVI"}, {"d": "TEST-PANDA:Calc2:PVI"}] } ``` a `PviTree` is built for each device entry in this list. """ sub_trees = await gather_dict( { vector_index: cls.build_device_tree(vector_entry["d"], timeout) for vector_index, vector_entry in enumerate(legacy_entry) if vector_entry is not None } ) # Legacy FastCS vector should not contain child signals, # devices, or its own PVI PV. return PviTree( vector_children=sub_trees, ) def __str__(self) -> str: """Print a readable top layer of the PviTree.""" children = { **{ child_name: tree.pvi_pv for child_name, tree in self.sub_devices.items() }, **{ child_name: vector_child.pvi_pv for child_name, vector_child in self.vector_children.items() if isinstance(vector_child, PviTree) }, } signals = { **{ signal_name: ( detail.signal_type.__name__, detail.read_pv, detail.write_pv, ) for signal_name, detail in self.signals.items() }, **{ signal_name: ( vector_child.signal_type.__name__, vector_child.read_pv, vector_child.write_pv, ) for signal_name, vector_child in self.vector_children.items() if isinstance(vector_child, SignalDetails) }, } return f"sub_devices={children}\nsignals={signals}" @field_validator("vector_children") @classmethod def _check_consistency_of_vector_children_type( cls, vector_children: Mapping[int, PviTree | SignalDetails | str], ): """Validates that parsed vector children are all of the same type.""" if not ( all( isinstance(vector_child, SignalDetails) for vector_child in vector_children.values() ) or all( isinstance(vector_child, PviTree) for vector_child in vector_children.values() ) or all( isinstance(vector_child, str) for vector_child in vector_children.values() ) ): raise ValueError( "Failed to validate PviTree. " "vector_children must all be of type `SignalDetails`, `PviTree` " f"or `str`. Received mixed type: {vector_children=}" ) return vector_children