Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions src/fastcs/datatypes.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,10 @@ def index_of(self, value: T_Enum) -> int:
def members(self) -> list[T_Enum]:
return list(self.enum_cls)

@cached_property
def names(self) -> list[str]:
return [member.name for member in self.members]

@property
def dtype(self) -> type[T_Enum]:
return self.enum_cls
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 @@ -171,9 +171,12 @@ def _make_record(
pv: str,
attribute: AttrR | AttrW | AttrRW,
on_update: Callable | None = None,
out_record: bool = False,
) -> RecordWrapper:
builder_callable = builder_callable_from_attribute(attribute, on_update is None)
datatype_record_metadata = record_metadata_from_datatype(attribute.datatype)
datatype_record_metadata = record_metadata_from_datatype(
attribute.datatype, out_record
)
attribute_record_metadata = record_metadata_from_attribute(attribute)

update = {"always_update": True, "on_update": on_update} if on_update else {}
Expand Down Expand Up @@ -201,7 +204,9 @@ async def on_update(value):
async def async_write_display(value: T):
record.set(cast_to_epics_type(attribute.datatype, value), process=False)

record = _make_record(f"{pv_prefix}:{pv_name}", attribute, on_update=on_update)
record = _make_record(
f"{pv_prefix}:{pv_name}", attribute, on_update=on_update, out_record=True
)

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

