Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
7f0d4b7
feat: implement initial subcontroller vector
shihab-dls Aug 15, 2025
7591e8f
WIP Pass bool record *NAM fields
GDYendell Aug 21, 2025
d6bfed5
WIP Handle ints in CA pvi
GDYendell Aug 21, 2025
98fdd76
chore: fix typing due to integer in controller path
shihab-dls Nov 3, 2025
57b411d
chore: amend p4p raw value logic to strip suffix
shihab-dls Nov 3, 2025
d520bbe
chore: update example iocs
shihab-dls Nov 3, 2025
016344f
tests: amend tests to use controller vetor
shihab-dls Nov 3, 2025
de036d7
chore: amend controller api path type signature
shihab-dls Nov 3, 2025
c8a6c0a
chore: add ios to vector init
shihab-dls Nov 3, 2025
480bab8
refactor: remove duplicate pvi tree layer for p4p
shihab-dls Nov 3, 2025
d882765
test: amend p4p tests to use new pvi structure
shihab-dls Nov 3, 2025
f76fa52
Merge branch 'main' into 122_controller_vector
shihab-dls Nov 3, 2025
3e8e43a
chore: revert type signature for add_sub_controller
shihab-dls Nov 3, 2025
a8805b9
chore: amend controller vector name
shihab-dls Nov 3, 2025
09c9842
refactor: move vector children up one tree level using controller api
shihab-dls Nov 5, 2025
c696a97
refactor: amend pva structure to add intermediate ControllerVector level
shihab-dls Nov 5, 2025
b27499b
refactor: revert path to str only and restrict add_sub_controller
shihab-dls Nov 5, 2025
ecaf5ec
tests: amend tests to use new vector children naming
shihab-dls Nov 5, 2025
cad6c34
tests: add tests for restricted add_sub_controller method
shihab-dls Nov 5, 2025
c231cd9
chore: address review comments
shihab-dls Nov 6, 2025
62f3761
chore: remove children() method from ControllerVector
shihab-dls Nov 7, 2025
c443171
refactor: convert PviDevice and PviTree operations into flattened fre…
shihab-dls Nov 7, 2025
a7fd462
tests: amend cases and descriptin passing to fix tests
shihab-dls Nov 7, 2025
a339629
docs: add docstring to public function
shihab-dls Nov 7, 2025
edad64f
refactor: add sub controller in ControllerVector setitem
shihab-dls Nov 7, 2025
249128a
tests: test setitem and delitem in ControllerVector
shihab-dls Nov 7, 2025
06505fd
tests: add tests for getitem and iter
shihab-dls Nov 7, 2025
4b3e2e3
Merge branch 'main' into 122_controller_vector
shihab-dls Nov 7, 2025
b0056c7
chore: address review comments
shihab-dls Nov 7, 2025
8fb3065
chore: add todo for _attribute_to_access
shihab-dls Nov 7, 2025
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
69 changes: 63 additions & 6 deletions src/fastcs/controller.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

from collections import Counter
from collections.abc import Sequence
from collections.abc import Iterator, Mapping, MutableMapping, Sequence
from copy import deepcopy
from typing import get_type_hints

Expand All @@ -17,6 +17,7 @@ class BaseController(Tracer):

#: Attributes passed from the device at runtime.
attributes: dict[str, Attribute]
root_attribute: Attribute | None = None

description: str | None = None

