Skip to content
183 changes: 157 additions & 26 deletions src/fastcs/transports/epics/ca/ioc.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,28 @@
import asyncio
from collections.abc import Callable
from dataclasses import asdict
from typing import Any, Literal

from softioc import builder, softioc
from softioc.asyncio_dispatcher import AsyncioDispatcher
from softioc.pythonSoftIoc import RecordWrapper

from fastcs.attributes import AttrR, AttrRW, AttrW
from fastcs.datatypes import DataType, DType_T
from fastcs.datatypes.waveform import Waveform
from fastcs.datatypes import Bool, DataType, DType_T, Enum, Float, Int, String, Waveform
from fastcs.exceptions import FastCSError
from fastcs.logging import bind_logger
from fastcs.methods import Command
from fastcs.tracer import Tracer
from fastcs.transports.controller_api import ControllerAPI
from fastcs.transports.epics import EpicsIOCOptions
from fastcs.transports.epics.ca.util import (
builder_callable_from_attribute,
DATATYPE_FIELD_TO_IN_RECORD_FIELD,
DATATYPE_FIELD_TO_OUT_RECORD_FIELD,
DEFAULT_STRING_WAVEFORM_LENGTH,
MBB_MAX_CHOICES,
cast_from_epics_type,
cast_to_epics_type,
record_metadata_from_attribute,
record_metadata_from_datatype,
create_state_keys,
)
from fastcs.transports.epics.util import controller_pv_prefix
from fastcs.util import snake_to_pascal
Expand Down Expand Up @@ -187,37 +190,165 @@ async def async_record_set(value: DType_T):

record.set(cast_to_epics_type(attribute.datatype, value))

record = _make_record(pv, attribute)
record = _make_in_record(pv, attribute)
_add_attr_pvi_info(record, pv_prefix, attr_name, "r")

attribute.add_on_update_callback(async_record_set)