Expand Down
24 changes: 19 additions & 5 deletions src/fastcs/transport/epics/ca/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ def record_metadata_from_attribute(
return {"DESC": attribute.description}


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

Expand All @@ -73,11 +75,17 @@ def record_metadata_from_datatype(datatype: DataType[T]) -> dict[str, str]:
state_keys = dict(
zip(
MBB_STATE_FIELDS,
[member.name for member in datatype.members],
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

return arguments

Expand All @@ -86,7 +94,10 @@ def cast_from_epics_type(datatype: DataType[T], value: object) -> T:
"""Casts from an EPICS datatype to a FastCS datatype."""
match datatype:
case Enum():
return datatype.validate(datatype.members[value])
if len(datatype.members) <= MBB_MAX_CHOICES:
return datatype.validate(datatype.members[value])
else: # enum backed by string record
return datatype.validate(datatype.enum_cls[value])
case datatype if issubclass(type(datatype), EPICS_ALLOWED_DATATYPES):
return datatype.validate(value) # type: ignore
case _:
Expand All @@ -97,7 +108,10 @@ def cast_to_epics_type(datatype: DataType[T], value: T) -> object:
"""Casts from an attribute's datatype to an EPICS datatype."""
match datatype:
case Enum():
return datatype.index_of(datatype.validate(value))
if len(datatype.members) <= MBB_MAX_CHOICES:
return datatype.index_of(datatype.validate(value))
else: # enum backed by string record
return datatype.validate(value).name
case datatype if issubclass(type(datatype), EPICS_ALLOWED_DATATYPES):
return datatype.validate(value)
case _:
Expand All @@ -119,7 +133,7 @@ def builder_callable_from_attribute(
return builder.longStringIn if make_in_record else builder.longStringOut
case Enum():
if len(attribute.datatype.members) > MBB_MAX_CHOICES:
return builder.longIn if make_in_record else builder.longOut
return builder.longStringIn if make_in_record else builder.longStringOut
else:
return builder.mbbIn if make_in_record else builder.mbbOut
case Waveform():
Expand Down
4 changes: 1 addition & 3 deletions src/fastcs/transport/epics/gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,7 @@ def _get_write_widget(attribute: AttrW) -> WriteWidgetUnion | None:
case String():
return TextWrite(format=TextFormat.string)
case Enum():
return ComboBox(
choices=[member.name for member in attribute.datatype.members]
)
return ComboBox(choices=attribute.datatype.names)
case Waveform():
return None
case datatype:
Expand Down
2 changes: 1 addition & 1 deletion src/fastcs/transport/epics/pva/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ def cast_to_p4p_value(attribute: Attribute[T], value: T) -> object:
case Enum():
return {
"index": attribute.datatype.index_of(value),
"choices": [member.name for member in attribute.datatype.members],
"choices": attribute.datatype.names,
}
case Waveform():
return attribute.datatype.validate(value)
Expand Down
44 changes: 35 additions & 9 deletions tests/transport/epics/ca/test_softioc.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@
_make_record,
)
from fastcs.transport.epics.ca.util import (
MBB_STATE_FIELDS,
record_metadata_from_attribute,
record_metadata_from_datatype,
)
Expand All @@ -45,12 +44,6 @@ class OnOffStates(enum.IntEnum):
ENABLED = 1


def record_input_from_enum(enum_cls: type[enum.IntEnum]) -> dict[str, str]:
return dict(
zip(MBB_STATE_FIELDS, [member.name for member in enum_cls], strict=False)
)


@pytest.mark.asyncio
async def test_create_and_link_read_pv(mocker: MockerFixture):
make_record = mocker.patch("fastcs.transport.epics.ca.ioc._make_record")
Expand Down Expand Up @@ -127,7 +120,9 @@ async def test_create_and_link_write_pv(mocker: MockerFixture):

_create_and_link_write_pv("PREFIX", "PV", "attr", attribute)

make_record.assert_called_once_with("PREFIX:PV", attribute, on_update=mocker.ANY)
make_record.assert_called_once_with(
"PREFIX:PV", attribute, on_update=mocker.ANY, out_record=True
)
add_attr_pvi_info.assert_called_once_with(record, "PREFIX", "attr", "w")

# Extract the write update callback generated and set in the function and call it
Expand All @@ -144,6 +139,26 @@ async def test_create_and_link_write_pv(mocker: MockerFixture):
attribute.process_without_display_update.assert_called_once_with(1)


class LongEnum(enum.Enum):
THIS = 0
IS = 1
AN = 2
ENUM = 3
WITH = 4
ALTOGETHER = 5
TOO = 6
MANY = 7
VALUES = 8
TO = 9
BE = 10
DESCRIBED = 11
BY = 12
MBB = 14
TYPE = 15
EPICS = 16
RECORDS = 17


@pytest.mark.parametrize(
"attribute,record_type,kwargs",
(
Expand All @@ -164,7 +179,7 @@ def test_make_output_record(
update = mocker.MagicMock()

pv = "PV"
_make_record(pv, attribute, on_update=update)
_make_record(pv, attribute, on_update=update, out_record=True)

kwargs.update(record_metadata_from_datatype(attribute.datatype))
kwargs.update(record_metadata_from_attribute(attribute))
Expand All @@ -176,6 +191,17 @@ def test_make_output_record(
)


def test_long_enum_validator(mocker: MockerFixture):
builder = mocker.patch("fastcs.transport.epics.ca.util.builder")
update = mocker.MagicMock()
attribute = AttrRW(Enum(LongEnum))
pv = "PV"
record = _make_record(pv, attribute, on_update=update, out_record=True)
validator = builder.longStringOut.call_args.kwargs["validate"]
assert validator(record, "THIS") # value is one of the Enum names
assert not validator(record, "an invalid string value")


def test_get_output_record_raises(mocker: MockerFixture):
# Pass a mock as attribute to provoke the fallback case matching on datatype
with pytest.raises(FastCSException):
Expand Down
141 changes: 141 additions & 0 deletions tests/transport/epics/ca/test_util.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import enum

import pytest
from softioc import builder

from fastcs.attributes import AttrRW
from fastcs.datatypes import Bool, Enum, Float, Int, String
from fastcs.transport.epics.ca.util import (
builder_callable_from_attribute,
cast_from_epics_type,
cast_to_epics_type,
)


class ShortEnum(enum.Enum):
NOT = 0
TOO = 1
MANY = 2
VALUES = 3


class LongEnum(enum.Enum):
THIS = 0
IS = 1
AN = 2
ENUM = 3
WITH = 4
ALTOGETHER = 5
TOO = 6
MANY = 7
VALUES = 8
TO = 9
BE = 10
DESCRIBED = 11
BY = 12
MBB = 14
TYPE = 15
EPICS = 16
RECORDS = 17


class LongMixedEnum(enum.Enum):
THIS = "the value is THIS"
IS = 1
AN = "the value is AN"
ENUM = 3
WITH = "the value is WITH"
ALTOGETHER = 5
TOO = "the value is TOO"
MANY = 7
VALUES = "the value is VALUES"
TO = 9
BE = "the value is BE"
DESCRIBED = 11
BY = "the value is BY"
MBB = 13
TYPE = "the value is TYPE"
EPICS = None
RECORDS = "the value is RECORDS"


class ShortMixedEnum(enum.Enum):
STRING_MEMBER = "I am a string"
INT_MEMBER = 2
NONE_MEMBER = None


@pytest.mark.parametrize(
"datatype,input,output",
[
(Enum(ShortEnum), ShortEnum.TOO, 1),
# in CA, enums with too many values become epics strings
(Enum(LongMixedEnum), LongMixedEnum.BE, "BE"), # string value
(Enum(LongMixedEnum), LongMixedEnum.EPICS, "EPICS"), # None value
(Enum(LongMixedEnum), LongMixedEnum.MBB, "MBB"), # int value
(Int(), 4, 4),
(Float(), 1.0, 1.0),
(Bool(), True, True),
(String(), "hey", "hey"),
# shorter enums can be represented by integers from 0-15
(Enum(ShortMixedEnum), ShortMixedEnum.STRING_MEMBER, 0),
(Enum(ShortMixedEnum), ShortMixedEnum.INT_MEMBER, 1),
(Enum(ShortMixedEnum), ShortMixedEnum.NONE_MEMBER, 2),
],
)
def test_casting_to_epics(datatype, input, output):
assert cast_to_epics_type(datatype, input) == output


@pytest.mark.parametrize(
"datatype, input",
[
# TODO cover Waveform and Table cases
(Enum(ShortEnum), 0), # can't use index
(Enum(ShortEnum), LongEnum.TOO), # wrong enum.Enum class
(Int(), 4.0),
(Float(), 1),
(Bool(), None),
(String(), 10),
],
)
def test_cast_to_epics_validations(datatype, input):
with pytest.raises(ValueError):
cast_to_epics_type(datatype, input)


@pytest.mark.parametrize(
"datatype,from_epics,result",
[
# long enums backed by strings
(Enum(LongMixedEnum), "BE", LongMixedEnum.BE), # string value
(Enum(LongMixedEnum), "EPICS", LongMixedEnum.EPICS), # None value
(Enum(LongMixedEnum), "MBB", LongMixedEnum.MBB), # int value
(Int(), 4, 4),
(Float(), 1.0, 1.0),
(Bool(), True, True),
(String(), "hey", "hey"),
(Enum(ShortEnum), 2, ShortEnum.MANY),
# short enums backed by mbbi/mbbo
(Enum(ShortMixedEnum), 0, ShortMixedEnum.STRING_MEMBER),
(Enum(ShortMixedEnum), 1, ShortMixedEnum.INT_MEMBER),
(Enum(ShortMixedEnum), 2, ShortMixedEnum.NONE_MEMBER),
],
)
def test_cast_from_epics_type(datatype, from_epics, result):
assert cast_from_epics_type(datatype, from_epics) == result


@pytest.mark.parametrize(
"datatype,in_record,out_record",
[
(Enum(ShortEnum), builder.mbbIn, builder.mbbOut),
# long enums use string even if all values are ints
(Enum(LongEnum), builder.longStringIn, builder.longStringOut),
(Enum(LongMixedEnum), builder.longStringIn, builder.longStringOut),
],
)
def test_builder_callable_enum_types(datatype, in_record, out_record):
attr = AttrRW(datatype)
assert builder_callable_from_attribute(attr, False) == out_record
assert builder_callable_from_attribute(attr, True) == in_record
Loading