diff --git a/.vscode/launch.json b/.vscode/launch.json index 36d8f50..5917463 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,19 +5,17 @@ "version": "0.2.0", "configurations": [ { - "name": "Debug Unit Test", + "name": "panda", "type": "debugpy", "request": "launch", + "module": "fastcs_pandablocks", "justMyCode": false, - "program": "${file}", - "purpose": [ - "debug-test" - ], "console": "integratedTerminal", - "env": { - // Enable break on exception when debugging tests (see: tests/conftest.py) - "PYTEST_RAISE": "1", - }, + "args": [ + "run", + "172.23.252.201", + "TEST-PANDA" + ] } ] } diff --git a/pyproject.toml b/pyproject.toml index 6f4bf04..85c5fcb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ classifiers = [ ] description = "A softioc to control a PandABlocks-FPGA." dependencies = [ - "fastcs~=0.9.0", + "fastcs~=0.10.1", "pandablocks~=0.10.0", "numpy<2", # until https://github.com/mdavidsaver/p4p/issues/145 is fixed "pydantic>2", diff --git a/src/fastcs_pandablocks/__init__.py b/src/fastcs_pandablocks/__init__.py index 7efa4e1..4283559 100644 --- a/src/fastcs_pandablocks/__init__.py +++ b/src/fastcs_pandablocks/__init__.py @@ -33,6 +33,7 @@ def ioc( controller = PandaController(hostname, poll_period) transport = FastCS(controller, [p4p_ioc_options]) + transport.create_gui() transport.run() diff --git a/src/fastcs_pandablocks/panda/blocks/blocks.py b/src/fastcs_pandablocks/panda/blocks/blocks.py index 38eae6f..5c890a9 100644 --- a/src/fastcs_pandablocks/panda/blocks/blocks.py +++ b/src/fastcs_pandablocks/panda/blocks/blocks.py @@ -23,6 +23,7 @@ TimeFieldInfo, UintFieldInfo, ) +from pandablocks.utils import words_to_table from fastcs_pandablocks.panda.client_wrapper import RawPanda from fastcs_pandablocks.panda.handlers import ( @@ -33,6 +34,7 @@ DefaultFieldSender, DefaultFieldUpdater, TableFieldHandler, + panda_value_to_attribute_value, ) from fastcs_pandablocks.types import ( PandaName, @@ -211,7 +213,7 @@ def add_field_to_block( match field_info: case TableFieldInfo(): return self._make_table_field( - parent_block, field_panda_name, field_info + parent_block, field_panda_name, field_info, initial_values ) case TimeFieldInfo(subtype=None): self._make_time_param( @@ -359,17 +361,30 @@ def _make_table_field( parent_block: BlockController, panda_name: PandaName, field_info: TableFieldInfo, + initial_values: RawInitialValuesType, ): structured_datatype = [ (name, self._table_datatypes_from_table_field_details(details)) for name, details in field_info.fields.items() ] + + initial_value = panda_value_to_attribute_value( + fastcs_datatype=Table(structured_datatype), + value=words_to_table( + words=initial_values[panda_name], table_field_info=field_info + ), + ) + # TODO: Add units handler to update the units field and value of this one PV # https://github.com/PandABlocks/PandABlocks-ioc/blob/c1e8056abf3f680fa3840493eb4ac6ca2be31313/src/pandablocks_ioc/ioc.py#L750-L769 - parent_block.attributes[panda_name.attribute_name] = AttrRW( + attribute = AttrRW( Table(structured_datatype), - handler=TableFieldHandler(panda_name), + handler=TableFieldHandler( + panda_name, field_info, self._raw_panda.put_value_to_panda + ), + initial_value=initial_value, ) + parent_block.add_attribute(panda_name, attribute) def _make_time_param( self, @@ -608,16 +623,17 @@ def _make_bit_mux( bit_mux_field_info: BitMuxFieldInfo, initial_values: RawInitialValuesType, ): + enum_type = enum.Enum("Labels", bit_mux_field_info.labels) parent_block.add_attribute( panda_name, AttrRW( - String(), + Enum(enum_type), description=bit_mux_field_info.description, handler=DefaultFieldHandler( panda_name, self._raw_panda.put_value_to_panda ), group=WidgetGroup.INPUTS.value, - initial_value=initial_values[panda_name], + initial_value=enum_type[initial_values[panda_name]], ), ) @@ -628,7 +644,7 @@ def _make_bit_mux( Int(min=0, max=bit_mux_field_info.max_delay), description="Clock delay on input.", handler=DefaultFieldHandler( - panda_name, self._raw_panda.put_value_to_panda + delay_panda_name, self._raw_panda.put_value_to_panda ), group=WidgetGroup.INPUTS.value, initial_value=int(initial_values[delay_panda_name]), diff --git a/src/fastcs_pandablocks/panda/client_wrapper.py b/src/fastcs_pandablocks/panda/client_wrapper.py index cd9daaf..0ebc513 100644 --- a/src/fastcs_pandablocks/panda/client_wrapper.py +++ b/src/fastcs_pandablocks/panda/client_wrapper.py @@ -28,7 +28,7 @@ RawInitialValuesType, ) -from .handlers import attribute_value_to_panda_value +LOGGER = logging.getLogger(__name__) class RawPanda: @@ -44,11 +44,12 @@ async def disconnect(self): await self._client.close() async def put_value_to_panda( - self, panda_name: PandaName, fastcs_datatype: DataType[T], value: T + self, + panda_name: PandaName, + fastcs_datatype: DataType[T], + value: str | list[str], ) -> None: - await self.send( - str(panda_name), attribute_value_to_panda_value(fastcs_datatype, value) - ) + await self.send(str(panda_name), value) async def introspect( self, @@ -63,7 +64,7 @@ async def introspect( for name, block_info in raw_blocks.items() } formatted_blocks = pformat(blocks, indent=4).replace("\n", "\n ") - logging.debug(f"BLOCKS RECEIVED:\n {formatted_blocks}") + LOGGER.debug(f"BLOCKS RECEIVED:\n {formatted_blocks}") raw_fields = await asyncio.gather( *[self._client.send(GetFieldInfo(str(block))) for block in blocks] @@ -75,9 +76,9 @@ async def introspect( } for block_values in raw_fields ] - logging.debug("FIELDS RECEIVED (TOO VERBOSE TO LOG)") + LOGGER.debug("FIELDS RECEIVED (TOO VERBOSE TO LOG)") - field_data = (await self._client.send(GetChanges(ChangeGroup.ALL, True))).values + field_data = await self.get_changes() for field_name, value in field_data.items(): if field_name.startswith("*METADATA"): @@ -85,8 +86,8 @@ async def introspect( if field_name_without_prefix == "DESIGN": continue # TODO: Handle design. elif not field_name_without_prefix.startswith("LABEL_"): - raise TypeError( - "Received metadata not corresponding to a `LABEL_`: " + logging.warning( + "Ignoring received metadata not corresponding to a `LABEL_`: " f"{field_name} = {value}." ) labels[ @@ -100,27 +101,33 @@ async def introspect( formatted_initial_values = pformat(initial_values, indent=4).replace( "\n", "\n " ) - logging.debug(f"INITIAL VALUES:\n {formatted_initial_values}") + LOGGER.debug(f"INITIAL VALUES:\n {formatted_initial_values}") formatted_labels = pformat(labels, indent=4).replace("\n", "\n ") logging.debug(f"LABELS:\n {formatted_labels}") return blocks, fields, labels, initial_values - async def send(self, name: str, value: str): - logging.debug(f"SENDING TO PANDA:\n {name} = {pformat(value, indent=4)}") + async def send(self, name: str, value: str | list[str]): + LOGGER.debug(f"SENDING TO PANDA:\n {name} = {pformat(value, indent=4)}") await self._client.send(Put(name, value)) async def get(self, name: str) -> str | list[str]: received = await self._client.send(Get(name)) formatted_received = pformat(received, indent=4).replace("\n", "\n ") - logging.debug(f"RECEIVED FROM PANDA:\n {name} = {formatted_received}") + LOGGER.debug(f"RECEIVED FROM PANDA:\n {name} = {formatted_received}") return received - async def get_changes(self) -> dict[str, str]: - received = (await self._client.send(GetChanges(ChangeGroup.ALL, False))).values - formatted_received = pformat(received, indent=4).replace("\n", "\n ") - logging.debug(f"RECEIVED CHANGES:\n {formatted_received}") - return received + async def get_changes(self) -> dict[str, str | list[str]]: + changes = await self._client.send(GetChanges(ChangeGroup.ALL, True)) + single_and_multiline_changes = { + **changes.values, + **changes.multiline_values, + } + formatted_received = pformat(single_and_multiline_changes, indent=4).replace( + "\n", "\n " + ) + LOGGER.debug(f"RECEIVED CHANGES:\n {formatted_received}") + return single_and_multiline_changes async def arm(self): await self._client.send(Arm()) diff --git a/src/fastcs_pandablocks/panda/handlers.py b/src/fastcs_pandablocks/panda/handlers.py index 55770fe..97283f8 100644 --- a/src/fastcs_pandablocks/panda/handlers.py +++ b/src/fastcs_pandablocks/panda/handlers.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from typing import Any +import numpy as np from fastcs.attributes import ( AttrHandlerR, AttrHandlerRW, @@ -13,12 +14,16 @@ AttrRW, AttrW, ) -from fastcs.datatypes import Bool, DataType, Enum, Float, Int, String, T +from fastcs.datatypes import Bool, DataType, Enum, Float, Int, String, T, Table +from pandablocks.responses import TableFieldInfo +from pandablocks.utils import table_to_words from fastcs_pandablocks.types import PandaName -def panda_value_to_attribute_value(fastcs_datatype: DataType[T], value: str) -> T: +def panda_value_to_attribute_value( + fastcs_datatype: DataType[T], value: str | dict +) -> T: """Converts from a value received from the panda through pandablock-client to the attribute value. """ @@ -26,17 +31,26 @@ def panda_value_to_attribute_value(fastcs_datatype: DataType[T], value: str) -> case String(): return value case Bool(): - return str(int(value)) + return bool(int(value)) case Int() | Float(): return fastcs_datatype.dtype(value) case Enum(): return fastcs_datatype.enum_cls[value] + case Table(): + num_rows = len(next(iter(value.values()))) + structured_datatype = fastcs_datatype.structured_dtype + attribute_value = np.zeros(num_rows, fastcs_datatype.structured_dtype) + for field_name, _ in structured_datatype: + attribute_value[field_name] = value[field_name] + return attribute_value case _: raise NotImplementedError(f"Unknown datatype {fastcs_datatype}") -def attribute_value_to_panda_value(fastcs_datatype: DataType[T], value: T) -> str: +def attribute_value_to_panda_value( + fastcs_datatype: DataType[T], value: T +) -> str | dict: """Converts from an attribute value to a value that can be sent to the panda with pandablocks-client. """ @@ -49,7 +63,12 @@ def attribute_value_to_panda_value(fastcs_datatype: DataType[T], value: T) -> st return str(value) case Enum(): return value.name - + case Table(): + assert isinstance(value, np.ndarray) + panda_value = {} + for field_name, _ in fastcs_datatype.structured_dtype: + panda_value[field_name] = value[field_name].tolist() + return panda_value case _: raise NotImplementedError(f"Unknown datatype {fastcs_datatype}") @@ -67,13 +86,13 @@ def __init__( self.panda_name = panda_name self.put_value_to_panda = put_value_to_panda - async def update(self, attr: AttrR) -> None: - # TODO: Convert to panda value - ... - async def put(self, attr: AttrW, value: Any) -> None: # TODO: Convert to attribtue value - ... + await self.put_value_to_panda( + self.panda_name, + attr.datatype, + attribute_value_to_panda_value(attr.datatype, value), + ) class DefaultFieldUpdater(AttrHandlerR): @@ -85,6 +104,8 @@ class DefaultFieldUpdater(AttrHandlerR): def __init__(self, panda_name: PandaName): self.panda_name = panda_name + async def update(self, attr: AttrR) -> None: ... + class DefaultFieldHandler(DefaultFieldSender, DefaultFieldUpdater, AttrHandlerRW): """Default handler for sending and updating introspected attributes.""" @@ -102,16 +123,23 @@ def __init__( class TableFieldHandler(AttrHandlerRW): """A handler for updating Table valued attributes.""" - def __init__(self, panda_name: PandaName): + def __init__( + self, + panda_name: PandaName, + field_info: TableFieldInfo, + put_value_to_panda: Callable[ + [PandaName, DataType, Any], Coroutine[None, None, None] + ], + ): self.panda_name = panda_name - - async def update(self, attr: AttrR) -> None: - # TODO: Convert to panda value - ... + self.field_info = field_info + self.put_value_to_panda = put_value_to_panda async def put(self, attr: AttrW, value: Any) -> None: - # TODO: Convert to attribtue value - ... + attr_value = attribute_value_to_panda_value(attr.datatype, value) + assert isinstance(attr_value, dict) + panda_words = table_to_words(attr_value, self.field_info) + await self.put_value_to_panda(self.panda_name, attr.datatype, panda_words) class CaptureHandler(DefaultFieldHandler): diff --git a/src/fastcs_pandablocks/panda/panda_controller.py b/src/fastcs_pandablocks/panda/panda_controller.py index 9687a60..e8169aa 100644 --- a/src/fastcs_pandablocks/panda/panda_controller.py +++ b/src/fastcs_pandablocks/panda/panda_controller.py @@ -1,14 +1,22 @@ import asyncio +import logging +from typing import Any from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW from fastcs.controller import Controller from fastcs.cs_methods import Scan +from fastcs.datatypes import Table +from pandablocks.utils import words_to_table from fastcs_pandablocks.panda.blocks import Blocks from fastcs_pandablocks.panda.client_wrapper import RawPanda +from fastcs_pandablocks.panda.handlers import ( + TableFieldHandler, + panda_value_to_attribute_value, +) from fastcs_pandablocks.types import PandaName -from .handlers import panda_value_to_attribute_value +LOGGER = logging.getLogger(__name__) class PandaController(Controller): @@ -43,23 +51,66 @@ async def initialise(self) -> None: for block_name, block in self._blocks.controllers(): self.register_sub_controller(block_name, block) - async def update_field_value(self, panda_name: PandaName, value: str): + async def update_field_value(self, panda_name: PandaName, value: str | list[str]): + """Update a panda field with either a single value or a list of words.""" + attribute = self._blocks.get_attribute(panda_name) - attribute_value = panda_value_to_attribute_value(attribute.datatype, value) + if attribute is None: + LOGGER.error(f"Couldn't find panda field for {panda_name}.") + return + + try: + attribute_value = self._coerce_value_to_panda_type(attribute, value) + except ValueError as e: + LOGGER.error(str(e)) + return + + await self.update_attribute(attribute, attribute_value) + + def _coerce_value_to_panda_type( + self, attribute: Attribute, value: str | list[str] + ) -> Any: + """Convert a provided value into an attribute_value for this panda attribute.""" + match value: + case list() as words: + if not isinstance(attribute.datatype, Table): + raise ValueError(f"{attribute} is not a Table attribute") + sender = getattr(attribute, "sender", None) + if not isinstance(sender, TableFieldHandler): + raise ValueError(f"Sender for {attribute} is not TableFieldHandler") + table_values = words_to_table(words, sender.field_info) + return panda_value_to_attribute_value(attribute.datatype, table_values) + case _: + return panda_value_to_attribute_value(attribute.datatype, value) - if isinstance(attribute, AttrW) and not isinstance(attribute, AttrRW): - await attribute.process(attribute_value) - elif isinstance(attribute, AttrR): - await attribute.set(attribute_value) - else: - raise RuntimeError(f"Couldn't find panda field for {panda_name}.") + async def update_attribute( + self, attribute: Attribute, attribute_value: Any + ) -> None: + """Dispatch setting logic based on attribute type.""" + match attribute: + case AttrRW(): + await attribute.set(attribute_value) + await attribute.update_display_without_process(attribute_value) + case AttrW(): + await attribute.process(attribute_value) + case AttrR(): + await attribute.set(attribute_value) async def _update(self): - changes = await self._raw_panda.get_changes() - - await asyncio.gather( - *[ - self.update_field_value(PandaName.from_string(raw_panda_name), value) - for raw_panda_name, value in changes.items() - ] - ) + try: + changes = await self._raw_panda.get_changes() + await asyncio.gather( + *[ + self.update_field_value( + PandaName.from_string(raw_panda_name), value + ) + for raw_panda_name, value in changes.items() + ] + ) + # TODO: General exception is not ideal; narrow this dowm. + except Exception as e: + LOGGER.error( + f"Failed to update changes from PandaBlocks client: {e}", + stack_info=True, + exc_info=True, + )