Expand All @@ -36,7 +37,7 @@ def __init__(
if not hasattr(self, "attributes"):
self.attributes = {}
self._path: list[str] = path or []
self.__sub_controller_tree: dict[str, Controller] = {}
self.__sub_controller_tree: dict[str, BaseController] = {}

self._bind_attrs()

Expand Down Expand Up @@ -144,7 +145,7 @@ def add_attribute(self, name, attribute: Attribute):
self.attributes[name] = attribute
super().__setattr__(name, attribute)

def add_sub_controller(self, name: str, sub_controller: Controller):
def add_sub_controller(self, name: str, sub_controller: BaseController):
if name in self.__sub_controller_tree.keys():
raise ValueError(
f"Cannot add sub controller {sub_controller}. "
Expand All @@ -166,7 +167,7 @@ def add_sub_controller(self, name: str, sub_controller: Controller):
self.attributes[name] = sub_controller.root_attribute

@property
def sub_controllers(self) -> dict[str, Controller]:
def sub_controllers(self) -> dict[str, BaseController]:
return self.__sub_controller_tree

def __repr__(self):
Expand Down Expand Up @@ -194,17 +195,73 @@ class Controller(BaseController):
such as generating a UI or creating parameters for a control system.
"""

root_attribute: Attribute | None = None

def __init__(
self,
description: str | None = None,
ios: Sequence[AttributeIO[T, AttributeIORefT]] | None = None,
) -> None:
super().__init__(description=description, ios=ios)

def add_sub_controller(self, name: str, sub_controller: BaseController):
if name.isdigit():
raise ValueError(
f"Cannot add sub controller {name}. "
"Numeric-only names are not allowed; use ControllerVector instead"
)
return super().add_sub_controller(name, sub_controller)

async def connect(self) -> None:
pass

async def disconnect(self) -> None:
pass


class ControllerVector(MutableMapping[int, Controller], BaseController):
"""A controller with a collection of identical sub controllers distinguished
by a numeric value"""

def __init__(
self,
children: Mapping[int, Controller],
description: str | None = None,
ios: Sequence[AttributeIO[T, AttributeIORefT]] | None = None,
) -> None:
super().__init__(description=description, ios=ios)
self._children: dict[int, Controller] = {}
for index, child in children.items():
self[index] = child

def add_sub_controller(self, name: str, sub_controller: BaseController):
raise NotImplementedError(
"Cannot add named sub controller to ControllerVector. "
"Use __setitem__ instead, for indexed sub controllers. "
"E.g., vector[1] = Controller()"
)

def __getitem__(self, key: int) -> Controller:
try:
return self._children[key]
except KeyError as exception:
raise KeyError(
f"ControllerVector does not have Controller with key {key}"
) from exception

def __setitem__(self, key: int, value: Controller) -> None:
if not isinstance(key, int):
msg = f"Expected int, got {key}"
raise TypeError(msg)
if not isinstance(value, Controller):
msg = f"Expected Controller, got {value}"
raise TypeError(msg)
self._children[key] = value
super().add_sub_controller(str(key), value)

def __delitem__(self, key: int) -> None:
raise NotImplementedError("Cannot delete sub controller from ControllerVector.")

def __iter__(self) -> Iterator[int]:
yield from self._children

def __len__(self) -> int:
return len(self._children)
9 changes: 6 additions & 3 deletions src/fastcs/controller_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,12 @@ def walk_api(self) -> Iterator["ControllerAPI"]:
yield from api.walk_api()

def __repr__(self):
return f"""\
ControllerAPI(path={self.path}, sub_apis=[{", ".join(self.sub_apis.keys())}])\
"""
return (
f"ControllerAPI("
f"path={self.path}, "
f"sub_apis=[{', '.join(self.sub_apis.keys())}]"
f")"
)

def get_scan_and_initial_coros(
self,
Expand Down
9 changes: 7 additions & 2 deletions src/fastcs/transport/epics/ca/ioc.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,14 @@ def _add_sub_controller_pvi_info(pv_prefix: str, parent: ControllerAPI):

for child in parent.sub_apis.values():
child_pvi = f"{controller_pv_prefix(pv_prefix, child)}:PVI"
child_name = child.path[-1].lower()
child_name = (
f"__{child.path[-1]}" # Sub-Controller of ControllerVector
if child.path[-1].isdigit()
else child.path[-1]
)

_add_pvi_info(child_pvi, parent_pvi, child_name.lower())

_add_pvi_info(child_pvi, parent_pvi, child_name)
_add_sub_controller_pvi_info(pv_prefix, child)


Expand Down
3 changes: 3 additions & 0 deletions src/fastcs/transport/epics/ca/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ def _verify_in_datatype(_, value):
return value in datatype.names

arguments["validate"] = _verify_in_datatype
case Bool():
arguments["ZNAM"] = "False"
arguments["ONAM"] = "True"
Copy link
Contributor

Choose a reason for hiding this comment

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

This seems unrelated?

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'm actually not sure how this got here; looks like you committed these in this ref? Not sure if this relates to another ticket?

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah right yes and I couldn't rebase it away because there is a merge from main after it. If we merge that change first and then squash merge this it should be fine

Copy link
Contributor

Choose a reason for hiding this comment

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

Made #261 to resolve this


return arguments

Expand Down
2 changes: 2 additions & 0 deletions src/fastcs/transport/epics/gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@ def extract_api_components(self, controller_api: ControllerAPI) -> Tree:
components: Tree = []

for name, api in controller_api.sub_apis.items():
if name.isdigit():
name = f"{controller_api.path[-1]}{name}"
components.append(
Group(
name=snake_to_pascal(name),
Expand Down
37 changes: 12 additions & 25 deletions src/fastcs/transport/epics/pva/ioc.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from p4p.server import Server, StaticProvider

from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW
from fastcs.attributes import AttrR, AttrRW, AttrW
from fastcs.controller_api import ControllerAPI
from fastcs.transport.epics.util import controller_pv_prefix
from fastcs.util import snake_to_pascal
Expand All @@ -12,32 +12,23 @@
make_shared_read_pv,
make_shared_write_pv,
)
from .pvi_tree import AccessModeType, PviTree


def _attribute_to_access(attribute: Attribute) -> AccessModeType:
match attribute:
case AttrRW():
return "rw"
case AttrR():
return "r"
case AttrW():
return "w"
case _:
raise ValueError(f"Unknown attribute type {type(attribute)}")
from .pvi import add_pvi_info


async def parse_attributes(
root_pv_prefix: str, root_controller_api: ControllerAPI
) -> list[StaticProvider]:
) -> StaticProvider:
"""Parses `Attribute` s into p4p signals in handlers."""
pvi_tree = PviTree(root_pv_prefix)
provider = StaticProvider(root_pv_prefix)

for controller_api in root_controller_api.walk_api():
pv_prefix = controller_pv_prefix(root_pv_prefix, controller_api)

pvi_tree.add_sub_device(pv_prefix, controller_api.description)
provider = add_pvi_info(
provider=provider,
pv_prefix=pv_prefix,
controller_api=controller_api,
description=controller_api.description,
)

for attr_name, attribute in controller_api.attributes.items():
full_pv_name = f"{pv_prefix}:{snake_to_pascal(attr_name)}"
Expand All @@ -47,23 +38,19 @@ async def parse_attributes(
attribute_pv_rbv = make_shared_read_pv(attribute)
provider.add(f"{full_pv_name}", attribute_pv)
provider.add(f"{full_pv_name}_RBV", attribute_pv_rbv)
pvi_tree.add_signal(f"{full_pv_name}", "rw")
case AttrR():
attribute_pv = make_shared_read_pv(attribute)
provider.add(f"{full_pv_name}", attribute_pv)
pvi_tree.add_signal(f"{full_pv_name}", "r")
case AttrW():
attribute_pv = make_shared_write_pv(attribute)
provider.add(f"{full_pv_name}", attribute_pv)
pvi_tree.add_signal(f"{full_pv_name}", "w")

for attr_name, method in controller_api.command_methods.items():
full_pv_name = f"{pv_prefix}:{snake_to_pascal(attr_name)}"
command_pv = make_command_pv(method.fn)
provider.add(f"{full_pv_name}", command_pv)
pvi_tree.add_signal(f"{full_pv_name}", "x")

return [provider, pvi_tree.make_provider()]
return provider


class P4PIOC:
Expand All @@ -74,8 +61,8 @@ def __init__(self, pv_prefix: str, controller_api: ControllerAPI):
self.controller_api = controller_api

async def run(self):
providers = await parse_attributes(self.pv_prefix, self.controller_api)
provider = await parse_attributes(self.pv_prefix, self.controller_api)

endless_event = asyncio.Event()
with Server(providers):
with Server([provider]):
await endless_event.wait()
119 changes: 119 additions & 0 deletions src/fastcs/transport/epics/pva/pvi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
from collections import defaultdict
from typing import Literal

from p4p import Type, Value
from p4p.nt.common import alarm, timeStamp
from p4p.server import StaticProvider
from p4p.server.asyncio import SharedPV

from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW
from fastcs.controller_api import ControllerAPI
from fastcs.util import snake_to_pascal

from .types import p4p_alarm_states, p4p_timestamp_now

AccessModeType = Literal["r", "w", "rw", "d", "x"]


# TODO: This should be removed after https://github.com/DiamondLightSource/FastCS/issues/260
def _attribute_to_access(attribute: Attribute) -> AccessModeType:
match attribute:
case AttrRW():
return "rw"
case AttrR():
return "r"
case AttrW():
return "w"
case _:
raise ValueError(f"Unknown attribute type {type(attribute)}")


def add_pvi_info(
provider: StaticProvider,
pv_prefix: str,
controller_api: ControllerAPI,
description: str | None = None,
) -> StaticProvider:
"""Add PVI information to given provider."""
provider.add(
f"{pv_prefix}:PVI",
SharedPV(initial=_make_p4p_value(pv_prefix, controller_api, description)),
)
return provider


def _make_p4p_value(
pv_prefix: str, controller_api: ControllerAPI, description: str | None
) -> Value:
display = (
{"display": {"description": description}} if description is not None else {}
) # Defined here so the value can be (none)

raw_value = _make_p4p_raw_value(pv_prefix, controller_api)
p4p_type = _make_type_for_raw_value(raw_value)

try:
return Value(
p4p_type,
{
**p4p_alarm_states(),
**p4p_timestamp_now(),
**display,
"value": raw_value,
},
)
except KeyError as e:
raise ValueError(f"Failed to create p4p Value from {raw_value}") from e


def _make_p4p_raw_value(pv_prefix: str, controller_api: ControllerAPI) -> dict:
p4p_raw_value = defaultdict(dict)
# Sub-controller api returned if current item is a Controller
for pv_leaf, sub_controller_api in controller_api.sub_apis.items():
# Add Controller entry
pv = f"{pv_prefix}:{snake_to_pascal(pv_leaf)}:PVI"
if sub_controller_api.path[-1].isdigit():
# Sub-device of a ControllerVector
p4p_raw_value[f"__{int(pv_leaf)}"]["d"] = pv
else:
p4p_raw_value[pv_leaf]["d"] = pv
for pv_leaf, attribute in controller_api.attributes.items():
# Add attribute entry
pv = f"{pv_prefix}:{snake_to_pascal(pv_leaf)}"
p4p_raw_value[pv_leaf][_attribute_to_access(attribute)] = pv
for pv_leaf, _ in controller_api.command_methods.items():
pv = f"{pv_prefix}:{snake_to_pascal(pv_leaf)}"
p4p_raw_value[pv_leaf]["x"] = pv

return p4p_raw_value


def _make_type_for_raw_value(raw_value: dict) -> Type:
p4p_raw_type = []
for pvi_group_name, access_to_field in raw_value.items():
pvi_group_structure = []
for access, field in access_to_field.items():
if isinstance(field, str):
pvi_group_structure.append((access, "s"))
elif isinstance(field, dict):
pvi_group_structure.append(
(
access,
(
"S",
None,
[(v, "s") for v, _ in field.items()],
),
)
)

p4p_raw_type.append((pvi_group_name, ("S", "structure", pvi_group_structure)))

return Type(
[
("alarm", alarm),
("timeStamp", timeStamp),
("display", ("S", None, [("description", "s")])),
("value", ("S", "structure", p4p_raw_type)),
]
)
Loading