From 07bcbc4e7a2a3d4687885a168504ec284fc7f737 Mon Sep 17 00:00:00 2001 From: Gary Yendell Date: Tue, 27 Jan 2026 13:36:33 +0000 Subject: [PATCH] Improve handling of failed scan tasks and allow reconnect Pause all scan tasks when an exception occurs in one Add `Controller.reconnect` method to reocnnect and unpause scan tasks Move creation of initial tasks and scan tasks into Controller --- src/fastcs/attributes/attr_r.py | 16 +-- src/fastcs/connections/ip_connection.py | 11 +- src/fastcs/control_system.py | 36 +----- src/fastcs/controllers/__init__.py | 1 + src/fastcs/controllers/base_controller.py | 16 ++- src/fastcs/controllers/controller.py | 109 ++++++++++++++++++- src/fastcs/controllers/controller_api.py | 38 +++++++ src/fastcs/demo/controllers.py | 6 + src/fastcs/transports/__init__.py | 1 - src/fastcs/transports/controller_api.py | 107 ------------------ src/fastcs/transports/epics/ca/ioc.py | 2 +- src/fastcs/transports/epics/ca/transport.py | 2 +- src/fastcs/transports/epics/docs.py | 2 +- src/fastcs/transports/epics/gui.py | 2 +- src/fastcs/transports/epics/pva/ioc.py | 2 +- src/fastcs/transports/epics/pva/pvi.py | 2 +- src/fastcs/transports/epics/pva/transport.py | 2 +- src/fastcs/transports/epics/util.py | 2 +- src/fastcs/transports/graphql/graphql.py | 2 +- src/fastcs/transports/graphql/transport.py | 2 +- src/fastcs/transports/rest/rest.py | 2 +- src/fastcs/transports/rest/transport.py | 2 +- src/fastcs/transports/tango/dsr.py | 2 +- src/fastcs/transports/tango/transport.py | 2 +- src/fastcs/transports/transport.py | 2 +- tests/assertable_controller.py | 11 +- tests/conftest.py | 3 +- tests/test_control_system.py | 74 +------------ tests/test_controllers.py | 55 +++++++++- tests/transports/epics/ca/test_gui.py | 2 +- tests/transports/epics/ca/test_softioc.py | 3 +- tests/transports/epics/pva/test_pva_gui.py | 2 +- tests/transports/rest/test_rest.py | 2 +- 33 files changed, 269 insertions(+), 254 deletions(-) create mode 100644 src/fastcs/controllers/controller_api.py delete mode 100644 src/fastcs/transports/controller_api.py diff --git a/src/fastcs/attributes/attr_r.py b/src/fastcs/attributes/attr_r.py index 66215c390..28d66c07b 100644 --- a/src/fastcs/attributes/attr_r.py +++ b/src/fastcs/attributes/attr_r.py @@ -1,7 +1,7 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable +from collections.abc import Callable, Coroutine from typing import Any from fastcs.attributes.attribute import Attribute, AttributeAccessMode @@ -10,11 +10,11 @@ from fastcs.datatypes import DataType, DType_T from fastcs.logging import logger -AttrIOUpdateCallback = Callable[["AttrR[DType_T, Any]"], Awaitable[None]] +AttrIOUpdateCallback = Callable[["AttrR[DType_T, Any]"], Coroutine[None, None, None]] """An AttributeIO callback that takes an AttrR and updates its value""" -AttrUpdateCallback = Callable[[], Awaitable[None]] +AttrUpdateCallback = Callable[[], Coroutine[None, None, None]] """A callback to be called periodically to update an attribute""" -AttrOnUpdateCallback = Callable[[DType_T], Awaitable[None]] +AttrOnUpdateCallback = Callable[[DType_T], Coroutine[None, None, None]] """A callback to be called when the value of the attribute is updated""" @@ -132,12 +132,8 @@ def bind_update_callback(self) -> AttrUpdateCallback: update_callback = self._update_callback async def update_attribute(): - try: - self.log_event("Update attribute", topic=self) - await update_callback(self) - except Exception: - logger.error("Attribute update loop stopped", attribute=self) - raise + self.log_event("Update attribute", topic=self) + await update_callback(self) return update_attribute diff --git a/src/fastcs/connections/ip_connection.py b/src/fastcs/connections/ip_connection.py index 12a0acee1..f9104fcb1 100644 --- a/src/fastcs/connections/ip_connection.py +++ b/src/fastcs/connections/ip_connection.py @@ -80,6 +80,13 @@ async def send_query(self, message: str) -> str: return response async def close(self): + if self.__connection is None: + return + async with self._connection as connection: - await connection.close() - self.__connection = None + try: + await connection.close() + except ConnectionResetError: + pass + + self.__connection = None diff --git a/src/fastcs/control_system.py b/src/fastcs/control_system.py index b1a6ea6cd..a111f1baf 100644 --- a/src/fastcs/control_system.py +++ b/src/fastcs/control_system.py @@ -7,11 +7,11 @@ from IPython.terminal.embed import InteractiveShellEmbed -from fastcs.controllers import BaseController, Controller +from fastcs.controllers import Controller from fastcs.logging import logger from fastcs.methods import ScanCallback from fastcs.tracer import Tracer -from fastcs.transports import ControllerAPI, Transport +from fastcs.transports import Transport tracer = Tracer() @@ -57,15 +57,6 @@ async def _run_initial_coros(self): async def _start_scan_tasks(self): self._scan_tasks = {self._loop.create_task(coro()) for coro in self._scan_coros} - for task in self._scan_tasks: - task.add_done_callback(self._scan_done) - - def _scan_done(self, task: asyncio.Task): - try: - task.result() - except Exception: - logger.exception("Exception raised in scan task") - def _stop_scan_tasks(self): for task in self._scan_tasks: if not task.done(): @@ -82,9 +73,8 @@ async def serve(self, interactive: bool = True) -> None: await self._controller.initialise() self._controller.post_initialise() - self.controller_api = build_controller_api(self._controller) - self._scan_coros, self._initial_coros = ( - self.controller_api.get_scan_and_initial_coros() + self.controller_api, self._scan_coros, self._initial_coros = ( + self._controller.create_api_and_tasks() ) context = { @@ -168,21 +158,3 @@ async def interactive_shell( def __del__(self): self._stop_scan_tasks() - - -def build_controller_api(controller: Controller) -> ControllerAPI: - return _build_controller_api(controller, []) - - -def _build_controller_api(controller: BaseController, path: list[str]) -> ControllerAPI: - return ControllerAPI( - path=path, - attributes=controller.attributes, - command_methods=controller.command_methods, - scan_methods=controller.scan_methods, - sub_apis={ - name: _build_controller_api(sub_controller, path + [name]) - for name, sub_controller in controller.sub_controllers.items() - }, - description=controller.description, - ) diff --git a/src/fastcs/controllers/__init__.py b/src/fastcs/controllers/__init__.py index 80b88d896..b982292de 100644 --- a/src/fastcs/controllers/__init__.py +++ b/src/fastcs/controllers/__init__.py @@ -1,3 +1,4 @@ from .base_controller import BaseController as BaseController from .controller import Controller as Controller +from .controller_api import ControllerAPI as ControllerAPI from .controller_vector import ControllerVector as ControllerVector diff --git a/src/fastcs/controllers/base_controller.py b/src/fastcs/controllers/base_controller.py index d1aa9489d..11c90e86d 100755 --- a/src/fastcs/controllers/base_controller.py +++ b/src/fastcs/controllers/base_controller.py @@ -3,7 +3,7 @@ from collections import Counter from collections.abc import Sequence from copy import deepcopy -from typing import ( # type: ignore +from typing import ( TypeVar, _GenericAlias, # type: ignore get_args, @@ -12,6 +12,7 @@ ) from fastcs.attributes import AnyAttributeIO, Attribute, AttrR, AttrW, HintedAttribute +from fastcs.controllers.controller_api import ControllerAPI from fastcs.logging import logger from fastcs.methods import Command, Method, Scan, UnboundCommand, UnboundScan from fastcs.tracer import Tracer @@ -388,3 +389,16 @@ def add_scan(self, name: str, scan: Scan): @property def scan_methods(self) -> dict[str, Scan]: return self.__scan_methods + + def _build_api(self, path: list[str]) -> ControllerAPI: + return ControllerAPI( + path=path, + attributes=self.attributes, + command_methods=self.command_methods, + scan_methods=self.scan_methods, + sub_apis={ + name: sub_controller._build_api(path + [name]) # noqa: SLF001 + for name, sub_controller in self.sub_controllers.items() + }, + description=self.description, + ) diff --git a/src/fastcs/controllers/controller.py b/src/fastcs/controllers/controller.py index f880e1e82..fe1b4258c 100755 --- a/src/fastcs/controllers/controller.py +++ b/src/fastcs/controllers/controller.py @@ -1,7 +1,15 @@ +import asyncio +from collections import defaultdict from collections.abc import Sequence from fastcs.attributes import AnyAttributeIO +from fastcs.attributes.attr_r import AttrR +from fastcs.attributes.attribute_io_ref import AttributeIORef from fastcs.controllers.base_controller import BaseController +from fastcs.controllers.controller_api import ControllerAPI +from fastcs.logging import logger +from fastcs.methods import ScanCallback +from fastcs.util import ONCE class Controller(BaseController): @@ -13,6 +21,7 @@ def __init__( ios: Sequence[AnyAttributeIO] | None = None, ) -> None: super().__init__(description=description, ios=ios) + self._connected = False def add_sub_controller(self, name: str, sub_controller: BaseController): if name.isdigit(): @@ -23,7 +32,105 @@ def add_sub_controller(self, name: str, sub_controller: BaseController): return super().add_sub_controller(name, sub_controller) async def connect(self) -> None: - pass + """Hook to perform initial connection to device + + This should set ``_connected`` to ``True`` if the connection was successful to + enable scan tasks. + + """ + self._connected = True + + async def reconnect(self): + """Hook to reconnect to device after an error + + This should set ``_connected`` to ``True`` if the connection was successful to + enable scan tasks. + + If the connection cannot be re-established it should log an error with the + reason. It should not raise an exception. + + """ + self._connected = True async def disconnect(self) -> None: + """Hook to tidy up resources before stopping the application""" pass + + def create_api_and_tasks( + self, + ) -> tuple[ControllerAPI, list[ScanCallback], list[ScanCallback]]: + """Create api for transports tasks for FastCS backend + + Creates a tuple of + - The `ControllerAPI` for this controller + - Initial coroutines to be run once on startup + - Periodic coroutines to run as background tasks + + Returns: + tuple[ControllerAPI, list[ScanCallback], list[ScanCallback]] + + """ + controller_api = self._build_api([]) + + scan_dict: dict[float, list[ScanCallback]] = defaultdict(list) + initial_coros: list[ScanCallback] = [] + + for api in controller_api.walk_api(): + for method in api.scan_methods.values(): + if method.period is ONCE: + initial_coros.append(method.fn) + else: + scan_dict[method.period].append(method.fn) + + for attribute in api.attributes.values(): + match attribute: + case AttrR(_io_ref=AttributeIORef(update_period=update_period)): + if update_period is ONCE: + initial_coros.append(attribute.bind_update_callback()) + elif update_period is not None: + scan_dict[update_period].append( + attribute.bind_update_callback() + ) + + periodic_scan_coros: list[ScanCallback] = [] + for period, methods in scan_dict.items(): + periodic_scan_coros.append(self._create_periodic_scan_coro(period, methods)) + + return controller_api, periodic_scan_coros, initial_coros + + def _create_periodic_scan_coro( + self, period: float, scans: Sequence[ScanCallback] + ) -> ScanCallback: + """Create a coroutine to run scans at a given period + + This returns a coroutine that runs scans at a given period. If an exception is + raised in a callback it is caught and the updates for the controller are + paused, waiting for `_connected` to be set back to true via the `reconnect` + method. + + Args: + period: The period to run the scans at + scans: A list of `ScanCallback` to run periodically + + Returns: + A wrapper `ScanCallback` that runs all of the callbacks at a given period + """ + + async def scan_coro() -> None: + while True: + if not self._connected: + await asyncio.sleep(1) + continue + + try: + await asyncio.gather( + asyncio.sleep(period), *[scan() for scan in scans] + ) + except Exception: + logger.exception("Exception in scan task", period=period) + self._connected = False + + await asyncio.sleep(1) # Wait so this message appears last + logger.error("Pausing scan tasks and waiting for reconnect") + + return scan_coro diff --git a/src/fastcs/controllers/controller_api.py b/src/fastcs/controllers/controller_api.py new file mode 100644 index 000000000..6b13aba41 --- /dev/null +++ b/src/fastcs/controllers/controller_api.py @@ -0,0 +1,38 @@ +from collections.abc import Iterator +from dataclasses import dataclass, field + +from fastcs.attributes import Attribute +from fastcs.methods import Command, Scan + + +@dataclass +class ControllerAPI: + """Attributes, Methods and sub APIs of a `Controller` to expose in a transport""" + + path: list[str] = field(default_factory=list) + """Path within controller tree (empty if this is the root)""" + attributes: dict[str, Attribute] = field(default_factory=dict) + command_methods: dict[str, Command] = field(default_factory=dict) + scan_methods: dict[str, Scan] = field(default_factory=dict) + sub_apis: dict[str, "ControllerAPI"] = field(default_factory=dict) + """APIs of the sub controllers of the `Controller` this API was built from""" + description: str | None = None + + def walk_api(self) -> Iterator["ControllerAPI"]: + """Walk through all the nested `ControllerAPI` s of this `ControllerAPI`. + + Yields the `ControllerAPI` s from a depth-first traversal of the tree, + including self. + + """ + yield self + for api in self.sub_apis.values(): + yield from api.walk_api() + + def __repr__(self): + return ( + f"ControllerAPI(" + f"path={self.path}, " + f"sub_apis=[{', '.join(self.sub_apis.keys())}]" + f")" + ) diff --git a/src/fastcs/demo/controllers.py b/src/fastcs/demo/controllers.py index 51383e0fb..1a3169bb8 100755 --- a/src/fastcs/demo/controllers.py +++ b/src/fastcs/demo/controllers.py @@ -95,6 +95,12 @@ async def cancel_all(self) -> None: async def connect(self) -> None: await self.connection.connect(self._settings.ip_settings) + async def reconnect(self): + await self.connection.close() + await self.connection.connect(self._settings.ip_settings) + + self._connected = True + async def close(self) -> None: await self.connection.close() diff --git a/src/fastcs/transports/__init__.py b/src/fastcs/transports/__init__.py index 0c91f9f17..c5bc22b5d 100644 --- a/src/fastcs/transports/__init__.py +++ b/src/fastcs/transports/__init__.py @@ -1,4 +1,3 @@ -from .controller_api import ControllerAPI as ControllerAPI from .transport import Transport as Transport try: diff --git a/src/fastcs/transports/controller_api.py b/src/fastcs/transports/controller_api.py deleted file mode 100644 index b41d8ad34..000000000 --- a/src/fastcs/transports/controller_api.py +++ /dev/null @@ -1,107 +0,0 @@ -import asyncio -from collections import defaultdict -from collections.abc import Callable, Iterator -from dataclasses import dataclass, field - -from fastcs.attributes import Attribute, AttributeIORef, AttrR -from fastcs.methods import Command, Scan, ScanCallback -from fastcs.util import ONCE - - -@dataclass -class ControllerAPI: - """Attributes, Methods and sub APIs of a `Controller` to expose in a transport""" - - path: list[str] = field(default_factory=list) - """Path within controller tree (empty if this is the root)""" - attributes: dict[str, Attribute] = field(default_factory=dict) - command_methods: dict[str, Command] = field(default_factory=dict) - scan_methods: dict[str, Scan] = field(default_factory=dict) - sub_apis: dict[str, "ControllerAPI"] = field(default_factory=dict) - """APIs of the sub controllers of the `Controller` this API was built from""" - description: str | None = None - - def walk_api(self) -> Iterator["ControllerAPI"]: - """Walk through all the nested `ControllerAPI` s of this `ControllerAPI`. - - Yields the `ControllerAPI` s from a depth-first traversal of the tree, - including self. - - """ - yield self - for api in self.sub_apis.values(): - yield from api.walk_api() - - def __repr__(self): - return ( - f"ControllerAPI(" - f"path={self.path}, " - f"sub_apis=[{', '.join(self.sub_apis.keys())}]" - f")" - ) - - def get_scan_and_initial_coros( - self, - ) -> tuple[list[ScanCallback], list[ScanCallback]]: - scan_dict: dict[float, list[Callable]] = defaultdict(list) - initial_coros: list[Callable] = [] - - for controller_api in self.walk_api(): - _add_scan_method_tasks(scan_dict, initial_coros, controller_api) - _add_attribute_update_tasks(scan_dict, initial_coros, controller_api) - - scan_coros = _get_periodic_scan_coros(scan_dict) - return scan_coros, initial_coros - - -def _add_scan_method_tasks( - scan_dict: dict[float, list[Callable]], - initial_coros: list[Callable], - controller_api: ControllerAPI, -): - for method in controller_api.scan_methods.values(): - if method.period is ONCE: - initial_coros.append(method.fn) - else: - scan_dict[method.period].append(method.fn) - - -def _add_attribute_update_tasks( - scan_dict: dict[float, list[Callable]], - initial_coros: list[Callable], - controller_api: ControllerAPI, -): - for attribute in controller_api.attributes.values(): - match attribute: - case ( - AttrR(_io_ref=AttributeIORef(update_period=update_period)) as attribute - ): - if update_period is ONCE: - initial_coros.append(attribute.bind_update_callback()) - elif update_period is not None: - scan_dict[update_period].append(attribute.bind_update_callback()) - - -def _get_periodic_scan_coros( - scan_dict: dict[float, list[ScanCallback]], -) -> list[ScanCallback]: - periodic_scan_coros: list[ScanCallback] = [] - for period, methods in scan_dict.items(): - periodic_scan_coros.append(_create_periodic_scan_coro(period, methods)) - - return periodic_scan_coros - - -def _create_periodic_scan_coro( - period: float, scans: list[ScanCallback] -) -> ScanCallback: - async def _sleep(): - await asyncio.sleep(period) - - methods = [_sleep] + list(scans) # Create periodic behavior - - async def scan_coro() -> None: - while True: - await asyncio.gather(*[method() for method in methods]) - - return scan_coro diff --git a/src/fastcs/transports/epics/ca/ioc.py b/src/fastcs/transports/epics/ca/ioc.py index e8e94d7a3..3f510a1b7 100644 --- a/src/fastcs/transports/epics/ca/ioc.py +++ b/src/fastcs/transports/epics/ca/ioc.py @@ -6,11 +6,11 @@ from softioc.pythonSoftIoc import RecordWrapper from fastcs.attributes import AttrR, AttrRW, AttrW +from fastcs.controllers import ControllerAPI from fastcs.datatypes import DType_T, Waveform from fastcs.logging import logger from fastcs.methods import Command from fastcs.tracer import Tracer -from fastcs.transports.controller_api import ControllerAPI from fastcs.transports.epics.ca.util import ( _make_in_record, _make_out_record, diff --git a/src/fastcs/transports/epics/ca/transport.py b/src/fastcs/transports/epics/ca/transport.py index 08ac3aebf..e367f7f36 100644 --- a/src/fastcs/transports/epics/ca/transport.py +++ b/src/fastcs/transports/epics/ca/transport.py @@ -4,8 +4,8 @@ from softioc import softioc +from fastcs.controllers import ControllerAPI from fastcs.logging import logger -from fastcs.transports.controller_api import ControllerAPI from fastcs.transports.epics import ( EpicsDocsOptions, EpicsGUIOptions, diff --git a/src/fastcs/transports/epics/docs.py b/src/fastcs/transports/epics/docs.py index cbc4937dc..ced56172d 100644 --- a/src/fastcs/transports/epics/docs.py +++ b/src/fastcs/transports/epics/docs.py @@ -1,4 +1,4 @@ -from fastcs.transports.controller_api import ControllerAPI +from fastcs.controllers import ControllerAPI from .options import EpicsDocsOptions diff --git a/src/fastcs/transports/epics/gui.py b/src/fastcs/transports/epics/gui.py index 98e2f9984..fbdf336d3 100644 --- a/src/fastcs/transports/epics/gui.py +++ b/src/fastcs/transports/epics/gui.py @@ -24,6 +24,7 @@ from pydantic import ValidationError from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW +from fastcs.controllers import ControllerAPI from fastcs.datatypes import ( Bool, Enum, @@ -34,7 +35,6 @@ ) from fastcs.logging import logger from fastcs.methods import Command -from fastcs.transports.controller_api import ControllerAPI from fastcs.transports.epics.options import EpicsGUIFormat, EpicsGUIOptions from fastcs.util import snake_to_pascal diff --git a/src/fastcs/transports/epics/pva/ioc.py b/src/fastcs/transports/epics/pva/ioc.py index df4f64570..0c7ea4b7c 100644 --- a/src/fastcs/transports/epics/pva/ioc.py +++ b/src/fastcs/transports/epics/pva/ioc.py @@ -3,7 +3,7 @@ from p4p.server import Server, StaticProvider from fastcs.attributes import AttrR, AttrRW, AttrW -from fastcs.transports.controller_api import ControllerAPI +from fastcs.controllers import ControllerAPI from fastcs.transports.epics.util import controller_pv_prefix from fastcs.util import snake_to_pascal diff --git a/src/fastcs/transports/epics/pva/pvi.py b/src/fastcs/transports/epics/pva/pvi.py index 5c381c05a..a68b00fde 100644 --- a/src/fastcs/transports/epics/pva/pvi.py +++ b/src/fastcs/transports/epics/pva/pvi.py @@ -6,7 +6,7 @@ from p4p.server import StaticProvider from p4p.server.asyncio import SharedPV -from fastcs.transports.controller_api import ControllerAPI +from fastcs.controllers import ControllerAPI from fastcs.util import snake_to_pascal from .types import p4p_alarm_states, p4p_timestamp_now diff --git a/src/fastcs/transports/epics/pva/transport.py b/src/fastcs/transports/epics/pva/transport.py index 14854dea7..560e9d825 100644 --- a/src/fastcs/transports/epics/pva/transport.py +++ b/src/fastcs/transports/epics/pva/transport.py @@ -1,8 +1,8 @@ import asyncio from dataclasses import dataclass, field +from fastcs.controllers import ControllerAPI from fastcs.logging import logger -from fastcs.transports.controller_api import ControllerAPI from fastcs.transports.epics import ( EpicsDocsOptions, EpicsGUIOptions, diff --git a/src/fastcs/transports/epics/util.py b/src/fastcs/transports/epics/util.py index 013cfaff4..4695939e2 100644 --- a/src/fastcs/transports/epics/util.py +++ b/src/fastcs/transports/epics/util.py @@ -1,4 +1,4 @@ -from fastcs.transports.controller_api import ControllerAPI +from fastcs.controllers import ControllerAPI from fastcs.util import snake_to_pascal diff --git a/src/fastcs/transports/graphql/graphql.py b/src/fastcs/transports/graphql/graphql.py index f3c26966a..8bc2fa322 100644 --- a/src/fastcs/transports/graphql/graphql.py +++ b/src/fastcs/transports/graphql/graphql.py @@ -8,10 +8,10 @@ from strawberry.types.field import StrawberryField from fastcs.attributes import AttrR, AttrRW, AttrW +from fastcs.controllers import ControllerAPI from fastcs.datatypes.datatype import DType_T from fastcs.exceptions import FastCSError from fastcs.logging import intercept_std_logger -from fastcs.transports.controller_api import ControllerAPI from .options import GraphQLServerOptions diff --git a/src/fastcs/transports/graphql/transport.py b/src/fastcs/transports/graphql/transport.py index 2e040e12e..a590c062f 100644 --- a/src/fastcs/transports/graphql/transport.py +++ b/src/fastcs/transports/graphql/transport.py @@ -1,7 +1,7 @@ import asyncio from dataclasses import dataclass, field -from fastcs.transports.controller_api import ControllerAPI +from fastcs.controllers import ControllerAPI from fastcs.transports.transport import Transport from .graphql import GraphQLServer diff --git a/src/fastcs/transports/rest/rest.py b/src/fastcs/transports/rest/rest.py index 3bab024ff..e1005ac0c 100644 --- a/src/fastcs/transports/rest/rest.py +++ b/src/fastcs/transports/rest/rest.py @@ -6,10 +6,10 @@ from pydantic import create_model from fastcs.attributes import AttrR, AttrRW, AttrW +from fastcs.controllers import ControllerAPI from fastcs.datatypes.datatype import DType_T from fastcs.logging import intercept_std_logger from fastcs.methods import CommandCallback -from fastcs.transports.controller_api import ControllerAPI from .options import RestServerOptions from .util import ( diff --git a/src/fastcs/transports/rest/transport.py b/src/fastcs/transports/rest/transport.py index 42490762c..d89a89b9e 100644 --- a/src/fastcs/transports/rest/transport.py +++ b/src/fastcs/transports/rest/transport.py @@ -1,7 +1,7 @@ import asyncio from dataclasses import dataclass, field -from fastcs.transports.controller_api import ControllerAPI +from fastcs.controllers import ControllerAPI from fastcs.transports.transport import Transport from .options import RestServerOptions diff --git a/src/fastcs/transports/tango/dsr.py b/src/fastcs/transports/tango/dsr.py index 09e25ed85..7350be2af 100644 --- a/src/fastcs/transports/tango/dsr.py +++ b/src/fastcs/transports/tango/dsr.py @@ -7,8 +7,8 @@ from tango.server import Device from fastcs.attributes import AttrR, AttrRW, AttrW +from fastcs.controllers import ControllerAPI from fastcs.methods import CommandCallback -from fastcs.transports.controller_api import ControllerAPI from .options import TangoDSROptions from .util import ( diff --git a/src/fastcs/transports/tango/transport.py b/src/fastcs/transports/tango/transport.py index e23dd8941..6ce3b102b 100644 --- a/src/fastcs/transports/tango/transport.py +++ b/src/fastcs/transports/tango/transport.py @@ -1,7 +1,7 @@ import asyncio from dataclasses import dataclass, field -from fastcs.transports.controller_api import ControllerAPI +from fastcs.controllers import ControllerAPI from fastcs.transports.transport import Transport from .dsr import TangoDSR, TangoDSROptions diff --git a/src/fastcs/transports/transport.py b/src/fastcs/transports/transport.py index 5b34ff03b..285c9ceff 100644 --- a/src/fastcs/transports/transport.py +++ b/src/fastcs/transports/transport.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from typing import Any, ClassVar, Union -from fastcs.transports.controller_api import ControllerAPI +from fastcs.controllers import ControllerAPI @dataclass diff --git a/tests/assertable_controller.py b/tests/assertable_controller.py index b988be27a..1b1c6f608 100644 --- a/tests/assertable_controller.py +++ b/tests/assertable_controller.py @@ -6,11 +6,9 @@ from pytest_mock import MockerFixture, MockType from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW -from fastcs.control_system import build_controller_api -from fastcs.controllers import Controller +from fastcs.controllers import Controller, ControllerAPI from fastcs.datatypes import DType_T, Int from fastcs.methods import command, scan -from fastcs.transports import ControllerAPI @dataclass @@ -49,7 +47,6 @@ def __init__(self) -> None: self.add_sub_controller(f"SubController{index:02d}", controller) initialised = False - connected = False count = 0 async def initialise(self) -> None: @@ -57,10 +54,10 @@ async def initialise(self) -> None: self.initialised = True async def connect(self) -> None: - self.connected = True + self._connected = True async def disconnect(self) -> None: - self.connected = False + self._connected = False @command() async def go(self): @@ -79,7 +76,7 @@ def __init__(self, controller: Controller, mocker: MockerFixture) -> None: self.command_method_spys: dict[str, MockType] = {} # Build a ControllerAPI from the given Controller - controller_api = build_controller_api(controller) + controller_api = controller._build_api([]) # Copy its fields self.attributes = controller_api.attributes self.command_methods = controller_api.command_methods diff --git a/tests/conftest.py b/tests/conftest.py index 8351eba17..83b7d5953 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,7 +19,6 @@ from softioc import builder from fastcs.attributes import AttrR, AttrRW, AttrW -from fastcs.control_system import build_controller_api from fastcs.datatypes import Bool, Float, Int, String from fastcs.logging import configure_logging, logger from fastcs.logging._logging import LogLevel @@ -50,7 +49,7 @@ def controller(): @pytest.fixture def controller_api(controller): - return build_controller_api(controller) + return controller._build_api([]) DATA_PATH = Path(__file__).parent / "data" diff --git a/tests/test_control_system.py b/tests/test_control_system.py index 1125a3703..c231b136b 100644 --- a/tests/test_control_system.py +++ b/tests/test_control_system.py @@ -3,11 +3,11 @@ import pytest -from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW -from fastcs.control_system import FastCS, build_controller_api +from fastcs.attributes import AttributeIO, AttributeIORef, AttrR +from fastcs.control_system import FastCS from fastcs.controllers import Controller from fastcs.datatypes import Int -from fastcs.methods import Command, command, scan +from fastcs.methods import Command, command from fastcs.util import ONCE @@ -26,32 +26,6 @@ async def test_scan_tasks(controller): assert controller.count > count -def test_controller_api(): - class MyTestController(Controller): - attr1: AttrRW[int] = AttrRW(Int()) - - def __init__(self): - super().__init__(description="Controller for testing") - - self.attr2 = AttrRW(Int()) - - @command() - async def do_nothing(self): - pass - - @scan(1.0) - async def scan_nothing(self): - pass - - controller = MyTestController() - api = build_controller_api(controller) - - assert api.description == controller.description - assert list(api.attributes) == ["attr1", "attr2"] - assert list(api.command_methods) == ["do_nothing"] - assert list(api.scan_methods) == ["scan_nothing"] - - @pytest.mark.asyncio async def test_controller_api_methods(): class MyTestController(Controller): @@ -106,9 +80,8 @@ class MyController(Controller): controller = MyController(ios=[AttributeIOTimesCalled()]) loop = asyncio.get_event_loop() - transport_options = [] - fastcs = FastCS(controller, transport_options, loop) + fastcs = FastCS(controller, [], loop) assert controller.update_quickly.get() == 0 assert controller.update_once.get() == 0 @@ -125,48 +98,9 @@ class MyController(Controller): assert len(fastcs._initial_coros) == 1 -@pytest.mark.asyncio -async def test_scan_raises_exception_via_callback(): - class MyTestController(Controller): - def __init__(self): - super().__init__() - - @scan(0.1) - async def raise_exception(self): - raise ValueError("Scan Exception") - - controller = MyTestController() - loop = asyncio.get_event_loop() - transport_options = [] - fastcs = FastCS(controller, transport_options, loop) - - exception_info = {} - # This will intercept the exception raised in _scan_done - loop.set_exception_handler( - lambda _loop, context: exception_info.update( - {"exception": context.get("exception")} - ) - ) - - task = asyncio.create_task(fastcs.serve(interactive=False)) - # This allows scan time to run - await asyncio.sleep(0.2) - for task in fastcs._scan_tasks: - internal_exception = task.exception() - assert internal_exception - # The task exception comes from scan method raise_exception - assert isinstance(internal_exception, ValueError) - assert "Scan Exception" == str(internal_exception) - - @pytest.mark.asyncio async def test_controller_connect_disconnect(): class MyTestController(Controller): - def __init__(self): - super().__init__() - - self.connected = False - async def connect(self): self.connected = True diff --git a/tests/test_controllers.py b/tests/test_controllers.py index 25b194c70..f54291269 100644 --- a/tests/test_controllers.py +++ b/tests/test_controllers.py @@ -1,3 +1,4 @@ +import asyncio import enum import pytest @@ -5,7 +6,7 @@ from fastcs.attributes import AttrR, AttrRW from fastcs.controllers import Controller, ControllerVector from fastcs.datatypes import Enum, Float, Int -from fastcs.methods import Command, Scan +from fastcs.methods import Command, Scan, command, scan def test_controller_nesting(): @@ -228,3 +229,55 @@ class HintedController(Controller): controller.add_scan("method", Scan(fn=noop, period=0.1)) controller._validate_type_hints() + + +def test_controller_api(): + class MyTestController(Controller): + attr1: AttrRW[int] = AttrRW(Int()) + + def __init__(self): + super().__init__(description="Controller for testing") + + self.attr2 = AttrRW(Int()) + + @command() + async def do_nothing(self): + pass + + @scan(1.0) + async def scan_nothing(self): + pass + + controller = MyTestController() + api = controller._build_api([]) + + assert api.description == controller.description + assert list(api.attributes) == ["attr1", "attr2"] + assert list(api.command_methods) == ["do_nothing"] + assert list(api.scan_methods) == ["scan_nothing"] + + +@pytest.mark.asyncio +async def test_scan_exception_sets_disconnected_and_reconnect_resumes(): + class MyTestController(Controller): + @scan(0.01) + async def failing_scan(self): + raise RuntimeError("scan error") + + controller = MyTestController() + controller.post_initialise() + _, scan_coros, _ = controller.create_api_and_tasks() + + controller._connected = True + task = asyncio.create_task(scan_coros[0]()) + + # Wait long enough for the scan to run and raise, setting _connected = False + await asyncio.sleep(0.1) + assert not controller._connected + + # Trigger reconnect - _connected resumes scan tasks + await controller.reconnect() + assert controller._connected + + task.cancel() + await asyncio.sleep(0.01) diff --git a/tests/transports/epics/ca/test_gui.py b/tests/transports/epics/ca/test_gui.py index 296be4118..39485a527 100644 --- a/tests/transports/epics/ca/test_gui.py +++ b/tests/transports/epics/ca/test_gui.py @@ -19,8 +19,8 @@ from tests.util import ColourEnum from fastcs.attributes import AttrR, AttrRW, AttrW +from fastcs.controllers import ControllerAPI from fastcs.datatypes import Bool, Enum, Float, Int, String, Waveform -from fastcs.transports import ControllerAPI from fastcs.transports.epics.gui import EpicsGUI diff --git a/tests/transports/epics/ca/test_softioc.py b/tests/transports/epics/ca/test_softioc.py index b97c6e434..0bf571d3b 100644 --- a/tests/transports/epics/ca/test_softioc.py +++ b/tests/transports/epics/ca/test_softioc.py @@ -13,11 +13,10 @@ from tests.util import ColourEnum from fastcs.attributes import AttrR, AttrRW, AttrW -from fastcs.controllers import Controller +from fastcs.controllers import Controller, ControllerAPI from fastcs.datatypes import Bool, Enum, Float, Int, String, Waveform from fastcs.exceptions import FastCSError from fastcs.methods import Command -from fastcs.transports.controller_api import ControllerAPI from fastcs.transports.epics.ca import EpicsCATransport from fastcs.transports.epics.ca.ioc import ( EPICS_MAX_NAME_LENGTH, diff --git a/tests/transports/epics/pva/test_pva_gui.py b/tests/transports/epics/pva/test_pva_gui.py index 9955b36c2..f5403d2ab 100644 --- a/tests/transports/epics/pva/test_pva_gui.py +++ b/tests/transports/epics/pva/test_pva_gui.py @@ -16,8 +16,8 @@ ) from fastcs.attributes import AttrR, AttrW +from fastcs.controllers import ControllerAPI from fastcs.datatypes import Table, Waveform -from fastcs.transports import ControllerAPI from fastcs.transports.epics.gui import EpicsGUI from fastcs.transports.epics.pva.gui import PvaEpicsGUI diff --git a/tests/transports/rest/test_rest.py b/tests/transports/rest/test_rest.py index bccdb0a88..2be0b21b9 100644 --- a/tests/transports/rest/test_rest.py +++ b/tests/transports/rest/test_rest.py @@ -8,8 +8,8 @@ from tests.assertable_controller import AssertableControllerAPI, MyTestController from fastcs.attributes import AttrR, AttrRW, AttrW +from fastcs.controllers import ControllerAPI from fastcs.datatypes import Bool, Enum, Float, Int, String, Waveform -from fastcs.transports.controller_api import ControllerAPI from fastcs.transports.rest.transport import RestTransport