diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 44de8d36a..b1220e057 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -13,12 +13,12 @@ "DISPLAY": "${localEnv:DISPLAY}" }, // Add the URLs of features you want added when the container is built. - "features": { - "ghcr.io/devcontainers/features/common-utils:1": { - "username": "none", - "upgradePackages": false - } - }, + // "features": { + // "ghcr.io/devcontainers/features/common-utils:1": { + // "username": "none", + // "upgradePackages": false + // } + // }, // Set *default* container specific settings.json values on container create. "settings": { "python.defaultInterpreterPath": "/venv/bin/python" diff --git a/.github/workflows/code.yml b/.github/workflows/code.yml index cbc3e2801..f54318bb3 100644 --- a/.github/workflows/code.yml +++ b/.github/workflows/code.yml @@ -32,12 +32,12 @@ jobs: fail-fast: false matrix: os: ["ubuntu-latest"] # can add windows-latest, macos-latest - python: ["3.8", "3.9", "3.10", "3.11"] + python: ["3.10", "3.11"] install: ["-e .[dev]"] # Make one version be non-editable to test both paths of version code include: - os: "ubuntu-latest" - python: "3.7" + python: "3.10" install: ".[dev]" runs-on: ${{ matrix.os }} @@ -146,9 +146,9 @@ jobs: - name: Build and export to Docker local cache uses: docker/build-push-action@v4 with: - # Note build-args, context, file, and target must all match between this - # step and the later build-push-action, otherwise the second build-push-action - # will attempt to build the image again + # Note build-args, context, file, and target must all match between this + # step and the later build-push-action, otherwise the second build-push-action + # will attempt to build the image again build-args: | PIP_OPTIONS=-r lockfiles/requirements.txt dist/*.whl context: artifacts/ @@ -156,8 +156,8 @@ jobs: target: runtime load: true tags: ${{ env.TEST_TAG }} - # If you have a long docker build (2+ minutes), uncomment the - # following to turn on caching. For short build times this + # If you have a long docker build (2+ minutes), uncomment the + # following to turn on caching. For short build times this # makes it a little slower #cache-from: type=gha #cache-to: type=gha,mode=max @@ -180,12 +180,12 @@ jobs: - name: Push cached image to container registry if: github.ref_type == 'tag' # || github.ref_name == 'main' uses: docker/build-push-action@v3 - # This does not build the image again, it will find the image in the + # This does not build the image again, it will find the image in the # Docker cache and publish it with: - # Note build-args, context, file, and target must all match between this - # step and the previous build-push-action, otherwise this step will - # attempt to build the image again + # Note build-args, context, file, and target must all match between this + # step and the previous build-push-action, otherwise this step will + # attempt to build the image again build-args: | PIP_OPTIONS=-r lockfiles/requirements.txt dist/*.whl context: artifacts/ diff --git a/.gitignore b/.gitignore index 9fbb6bfe0..701fcd27e 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,7 @@ cov.xml # Sphinx documentation docs/_build/ +docs/**/_autosummary # PyBuilder target/ diff --git a/Dockerfile b/Dockerfile index 90c2f35e7..13cce1505 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ # The devcontainer should use the build target and run as root with podman # or docker with user namespaces. # -FROM python:3.11 as build +FROM python:3.10 as build ARG PIP_OPTIONS=. @@ -24,7 +24,7 @@ WORKDIR /context # install python package into /venv RUN pip install ${PIP_OPTIONS} -FROM python:3.11-slim as runtime +FROM python:3.10-slim as runtime # Add apt-get system dependecies for runtime here if needed diff --git a/docs/_templates/custom-class-template.rst b/docs/_templates/custom-class-template.rst new file mode 100644 index 000000000..16ebb2f33 --- /dev/null +++ b/docs/_templates/custom-class-template.rst @@ -0,0 +1,32 @@ +{{ fullname | escape | underline}} + +.. currentmodule:: {{ module }} + +.. autoclass:: {{ objname }} + :members: + :show-inheritance: + :inherited-members: + + {% block methods %} + .. automethod:: __init__ + + {% if methods %} + .. rubric:: {{ _('Methods') }} + + .. autosummary:: + {% for item in methods %} + ~{{ name }}.{{ item }} + {%- endfor %} + {% endif %} + {% endblock %} + + {% block attributes %} + {% if attributes %} + .. rubric:: {{ _('Attributes') }} + + .. autosummary:: + {% for item in attributes %} + ~{{ name }}.{{ item }} + {%- endfor %} + {% endif %} + {% endblock %} \ No newline at end of file diff --git a/docs/_templates/custom-module-template.rst b/docs/_templates/custom-module-template.rst new file mode 100644 index 000000000..74078355f --- /dev/null +++ b/docs/_templates/custom-module-template.rst @@ -0,0 +1,66 @@ +{{ fullname | escape | underline}} + +.. automodule:: {{ fullname }} + + {% block attributes %} + {% if attributes %} + .. rubric:: Module Attributes + + .. autosummary:: + :toctree: + {% for item in attributes %} + {{ item }} + {%- endfor %} + {% endif %} + {% endblock %} + + {% block functions %} + {% if functions %} + .. rubric:: {{ _('Functions') }} + + .. autosummary:: + :toctree: + {% for item in functions %} + {{ item }} + {%- endfor %} + {% endif %} + {% endblock %} + + {% block classes %} + {% if classes %} + .. rubric:: {{ _('Classes') }} + + .. autosummary:: + :toctree: + :template: custom-class-template.rst + {% for item in classes %} + {{ item }} + {%- endfor %} + {% endif %} + {% endblock %} + + {% block exceptions %} + {% if exceptions %} + .. rubric:: {{ _('Exceptions') }} + + .. autosummary:: + :toctree: + {% for item in exceptions %} + {{ item }} + {%- endfor %} + {% endif %} + {% endblock %} + +{% block modules %} +{% if modules %} +.. rubric:: Modules + +.. autosummary:: + :toctree: + :template: custom-module-template.rst + :recursive: +{% for item in modules %} + {{ item }} +{%- endfor %} +{% endif %} +{% endblock %} \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index cb2cab220..ef580206b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -8,7 +8,7 @@ from pathlib import Path from subprocess import check_output -import requests +import requests # type:ignore import fastcs @@ -32,6 +32,7 @@ extensions = [ # Use this for generating API docs "sphinx.ext.autodoc", + "sphinx.ext.autosummary", # This can parse google style docstrings "sphinx.ext.napoleon", # For linking to external sphinx documentation @@ -48,7 +49,7 @@ # If true, Sphinx will warn about all references where the target cannot # be found. -nitpicky = True +nitpicky = False # A list of (type, target) tuples (by default empty) that should be ignored when # generating warnings in "nitpicky mode". Note that type should include the @@ -75,6 +76,12 @@ # Don't inherit docstrings from baseclasses autodoc_inherit_docstrings = False +# Generate autosummary sections +autosummary_generate = True + +templates_path = ["_templates"] +exclude_patterns = ["_build", "_templates"] + # Output graphviz directive produced images in a scalable format graphviz_output_format = "svg" @@ -98,7 +105,10 @@ # This means you can link things like `str` and `asyncio` to the relevant # docs in the python documentation. -intersphinx_mapping = dict(python=("https://docs.python.org/3/", None)) +intersphinx_mapping = dict( + python=("https://docs.python.org/3/", None), + numpy=("https://numpy.org/doc/stable/", None), +) # A dictionary of graphviz graph attributes for inheritance diagrams. inheritance_graph_attrs = dict(rankdir="TB") diff --git a/docs/user/reference/api.rst b/docs/user/reference/api.rst index 152c4a4d0..c6e74bb1f 100644 --- a/docs/user/reference/api.rst +++ b/docs/user/reference/api.rst @@ -1,7 +1,12 @@ API === -.. automodule:: fastcs +.. toctree:: + +.. autosummary:: + :toctree: _autosummary + :template: custom-module-template.rst + :recursive: ``fastcs`` ----------------------------------- diff --git a/pyproject.toml b/pyproject.toml index d5469004a..307d89cb3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,20 +7,20 @@ name = "fastcs" classifiers = [ "Development Status :: 3 - Alpha", "License :: OSI Approved :: Apache Software License", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", ] description = "Control system agnostic framework for building Device support in Python that will work for both EPICS and Tango" dependencies = [ - "typing-extensions;python_version<'3.8'", + "numpy", + "pydantic", + "pvi", + "softioc", ] # Add project dependencies here, e.g. ["click", "numpy"] dynamic = ["version"] license.file = "LICENSE" readme = "README.rst" -requires-python = ">=3.7" +requires-python = ">=3.10" [project.optional-dependencies] dev = [ @@ -99,8 +99,8 @@ skipsdist=True # Don't create a virtualenv for the command, requires tox-direct plugin direct = True passenv = * -allowlist_externals = - pytest +allowlist_externals = + pytest pre-commit mypy sphinx-build diff --git a/src/fastcs/attributes.py b/src/fastcs/attributes.py new file mode 100644 index 000000000..67f24b024 --- /dev/null +++ b/src/fastcs/attributes.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +from enum import Enum +from typing import Any, Generic, Protocol, runtime_checkable + +from .datatypes import ATTRIBUTE_TYPES, AttrCallback, DataType, T + + +class AttrMode(Enum): + READ = 1 + WRITE = 2 + READ_WRITE = 3 + + +@runtime_checkable +class Sender(Protocol): + async def put(self, controller: Any, attr: AttrW, value: Any) -> None: + pass + + +@runtime_checkable +class Updater(Protocol): + update_period: float + + async def update(self, controller: Any, attr: AttrR) -> None: + pass + + +@runtime_checkable +class Handler(Sender, Updater, Protocol): + pass + + +class Attribute(Generic[T]): + def __init__( + self, datatype: DataType[T], access_mode: AttrMode, handler: Any = None + ) -> None: + assert ( + datatype.dtype in ATTRIBUTE_TYPES + ), f"Attr type must be one of {ATTRIBUTE_TYPES}, received type {datatype.dtype}" + self._datatype: DataType[T] = datatype + self._access_mode: AttrMode = access_mode + + @property + def datatype(self) -> DataType[T]: + return self._datatype + + @property + def dtype(self) -> type[T]: + return self._datatype.dtype + + @property + def access_mode(self) -> AttrMode: + return self._access_mode + + +class AttrR(Attribute[T]): + def __init__( + self, + datatype: DataType[T], + access_mode=AttrMode.READ, + handler: Updater | None = None, + ) -> None: + super().__init__(datatype, access_mode, handler) # type: ignore + self._value: T = datatype.dtype() + self._update_callback: AttrCallback[T] | None = None + self._updater = handler + + def get(self) -> T: + return self._value + + async def set(self, value: T) -> None: + self._value = self._datatype.dtype(value) + + if self._update_callback is not None: + await self._update_callback(self._value) + + def set_update_callback(self, callback: AttrCallback[T] | None) -> None: + self._update_callback = callback + + @property + def updater(self) -> Updater | None: + return self._updater + + +class AttrW(Attribute[T]): + def __init__( + self, + datatype: DataType[T], + access_mode=AttrMode.WRITE, + handler: Sender | None = None, + ) -> None: + super().__init__(datatype, access_mode, handler) # type: ignore + self._process_callback: AttrCallback[T] | None = None + self._write_display_callback: AttrCallback[T] | None = None + self._sender = handler + + async def process(self, value: T) -> None: + if self._write_display_callback is not None: + await self._write_display_callback(self._datatype.dtype(value)) + + await self.process_without_display_update(value) + + async def process_without_display_update(self, value: T) -> None: + if self._process_callback is not None: + await self._process_callback(self._datatype.dtype(value)) + + def set_process_callback(self, callback: AttrCallback[T] | None) -> None: + self._process_callback = callback + + def has_process_callback(self) -> bool: + return self._process_callback is not None + + def set_write_display_callback(self, callback: AttrCallback[T] | None) -> None: + self._write_display_callback = callback + + @property + def sender(self) -> Sender | None: + return self._sender + + +class AttrRW(AttrW[T], AttrR[T]): + def __init__( + self, + datatype: DataType[T], + access_mode=AttrMode.READ_WRITE, + handler: Handler | None = None, + ) -> None: + super().__init__(datatype, access_mode, handler) # type: ignore + + async def process(self, value: T) -> None: + await self.set(value) + + await super().process(value) # type: ignore diff --git a/src/fastcs/backend.py b/src/fastcs/backend.py new file mode 100644 index 000000000..e2806c68f --- /dev/null +++ b/src/fastcs/backend.py @@ -0,0 +1,130 @@ +import asyncio +from collections import defaultdict +from types import MethodType +from typing import Callable + +from .attributes import AttrR, AttrW, Sender, Updater +from .exceptions import FastCSException +from .mapping import Mapping, SingleMapping + + +def _get_initial_tasks(mapping: Mapping) -> list[Callable]: + initial_tasks: list[Callable] = [] + initial_tasks.append(mapping.controller.connect) + return initial_tasks + + +def _create_periodic_scan_task(period, methods: list[Callable]) -> Callable: + async def scan_task() -> None: + while True: + await asyncio.gather(*[method() for method in methods]) + await asyncio.sleep(period) + + return scan_task + + +def _get_periodic_scan_tasks(scan_dict: dict[float, list[Callable]]) -> list[Callable]: + periodic_scan_tasks: list[Callable] = [] + for period, methods in scan_dict.items(): + periodic_scan_tasks.append(_create_periodic_scan_task(period, methods)) + + return periodic_scan_tasks + + +def _add_scan_method_tasks( + scan_dict: dict[float, list[Callable]], single_mapping: SingleMapping +): + for method in single_mapping.scan_methods.values(): + scan_dict[method.period].append( + MethodType(method.fn, single_mapping.controller) + ) + + +def _create_updater_callback(attribute, controller): + async def callback(): + await attribute.updater.update(controller, attribute) + + return callback + + +def _add_attribute_updater_tasks( + scan_dict: dict[float, list[Callable]], single_mapping: SingleMapping +): + for attribute in single_mapping.attributes.values(): + match attribute: + case AttrR(updater=Updater(update_period=update_period)) as attribute: + callback = _create_updater_callback( + attribute, single_mapping.controller + ) + scan_dict[update_period].append(callback) + + +def _get_scan_tasks(mapping: Mapping) -> list[Callable]: + scan_dict: dict[float, list[Callable]] = defaultdict(list) + + for single_mapping in mapping.get_controller_mappings(): + _add_scan_method_tasks(scan_dict, single_mapping) + _add_attribute_updater_tasks(scan_dict, single_mapping) + + scan_tasks = _get_periodic_scan_tasks(scan_dict) + return scan_tasks + + +def _link_single_controller_put_tasks(single_mapping: SingleMapping) -> None: + for name, method in single_mapping.put_methods.items(): + name = name.removeprefix("put_") + + attribute = single_mapping.attributes[name] + match attribute: + case AttrW(): + attribute.set_process_callback( + MethodType(method.fn, single_mapping.controller) + ) + case _: + raise FastCSException( + f"Mode {attribute.access_mode} does not " + f"support put operations for {name}" + ) + + +def _create_sender_callback(attribute, controller): + async def callback(value): + await attribute.sender.put(controller, attribute, value) + + return callback + + +def _link_attribute_sender_class(single_mapping: SingleMapping) -> None: + for attr_name, attribute in single_mapping.attributes.items(): + match attribute: + case AttrW(sender=Sender()): + assert ( + not attribute.has_process_callback() + ), f"Cannot assign both put method and Sender object to {attr_name}" + + callback = _create_sender_callback(attribute, single_mapping.controller) + attribute.set_process_callback(callback) + + +class Backend: + def __init__(self, mapping: Mapping, loop: asyncio.AbstractEventLoop): + self._mapping = mapping + self._loop = loop + + def link_process_tasks(self): + for single_mapping in self._mapping.get_controller_mappings(): + _link_single_controller_put_tasks(single_mapping) + _link_attribute_sender_class(single_mapping) + + def run_initial_tasks(self): + initial_tasks = _get_initial_tasks(self._mapping) + + for task in initial_tasks: + future = asyncio.run_coroutine_threadsafe(task(), self._loop) + future.result() + + def start_scan_tasks(self): + scan_tasks = _get_scan_tasks(self._mapping) + + for task in scan_tasks: + asyncio.run_coroutine_threadsafe(task(), self._loop) diff --git a/src/fastcs/backends/__init__.py b/src/fastcs/backends/__init__.py new file mode 100644 index 000000000..a91d1277e --- /dev/null +++ b/src/fastcs/backends/__init__.py @@ -0,0 +1,4 @@ +from .asyncio_backend import AsyncioBackend +from .epics.backend import EpicsBackend + +__all__ = ["EpicsBackend", "AsyncioBackend"] diff --git a/src/fastcs/backends/asyncio_backend.py b/src/fastcs/backends/asyncio_backend.py new file mode 100644 index 000000000..100869a26 --- /dev/null +++ b/src/fastcs/backends/asyncio_backend.py @@ -0,0 +1,30 @@ +from softioc import asyncio_dispatcher, softioc + +from fastcs.backend import Backend +from fastcs.mapping import Mapping + + +class AsyncioBackend: + def __init__(self, mapping: Mapping): + self._mapping = mapping + + def run_interactive_session(self): + # Create an asyncio dispatcher; the event loop is now running + dispatcher = asyncio_dispatcher.AsyncioDispatcher() + + backend = Backend(self._mapping, dispatcher.loop) + + backend.link_process_tasks() + backend.run_initial_tasks() + backend.start_scan_tasks() + + # Run the interactive shell + global_variables = globals() + global_variables.update( + { + "dispatcher": dispatcher, + "mapping": self._mapping, + "controller": self._mapping.controller, + } + ) + softioc.interactive_ioc(globals()) diff --git a/src/fastcs/backends/epics/__init__.py b/src/fastcs/backends/epics/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/fastcs/backends/epics/backend.py b/src/fastcs/backends/epics/backend.py new file mode 100644 index 000000000..80fca93df --- /dev/null +++ b/src/fastcs/backends/epics/backend.py @@ -0,0 +1,21 @@ +from fastcs.mapping import Mapping + +from .docs import EpicsDocs, EpicsDocsOptions +from .gui import EpicsGUI, EpicsGUIOptions +from .ioc import EpicsIOC + + +class EpicsBackend: + def __init__(self, mapping: Mapping): + self._mapping = mapping + + def create_docs(self, options: EpicsDocsOptions | None = None) -> None: + docs = EpicsDocs(self._mapping) + docs.create_docs(options) + + def create_gui(self, options: EpicsGUIOptions | None = None) -> None: + gui = EpicsGUI(self._mapping) + gui.create_gui(options) + + def get_ioc(self) -> EpicsIOC: + return EpicsIOC(self._mapping) diff --git a/src/fastcs/backends/epics/docs.py b/src/fastcs/backends/epics/docs.py new file mode 100644 index 000000000..c2bc569a2 --- /dev/null +++ b/src/fastcs/backends/epics/docs.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass +from pathlib import Path + +from fastcs.mapping import Mapping + + +@dataclass +class EpicsDocsOptions: + path: Path = Path.cwd() + depth: int | None = None + + +class EpicsDocs: + def __init__(self, mapping: Mapping) -> None: + self._mapping = mapping + + def create_docs(self, options: EpicsDocsOptions | None = None) -> None: + if options is None: + options = EpicsDocsOptions() diff --git a/src/fastcs/backends/epics/gui.py b/src/fastcs/backends/epics/gui.py new file mode 100644 index 000000000..31b2dce50 --- /dev/null +++ b/src/fastcs/backends/epics/gui.py @@ -0,0 +1,136 @@ +from dataclasses import dataclass +from enum import Enum +from pathlib import Path + +from pvi._format.base import Formatter +from pvi._yaml_utils import deserialize_yaml +from pvi.device import ( + LED, + CheckBox, + Component, + Device, + Grid, + Group, + ReadWidget, + SignalR, + SignalRW, + SignalW, + SignalX, + TextRead, + TextWrite, + Tree, + WriteWidget, +) + +from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW +from fastcs.datatypes import Bool, DataType, Float, Int +from fastcs.exceptions import FastCSException +from fastcs.mapping import Mapping + +FORMATTER_YAML = Path.cwd() / ".." / "pvi" / "formatters" / "dls.bob.pvi.formatter.yaml" + + +class EpicsGUIFormat(Enum): + bob = ".bob" + edl = ".edl" + + +@dataclass +class EpicsGUIOptions: + output_path: Path = Path.cwd() / "output.bob" + file_format: EpicsGUIFormat = EpicsGUIFormat.bob + + +class EpicsGUI: + def __init__(self, mapping: Mapping) -> None: + self._mapping = mapping + + @staticmethod + def _get_pv(attr_path: str, name: str): + if attr_path: + attr_path = ":" + attr_path + attr_path += ":" + + pv = attr_path.upper() + name.title().replace("_", "") + + return pv + + @staticmethod + def _get_read_widget(datatype: DataType) -> ReadWidget: + match datatype: + case Bool(): + return LED() + case Int() | Float(): + return TextRead() + case _: + raise FastCSException(f"Unsupported type {type(datatype)}: {datatype}") + + @staticmethod + def _get_write_widget(datatype: DataType) -> WriteWidget: + match datatype: + case Bool(): + return CheckBox() + case Int() | Float(): + return TextWrite() + case _: + raise FastCSException(f"Unsupported type {type(datatype)}: {datatype}") + + @classmethod + def _get_attribute_component(cls, attr_path: str, name: str, attribute: Attribute): + pv = cls._get_pv(attr_path, name) + name = name.title().replace("_", " ") + + match attribute: + case AttrRW(): + read_widget = cls._get_read_widget(attribute.datatype) + write_widget = cls._get_write_widget(attribute.datatype) + return SignalRW(name, pv, write_widget, pv + "_RBV", read_widget) + case AttrR(): + read_widget = cls._get_read_widget(attribute.datatype) + return SignalR(name, pv, read_widget) + case AttrW(): + write_widget = cls._get_write_widget(attribute.datatype) + return SignalW(name, pv, TextWrite()) + + @classmethod + def _get_command_component(cls, attr_path: str, name: str): + pv = cls._get_pv(attr_path, name) + name = name.title().replace("_", " ") + + return SignalX(name, pv, value=1) + + def create_gui(self, options: EpicsGUIOptions | None = None) -> None: + if options is None: + options = EpicsGUIOptions() + + if options.file_format is EpicsGUIFormat.edl: + raise FastCSException("FastCS does not support .edl screens.") + + assert options.output_path.suffix == options.file_format.value + + formatter = deserialize_yaml(Formatter, FORMATTER_YAML) + + components: Tree[Component] = [] + for single_mapping in self._mapping.get_controller_mappings(): + attr_path = single_mapping.controller.path + + group_name = type(single_mapping.controller).__name__ + " " + attr_path + group_children: list[Component] = [] + + for attr_name, attribute in single_mapping.attributes.items(): + group_children.append( + self._get_attribute_component( + attr_path, + attr_name, + attribute, + ) + ) + + for name in single_mapping.command_methods: + group_children.append(self._get_command_component(attr_path, name)) + + components.append(Group(group_name, Grid(), group_children)) + + device = Device("Simple Device", children=components) + + formatter.format(device, "MY-DEVICE-PREFIX", options.output_path) diff --git a/src/fastcs/backends/epics/ioc.py b/src/fastcs/backends/epics/ioc.py new file mode 100644 index 000000000..adc14758a --- /dev/null +++ b/src/fastcs/backends/epics/ioc.py @@ -0,0 +1,144 @@ +from dataclasses import dataclass +from types import MethodType +from typing import Any, Callable + +from softioc import asyncio_dispatcher, builder, softioc +from softioc.pythonSoftIoc import RecordWrapper + +from fastcs.attributes import AttrR, AttrRW, AttrW +from fastcs.backend import Backend +from fastcs.datatypes import Bool, DataType, Float, Int +from fastcs.exceptions import FastCSException +from fastcs.mapping import Mapping + + +@dataclass +class EpicsIOCOptions: + terminal: bool = True + + +def _get_input_record(pv_name: str, datatype: DataType) -> RecordWrapper: + match datatype: + case Bool(znam, onam): + return builder.boolIn(pv_name, ZNAM=znam, ONAM=onam) + case Int(): + return builder.longIn(pv_name) + case Float(prec): + return builder.aIn(pv_name, PREC=prec) + case _: + raise FastCSException(f"Unsupported type {type(datatype)}: {datatype}") + + +def _create_and_link_read_pv(pv_name: str, attribute: AttrR) -> None: + record = _get_input_record(pv_name, attribute._datatype) + + async def async_wrapper(v): + record.set(v) + + attribute.set_update_callback(async_wrapper) + + +def _get_output_record(pv_name: str, datatype: DataType, on_update: Callable) -> Any: + match datatype: + case Bool(znam, onam): + return builder.boolOut( + pv_name, + ZNAM=znam, + ONAM=onam, + always_update=True, + on_update=on_update, + ) + case Int(): + return builder.longOut(pv_name, always_update=True, on_update=on_update) + case Float(prec): + return builder.aOut( + pv_name, always_update=True, on_update=on_update, PREC=prec + ) + case _: + raise FastCSException(f"Unsupported type {type(datatype)}: {datatype}") + + +def _create_and_link_write_pv(pv_name: str, attribute: AttrW) -> None: + record = _get_output_record( + pv_name, attribute.datatype, on_update=attribute.process_without_display_update + ) + + async def async_wrapper(v): + record.set(v) + + attribute.set_write_display_callback(async_wrapper) + + +def _create_and_link_command_pv(pv_name: str, method: Callable) -> None: + async def wrapped_method(_: Any): + await method() + + builder.aOut(pv_name, always_update=True, on_update=wrapped_method) + + +def _create_and_link_attribute_pvs(mapping: Mapping) -> None: + for single_mapping in mapping.get_controller_mappings(): + path = single_mapping.controller.path + for attr_name, attribute in single_mapping.attributes.items(): + attr_name = attr_name.title().replace("_", "") + pv_name = path.upper() + ":" + attr_name if path else attr_name + + match attribute: + case AttrRW(): + _create_and_link_read_pv(pv_name + "_RBV", attribute) + _create_and_link_write_pv(pv_name, attribute) + case AttrR(): + _create_and_link_read_pv(pv_name, attribute) + case AttrW(): + _create_and_link_write_pv(pv_name, attribute) + + +def _create_and_link_command_pvs(mapping: Mapping) -> None: + for single_mapping in mapping.get_controller_mappings(): + path = single_mapping.controller.path + for name, method in single_mapping.command_methods.items(): + name = name.title().replace("_", "") + pv_name = path.upper() + ":" + name if path else name + + _create_and_link_command_pv( + pv_name, MethodType(method.fn, single_mapping.controller) + ) + + +class EpicsIOC: + def __init__(self, mapping: Mapping): + self._mapping = mapping + + def run(self, options: EpicsIOCOptions | None = None) -> None: + if options is None: + options = EpicsIOCOptions() + + # Create an asyncio dispatcher; the event loop is now running + dispatcher = asyncio_dispatcher.AsyncioDispatcher() + backend = Backend(self._mapping, dispatcher.loop) + + # Set the record prefix + builder.SetDeviceName("MY-DEVICE-PREFIX") + + _create_and_link_attribute_pvs(self._mapping) + + _create_and_link_command_pvs(self._mapping) + + # Boilerplate to get the IOC started + builder.LoadDatabase() + softioc.iocInit(dispatcher) + + backend.link_process_tasks() + backend.run_initial_tasks() + backend.start_scan_tasks() + + # Run the interactive shell + global_variables = globals() + global_variables.update( + { + "dispatcher": dispatcher, + "mapping": self._mapping, + "controller": self._mapping.controller, + } + ) + softioc.interactive_ioc(globals()) diff --git a/src/fastcs/connections.py b/src/fastcs/connections.py new file mode 100644 index 000000000..b121066dc --- /dev/null +++ b/src/fastcs/connections.py @@ -0,0 +1,54 @@ +import asyncio +from dataclasses import dataclass + + +class DisconnectedError(Exception): + pass + + +@dataclass +class IPConnectionSettings: + ip: str = "127.0.0.1" + port: int = 25565 + + +class IPConnection: + def __init__(self): + self._reader, self._writer = (None, None) + self._lock = asyncio.Lock() + + async def connect(self, settings: IPConnectionSettings): + self._reader, self._writer = await asyncio.open_connection( + settings.ip, settings.port + ) + + def ensure_connected(self): + if self._reader is None or self._writer is None: + raise DisconnectedError("Need to call connect() before using IPConnection.") + + async def send_command(self, message) -> None: + async with self._lock: + self.ensure_connected() + await self._send_message(message) + + async def send_query(self, message) -> str: + async with self._lock: + self.ensure_connected() + await self._send_message(message) + return await self._receive_response() + + # TODO: Figure out type hinting for connections. TypeGuard fails to work as expected + async def close(self): + async with self._lock: + self.ensure_connected() + self._writer.close() + await self._writer.wait_closed() + self._reader, self._writer = (None, None) + + async def _send_message(self, message) -> None: + self._writer.write(message.encode("utf-8")) + await self._writer.drain() + + async def _receive_response(self) -> str: + data = await self._reader.readline() + return data.decode("utf-8") diff --git a/src/fastcs/controller.py b/src/fastcs/controller.py new file mode 100755 index 000000000..cf37e531f --- /dev/null +++ b/src/fastcs/controller.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from copy import copy + +from .attributes import Attribute + + +class BaseController: + def __init__(self, path="") -> None: + self._path: str = path + self._bind_attrs() + + @property + def path(self): + return self._path + + def _bind_attrs(self) -> None: + for attr_name in dir(self): + attr = getattr(self, attr_name) + if isinstance(attr, Attribute): + new_attribute = copy(attr) + setattr(self, attr_name, new_attribute) + + +class Controller(BaseController): + def __init__(self) -> None: + super().__init__() + self.__sub_controllers: list[SubController] = [] + + async def connect(self) -> None: + pass + + def register_sub_controller(self, controller: SubController): + self.__sub_controllers.append(controller) + + def get_sub_controllers(self) -> list[SubController]: + return self.__sub_controllers + + +class SubController(BaseController): + def __init__(self, path: str) -> None: + super().__init__(path) diff --git a/src/fastcs/cs_methods.py b/src/fastcs/cs_methods.py new file mode 100644 index 000000000..20cc4b37d --- /dev/null +++ b/src/fastcs/cs_methods.py @@ -0,0 +1,81 @@ +from asyncio import iscoroutinefunction +from inspect import Signature, getdoc, signature +from typing import Awaitable, Callable + +from .exceptions import FastCSException + +ScanCallback = Callable[..., Awaitable[None]] + + +class Method: + def __init__(self, fn: Callable) -> None: + self._docstring = getdoc(fn) + + sig = signature(fn, eval_str=True) + self._parameters = sig.parameters + self._return_type = sig.return_annotation + self._validate(fn) + + self._fn = fn + + def _validate(self, fn: Callable) -> None: + if self.return_type not in (None, Signature.empty): + raise FastCSException("Method return type must be None or empty") + + if not iscoroutinefunction(fn): + raise FastCSException("Method must be async function") + + @property + def return_type(self): + return self._return_type + + @property + def parameters(self): + return self._parameters + + @property + def docstring(self): + return self._docstring + + @property + def fn(self): + return self._fn + + +class Scan(Method): + def __init__(self, fn: Callable, period) -> None: + super().__init__(fn) + + self._period = period + + def _validate(self, fn: Callable) -> None: + super()._validate(fn) + + if not len(self.parameters) == 1: + raise FastCSException("Scan method cannot have arguments") + + @property + def period(self): + return self._period + + +class Put(Method): + def __init__(self, fn: Callable) -> None: + super().__init__(fn) + + def _validate(self, fn: Callable) -> None: + super()._validate(fn) + + if not len(self.parameters) == 2: + raise FastCSException("Put method can only take one argument") + + +class Command(Method): + def __init__(self, fn: Callable) -> None: + super().__init__(fn) + + def _validate(self, fn: Callable) -> None: + super()._validate(fn) + + if not len(self.parameters) == 1: + raise FastCSException("Command method cannot have arguments") diff --git a/src/fastcs/datatypes.py b/src/fastcs/datatypes.py new file mode 100644 index 000000000..4f04c941c --- /dev/null +++ b/src/fastcs/datatypes.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from abc import abstractmethod +from dataclasses import dataclass +from typing import Awaitable, Callable, Generic, TypeVar + +T = TypeVar("T", int, float, bool) +ATTRIBUTE_TYPES: tuple[type] = T.__constraints__ # type: ignore + + +AttrCallback = Callable[[T], Awaitable[None]] + + +class DataType(Generic[T]): + @property + @abstractmethod + def dtype(self) -> type[T]: # Using property due to lack of Generic ClassVars + pass + + +@dataclass(frozen=True) +class Int(DataType[int]): + @property + def dtype(self) -> type[int]: + return int + + +@dataclass(frozen=True) +class Float(DataType[float]): + prec: int = 2 + + @property + def dtype(self) -> type[float]: + return float + + +@dataclass(frozen=True) +class Bool(DataType[bool]): + znam: str = "OFF" + onam: str = "ON" + + @property + def dtype(self) -> type[bool]: + return bool diff --git a/src/fastcs/exceptions.py b/src/fastcs/exceptions.py new file mode 100644 index 000000000..5e1f375fb --- /dev/null +++ b/src/fastcs/exceptions.py @@ -0,0 +1,2 @@ +class FastCSException(Exception): + pass diff --git a/src/fastcs/mapping.py b/src/fastcs/mapping.py new file mode 100644 index 000000000..015804094 --- /dev/null +++ b/src/fastcs/mapping.py @@ -0,0 +1,59 @@ +from dataclasses import dataclass + +from .attributes import Attribute +from .controller import BaseController, Controller +from .cs_methods import Command, Put, Scan +from .wrappers import WrappedMethod + + +@dataclass +class SingleMapping: + controller: BaseController + scan_methods: dict[str, Scan] + put_methods: dict[str, Put] + command_methods: dict[str, Command] + attributes: dict[str, Attribute] + + +class Mapping: + def __init__(self, controller: Controller) -> None: + self.controller = controller + + self._controller_mappings: list[SingleMapping] = [] + self._controller_mappings.append(self._get_single_mapping(controller)) + + for sub_controller in controller.get_sub_controllers(): + self._controller_mappings.append(self._get_single_mapping(sub_controller)) + + @staticmethod + def _get_single_mapping(controller: BaseController) -> SingleMapping: + scan_methods = {} + put_methods = {} + command_methods = {} + attributes = {} + for attr_name in dir(controller): + attr = getattr(controller, attr_name) + match attr: + case WrappedMethod(fastcs_method=fastcs_method): + match fastcs_method: + case Put(): + put_methods[attr_name] = fastcs_method + case Scan(): + scan_methods[attr_name] = fastcs_method + case Command(): + command_methods[attr_name] = fastcs_method + case Attribute(): + attributes[attr_name] = attr + + return SingleMapping( + controller, scan_methods, put_methods, command_methods, attributes + ) + + def __str__(self) -> str: + result = "Controller mappings:\n" + for mapping in self._controller_mappings: + result += f"{mapping}\n" + return result + + def get_controller_mappings(self) -> list[SingleMapping]: + return self._controller_mappings diff --git a/src/fastcs/wrappers.py b/src/fastcs/wrappers.py new file mode 100644 index 000000000..9d45e4cc9 --- /dev/null +++ b/src/fastcs/wrappers.py @@ -0,0 +1,31 @@ +from typing import Any, Protocol, runtime_checkable + +from .cs_methods import Command, Method, Put, Scan +from .exceptions import FastCSException + + +@runtime_checkable +class WrappedMethod(Protocol): + fastcs_method: Method + + +# TODO: Consider type hints with the use of typing.Protocol +def scan(period: float) -> Any: + if period <= 0: + raise FastCSException("Scan method must have a positive scan period") + + def wrapper(fn): + fn.fastcs_method = Scan(fn, period) + return fn + + return wrapper + + +def put(fn) -> Any: + fn.fastcs_method = Put(fn) + return fn + + +def command(fn) -> Any: + fn.fastcs_method = Command(fn) + return fn