def _make_record(
def _make_in_record(pv: str, attribute: AttrR) -> RecordWrapper:
attribute_record_metadata = {
"DESC": attribute.description,
"initial_value": cast_to_epics_type(attribute.datatype, attribute.get()),
}

match attribute.datatype:
case Bool():
record = builder.boolIn(
pv, ZNAM="False", ONAM="True", **attribute_record_metadata
)
case Int():
record = builder.longIn(
pv,
LOPR=attribute.datatype.min_alarm,
HOPR=attribute.datatype.max_alarm,
EGU=attribute.datatype.units,
**attribute_record_metadata,
)
case Float():
record = builder.aIn(
pv,
LOPR=attribute.datatype.min_alarm,
HOPR=attribute.datatype.max_alarm,
EGU=attribute.datatype.units,
PREC=attribute.datatype.prec,
**attribute_record_metadata,
)
case String():
record = builder.longStringIn(
pv,
length=attribute.datatype.length or DEFAULT_STRING_WAVEFORM_LENGTH,
**attribute_record_metadata,
)
case Enum():
if len(attribute.datatype.members) > MBB_MAX_CHOICES:
record = builder.longStringIn(
pv,
**attribute_record_metadata,
)
else:
attribute_record_metadata.update(create_state_keys(attribute.datatype))
record = builder.mbbIn(
pv,
**attribute_record_metadata,
)
case Waveform():
record = builder.WaveformIn(
pv, length=attribute.datatype.shape[0], **attribute_record_metadata
)
case _:
raise FastCSError(
f"EPICS unsupported datatype on {attribute}: {attribute.datatype}"
)

def datatype_updater(datatype: DataType):
for name, value in asdict(datatype).items():
if name in DATATYPE_FIELD_TO_IN_RECORD_FIELD:
record.set_field(DATATYPE_FIELD_TO_IN_RECORD_FIELD[name], value)

attribute.add_update_datatype_callback(datatype_updater)
return record


def _make_out_record(
pv: str,
attribute: AttrR | AttrW | AttrRW,
on_update: Callable | None = None,
out_record: bool = False,
attribute: AttrW | AttrRW,
on_update: Callable,
) -> RecordWrapper:
builder_callable = builder_callable_from_attribute(attribute, on_update is None)
datatype_record_metadata = record_metadata_from_datatype(
attribute.datatype, out_record
)
attribute_record_metadata = record_metadata_from_attribute(attribute)
attribute_record_metadata = {
"DESC": attribute.description,
"initial_value": cast_to_epics_type(
attribute.datatype,
attribute.get()
if isinstance(attribute, AttrR)
else attribute.datatype.initial_value,
),
}

update = (
{"on_update": on_update, "always_update": True, "blocking": True}
if on_update
else {}
)
update = {"on_update": on_update, "always_update": True, "blocking": True}

match attribute.datatype:
case Bool():
record = builder.boolOut(
pv, ZNAM="False", ONAM="True", **update, **attribute_record_metadata
)
case Int():
record = builder.longOut(
pv,
LOPR=attribute.datatype.min_alarm,
HOPR=attribute.datatype.max_alarm,
EGU=attribute.datatype.units,
DRVL=attribute.datatype.min,
DRVH=attribute.datatype.max,
**update,
**attribute_record_metadata,
)
case Float():
record = builder.aOut(
pv,
LOPR=attribute.datatype.min_alarm,
HOPR=attribute.datatype.max_alarm,
EGU=attribute.datatype.units,
PREC=attribute.datatype.prec,
DRVL=attribute.datatype.min,
DRVH=attribute.datatype.max,
**update,
**attribute_record_metadata,
)
case String():
record = builder.longStringOut(
pv,
length=attribute.datatype.length or DEFAULT_STRING_WAVEFORM_LENGTH,
**update,
**attribute_record_metadata,
)
case Enum():
if len(attribute.datatype.members) > MBB_MAX_CHOICES:
datatype: Enum = attribute.datatype

def _verify_in_datatype(_, value):
return value in datatype.names

record = builder.longStringOut(
pv,
validate=_verify_in_datatype,
**update,
**attribute_record_metadata,
)

record = builder_callable(
pv, **update, **datatype_record_metadata, **attribute_record_metadata
)
else:
attribute_record_metadata.update(create_state_keys(attribute.datatype))
record = builder.mbbOut(
pv,
**update,
**attribute_record_metadata,
)
case Waveform():
record = builder.WaveformOut(
pv,
length=attribute.datatype.shape[0],
**update,
**attribute_record_metadata,
)
case _:
raise FastCSError(
f"EPICS unsupported datatype on {attribute}: {attribute.datatype}"
)

def datatype_updater(datatype: DataType):
for name, value in record_metadata_from_datatype(datatype, out_record).items():
record.set_field(name, value)
for name, value in asdict(datatype).items():
if name in DATATYPE_FIELD_TO_OUT_RECORD_FIELD:
record.set_field(DATATYPE_FIELD_TO_OUT_RECORD_FIELD[name], value)

attribute.add_update_datatype_callback(datatype_updater)
return record
Expand All @@ -240,7 +371,7 @@ async def set_setpoint_without_process(value: DType_T):

record.set(cast_to_epics_type(attribute.datatype, value), process=False)

record = _make_record(pv, attribute, on_update=on_update, out_record=True)
record = _make_out_record(pv, attribute, on_update=on_update)

_add_attr_pvi_info(record, pv_prefix, attr_name, "w")

Expand Down
112 changes: 17 additions & 95 deletions src/fastcs/transports/epics/ca/util.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
import enum
from dataclasses import asdict
from typing import Any

from softioc import builder

from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW
from fastcs.datatypes import Bool, DType_T, Enum, Float, Int, String, Waveform
from fastcs.datatypes.datatype import DataType
from fastcs.exceptions import FastCSError

_MBB_FIELD_PREFIXES = (
"ZR",
Expand Down Expand Up @@ -36,7 +31,14 @@
EPICS_ALLOWED_DATATYPES = (Bool, Enum, Float, Int, String, Waveform)
DEFAULT_STRING_WAVEFORM_LENGTH = 256

DATATYPE_FIELD_TO_RECORD_FIELD = {
DATATYPE_FIELD_TO_IN_RECORD_FIELD = {
"prec": "PREC",
"units": "EGU",
"min_alarm": "LOPR",
"max_alarm": "HOPR",
}

DATATYPE_FIELD_TO_OUT_RECORD_FIELD = {
"prec": "PREC",
"units": "EGU",
"min": "DRVL",
Expand All @@ -46,69 +48,15 @@
}


def record_metadata_from_attribute(attribute: Attribute[DType_T]) -> dict[str, Any]:
"""Converts attributes on the `Attribute` to the
field name/value in the record metadata."""
metadata: dict[str, Any] = {"DESC": attribute.description}
initial = None
match attribute:
case AttrR():
initial = attribute.get()
case AttrW():
initial = attribute.datatype.initial_value
if initial is not None:
metadata["initial_value"] = cast_to_epics_type(attribute.datatype, initial)
return metadata


def record_metadata_from_datatype(
datatype: DataType[Any], out_record: bool = False
) -> dict[str, str]:
"""Converts attributes on the `DataType` to the
field name/value in the record metadata."""

arguments = {
DATATYPE_FIELD_TO_RECORD_FIELD[field]: value
for field, value in asdict(datatype).items()
if field in DATATYPE_FIELD_TO_RECORD_FIELD
}

if not out_record:
# in type records don't have DRVL/DRVH fields
arguments.pop("DRVL", None)
arguments.pop("DRVH", None)

match datatype:
case String():
arguments["length"] = datatype.length or DEFAULT_STRING_WAVEFORM_LENGTH
case Waveform():
if len(datatype.shape) != 1:
raise TypeError(
f"Unsupported shape {datatype.shape}, the EPICS transport only "
"supports to 1D arrays"
)
arguments["length"] = datatype.shape[0]
case Enum():
if len(datatype.members) <= MBB_MAX_CHOICES:
state_keys = dict(
zip(
MBB_STATE_FIELDS,
datatype.names,
strict=False,
)
)
arguments.update(state_keys)
elif out_record: # no validators for in type records

def _verify_in_datatype(_, value):
return value in datatype.names

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

return arguments
def create_state_keys(datatype: Enum):
"""Creates a dictionary of state field keys to names"""
return dict(
zip(
MBB_STATE_FIELDS,
datatype.names,
strict=False,
)
)


def cast_from_epics_type(datatype: DataType[DType_T], value: object) -> DType_T:
Expand Down Expand Up @@ -154,29 +102,3 @@ def cast_to_epics_type(datatype: DataType[DType_T], value: DType_T) -> Any:
return value
case _:
raise ValueError(f"Unsupported datatype {datatype}")


def builder_callable_from_attribute(
attribute: AttrR | AttrW | AttrRW, make_in_record: bool
):
"""Returns a callable to make the softioc record from an attribute instance."""
match attribute.datatype:
case Bool():
return builder.boolIn if make_in_record else builder.boolOut
case Int():
return builder.longIn if make_in_record else builder.longOut
case Float():
return builder.aIn if make_in_record else builder.aOut
case String():
return builder.longStringIn if make_in_record else builder.longStringOut
case Enum():
if len(attribute.datatype.members) > MBB_MAX_CHOICES:
return builder.longStringIn if make_in_record else builder.longStringOut
else:
return builder.mbbIn if make_in_record else builder.mbbOut
case Waveform():
return builder.WaveformIn if make_in_record else builder.WaveformOut
case _:
raise FastCSError(
f"EPICS unsupported datatype on {attribute}: {attribute.datatype}"
)
Loading