diff --git a/src/fastcs/transport/epics/gui.py b/src/fastcs/transport/epics/gui.py index c7fbff30a..698102b9a 100644 --- a/src/fastcs/transport/epics/gui.py +++ b/src/fastcs/transport/epics/gui.py @@ -2,6 +2,7 @@ from pvi.device import ( LED, ButtonPanel, + CheckBox, ComboBox, ComponentUnion, Device, @@ -13,6 +14,8 @@ SignalW, SignalX, SubScreen, + TableRead, + TableWrite, TextFormat, TextRead, TextWrite, @@ -25,9 +28,18 @@ from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW from fastcs.controller_api import ControllerAPI from fastcs.cs_methods import Command -from fastcs.datatypes import Bool, Enum, Float, Int, String, Waveform +from fastcs.datatypes import ( + Bool, + DataType, + Enum, + Float, + Int, + String, + Table, + Waveform, +) from fastcs.exceptions import FastCSException -from fastcs.util import snake_to_pascal +from fastcs.util import numpy_to_fastcs_datatype, snake_to_pascal from .options import EpicsGUIFormat, EpicsGUIOptions @@ -40,12 +52,13 @@ def __init__(self, controller_api: ControllerAPI, pv_prefix: str) -> None: self._pv_prefix = pv_prefix def _get_pv(self, attr_path: list[str], name: str): - attr_prefix = ":".join([self._pv_prefix] + attr_path) + attr_prefix = ":".join( + [self._pv_prefix] + [snake_to_pascal(node) for node in attr_path] + ) return f"{attr_prefix}:{snake_to_pascal(name)}" - @staticmethod - def _get_read_widget(attribute: AttrR) -> ReadWidgetUnion | None: - match attribute.datatype: + def _get_read_widget(self, fastcs_datatype: DataType) -> ReadWidgetUnion | None: + match fastcs_datatype: case Bool(): return LED() case Int() | Float(): @@ -59,9 +72,8 @@ def _get_read_widget(attribute: AttrR) -> ReadWidgetUnion | None: case datatype: raise FastCSException(f"Unsupported type {type(datatype)}: {datatype}") - @staticmethod - def _get_write_widget(attribute: AttrW) -> WriteWidgetUnion | None: - match attribute.datatype: + def _get_write_widget(self, fastcs_datatype: DataType) -> WriteWidgetUnion | None: + match fastcs_datatype: case Bool(): return ToggleButton() case Int() | Float(): @@ -69,7 +81,7 @@ def _get_write_widget(attribute: AttrW) -> WriteWidgetUnion | None: case String(): return TextWrite(format=TextFormat.string) case Enum(): - return ComboBox(choices=attribute.datatype.names) + return ComboBox(choices=fastcs_datatype.names) case Waveform(): return None case datatype: @@ -82,8 +94,8 @@ def _get_attribute_component( name = snake_to_pascal(name) match attribute: case AttrRW(): - read_widget = self._get_read_widget(attribute) - write_widget = self._get_write_widget(attribute) + read_widget = self._get_read_widget(attribute.datatype) + write_widget = self._get_write_widget(attribute.datatype) if write_widget is None or read_widget is None: return None return SignalRW( @@ -94,12 +106,12 @@ def _get_attribute_component( read_widget=read_widget, ) case AttrR(): - read_widget = self._get_read_widget(attribute) + read_widget = self._get_read_widget(attribute.datatype) if read_widget is None: return None return SignalR(name=name, read_pv=pv, read_widget=read_widget) case AttrW(): - write_widget = self._get_write_widget(attribute) + write_widget = self._get_write_widget(attribute.datatype) if write_widget is None: return None return SignalW(name=name, write_pv=pv, write_widget=write_widget) @@ -188,3 +200,39 @@ def extract_api_components(self, controller_api: ControllerAPI) -> Tree: components.append(Group(name=name, layout=Grid(), children=children)) return components + + +class PvaEpicsGUI(EpicsGUI): + """For creating gui in the PVA EPICS transport.""" + + def _get_pv(self, attr_path: list[str], name: str): + return f"pva://{super()._get_pv(attr_path, name)}" + + def _get_read_widget(self, fastcs_datatype: DataType) -> ReadWidgetUnion | None: + if isinstance(fastcs_datatype, Table): + fastcs_datatypes = [ + numpy_to_fastcs_datatype(datatype) + for _, datatype in fastcs_datatype.structured_dtype + ] + + base_get_read_widget = super()._get_read_widget + widgets = [base_get_read_widget(datatype) for datatype in fastcs_datatypes] + + return TableRead(widgets=widgets) # type: ignore + else: + return super()._get_read_widget(fastcs_datatype) + + def _get_write_widget(self, fastcs_datatype: DataType) -> WriteWidgetUnion | None: + if isinstance(fastcs_datatype, Table): + widgets = [] + for _, datatype in fastcs_datatype.structured_dtype: + fastcs_datatype = numpy_to_fastcs_datatype(datatype) + if isinstance(fastcs_datatype, Bool): + # Replace with compact version for Table row + widget = CheckBox() + else: + widget = super()._get_write_widget(fastcs_datatype) + widgets.append(widget) + return TableWrite(widgets=widgets) + else: + return super()._get_write_widget(fastcs_datatype) diff --git a/src/fastcs/transport/epics/pva/adapter.py b/src/fastcs/transport/epics/pva/adapter.py index d07aafa00..569442e23 100644 --- a/src/fastcs/transport/epics/pva/adapter.py +++ b/src/fastcs/transport/epics/pva/adapter.py @@ -1,7 +1,7 @@ from fastcs.controller_api import ControllerAPI from fastcs.transport.adapter import TransportAdapter from fastcs.transport.epics.docs import EpicsDocs -from fastcs.transport.epics.gui import EpicsGUI +from fastcs.transport.epics.gui import PvaEpicsGUI from fastcs.transport.epics.pva.options import EpicsPVAOptions from .ioc import P4PIOC @@ -32,4 +32,4 @@ def create_docs(self) -> None: EpicsDocs(self._controller_api).create_docs(self.options.docs) def create_gui(self) -> None: - EpicsGUI(self._controller_api, self._pv_prefix).create_gui(self.options.gui) + PvaEpicsGUI(self._controller_api, self._pv_prefix).create_gui(self.options.gui) diff --git a/src/fastcs/util.py b/src/fastcs/util.py index 1ec8c3925..e4f526a49 100644 --- a/src/fastcs/util.py +++ b/src/fastcs/util.py @@ -1,5 +1,9 @@ import re +import numpy as np + +from fastcs.datatypes import Bool, DataType, Float, Int, String + def snake_to_pascal(name: str) -> str: """Converts string from snake case to Pascal case. @@ -8,3 +12,17 @@ def snake_to_pascal(name: str) -> str: if re.fullmatch(r"[a-z][a-z0-9]*(?:_[a-z0-9]+)*", name): name = re.sub(r"(?:^|_)([a-z0-9])", lambda match: match.group(1).upper(), name) return name + + +def numpy_to_fastcs_datatype(np_type) -> DataType: + """Converts numpy types to fastcs types for widget creation. + Only types important for widget creation are explicitly converted + """ + if np.issubdtype(np_type, np.integer): + return Int() + elif np.issubdtype(np_type, np.floating): + return Float() + elif np.issubdtype(np_type, np.bool_): + return Bool() + else: + return String() diff --git a/tests/test_util.py b/tests/test_util.py index c60711386..f6cb87b3c 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,8 +1,10 @@ +import numpy as np import pytest from pvi.device import SignalR from pydantic import ValidationError -from fastcs.util import snake_to_pascal +from fastcs.datatypes import Bool, Float, Int, String +from fastcs.util import numpy_to_fastcs_datatype, snake_to_pascal def test_snake_to_pascal(): @@ -36,3 +38,21 @@ def test_pvi_validation_error(): name = snake_to_pascal("Name-With_%_Invalid-&-Symbols_£_") with pytest.raises(ValidationError): SignalR(name=name, read_pv="test") + + +@pytest.mark.parametrize( + "numpy_type, fastcs_datatype", + [ + (np.float16, Float()), + (np.float32, Float()), + (np.int16, Int()), + (np.int32, Int()), + (np.bool, Bool()), + (np.dtype("S1000"), String()), + (np.dtype("U25"), String()), + (np.dtype(">i4"), Int()), + (np.dtype("d"), Float()), + ], +) +def test_numpy_to_fastcs_datatype(numpy_type, fastcs_datatype): + assert fastcs_datatype == numpy_to_fastcs_datatype(numpy_type) diff --git a/tests/transport/epics/ca/test_gui.py b/tests/transport/epics/ca/test_gui.py index cf1d47b8c..80a6da4dc 100644 --- a/tests/transport/epics/ca/test_gui.py +++ b/tests/transport/epics/ca/test_gui.py @@ -78,12 +78,14 @@ def test_get_attribute_component_none(mocker, controller_api): assert gui._get_attribute_component([], "Attr", AttrRW(Int())) is None -def test_get_read_widget_none(): - assert EpicsGUI._get_read_widget(AttrR(Waveform(np.int32))) is None +def test_get_read_widget_none(controller_api): + gui = EpicsGUI(controller_api, "DEVICE") + assert gui._get_read_widget(fastcs_datatype=Waveform(np.int32)) is None -def test_get_write_widget_none(): - assert EpicsGUI._get_write_widget(AttrW(Waveform(np.int32))) is None +def test_get_write_widget_none(controller_api): + gui = EpicsGUI(controller_api, "DEVICE") + assert gui._get_write_widget(fastcs_datatype=Waveform(np.int32)) is None def test_get_components(controller_api): diff --git a/tests/transport/epics/pva/test_pva_gui.py b/tests/transport/epics/pva/test_pva_gui.py new file mode 100644 index 000000000..b83e8a102 --- /dev/null +++ b/tests/transport/epics/pva/test_pva_gui.py @@ -0,0 +1,76 @@ +import numpy as np +from pvi.device import ( + LED, + CheckBox, + SignalR, + SignalW, + TableRead, + TableWrite, + TextFormat, + TextRead, + TextWrite, +) + +from fastcs.attributes import AttrR, AttrW +from fastcs.datatypes import Table +from fastcs.transport.epics.gui import PvaEpicsGUI + + +def test_get_pv_in_pva(controller_api): + gui = PvaEpicsGUI(controller_api, "DEVICE") + + assert gui._get_pv([], "A") == "pva://DEVICE:A" + assert gui._get_pv(["B"], "C") == "pva://DEVICE:B:C" + assert gui._get_pv(["D", "E"], "F") == "pva://DEVICE:D:E:F" + + +def test_get_attribute_component_table_write(controller_api): + gui = PvaEpicsGUI(controller_api, "DEVICE") + + attribute_component = gui._get_attribute_component( + [], + "Table", + AttrW( + Table( + structured_dtype=[ + ("FIELD1", np.uint32), + ("FIELD2", np.bool), + ("FIELD3", np.dtype("S1000")), + ] + ) + ), + ) + + assert isinstance(attribute_component, SignalW) + assert isinstance(attribute_component.write_widget, TableWrite) + assert attribute_component.write_widget.widgets == [ + TextWrite(), + CheckBox(), + TextWrite(format=TextFormat.string), + ] + + +def test_get_attribute_component_table_read(controller_api): + gui = PvaEpicsGUI(controller_api, "DEVICE") + + attribute_component = gui._get_attribute_component( + [], + "Table", + AttrR( + Table( + structured_dtype=[ + ("FIELD1", np.uint32), + ("FIELD2", np.bool), + ("FIELD3", np.dtype("S1000")), + ] + ) + ), + ) + + assert isinstance(attribute_component, SignalR) + assert isinstance(attribute_component.read_widget, TableRead) + assert attribute_component.read_widget.widgets == [ + TextRead(), + LED(), + TextRead(format=TextFormat.string), + ]