Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 40 additions & 9 deletions src/fastcs/attributes.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

from collections.abc import Callable
from enum import Enum
from typing import Any, Generic, Protocol, runtime_checkable

Expand All @@ -26,7 +27,8 @@
class Updater(Protocol):
"""Protocol for updating the cached readback value of an ``Attribute``."""

update_period: float
# If update period is None then the attribute will not be updated as a task.
update_period: float | None = None

async def update(self, controller: Any, attr: AttrR) -> None:
pass
Expand All @@ -52,6 +54,7 @@
group: str | None = None,
handler: Any = None,
allowed_values: list[T] | None = None,
description: str | None = None,
) -> None:
assert (
datatype.dtype in ATTRIBUTE_TYPES
Expand All @@ -61,6 +64,11 @@
self._group = group
self.enabled = True
self._allowed_values: list[T] | None = allowed_values
self.description = description

# A callback to use when setting the datatype to a different value, for example
# changing the units on an int. This should be implemented in the backend.
self._update_datatype_callback: Callable[[DataType[T]], None] | None = None

@property
def datatype(self) -> DataType[T]:
Expand All @@ -82,6 +90,18 @@
def allowed_values(self) -> list[T] | None:
return self._allowed_values

def set_update_datatype_callback(
self, callback: Callable[[DataType[T]], None] | None
) -> None:
self._update_datatype_callback = callback

def update_datatype(self, datatype: DataType[T]) -> None:
if not isinstance(self._datatype, type(datatype)):
raise ValueError(f"Attribute datatype must be of type {type(datatype)}")
self._datatype = datatype
if self._update_datatype_callback is not None:
self._update_datatype_callback(datatype)

Check warning on line 103 in src/fastcs/attributes.py

View check run for this annotation

Codecov / codecov/patch

src/fastcs/attributes.py#L99-L103

Added lines #L99 - L103 were not covered by tests


class AttrR(Attribute[T]):
"""A read-only ``Attribute``."""
Expand All @@ -92,24 +112,29 @@
access_mode=AttrMode.READ,
group: str | None = None,
handler: Updater | None = None,
initial_value: T | None = None,
allowed_values: list[T] | None = None,
description: str | None = None,
) -> None:
super().__init__(
datatype, # type: ignore
access_mode,
group,
handler,
allowed_values=allowed_values, # type: ignore
description=description,
)
self._value: T = (
datatype.initial_value if initial_value is None else initial_value
)
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)
self._value = self._datatype.cast(value)

if self._update_callback is not None:
await self._update_callback(self._value)
Expand All @@ -132,13 +157,15 @@
group: str | None = None,
handler: Sender | None = None,
allowed_values: list[T] | None = None,
description: str | None = None,
) -> None:
super().__init__(
datatype, # type: ignore
access_mode,
group,
handler,
allowed_values=allowed_values, # type: ignore
description=description,
)
self._process_callback: AttrCallback[T] | None = None
self._write_display_callback: AttrCallback[T] | None = None
Expand All @@ -150,11 +177,11 @@

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))
await self._process_callback(self._datatype.cast(value))

async def update_display_without_process(self, value: T) -> None:
if self._write_display_callback is not None:
await self._write_display_callback(self._datatype.dtype(value))
await self._write_display_callback(self._datatype.cast(value))

def set_process_callback(self, callback: AttrCallback[T] | None) -> None:
self._process_callback = callback
Expand All @@ -170,7 +197,7 @@
return self._sender


class AttrRW(AttrW[T], AttrR[T]):
class AttrRW(AttrR[T], AttrW[T]):
"""A read-write ``Attribute``."""

def __init__(
Expand All @@ -179,14 +206,18 @@
access_mode=AttrMode.READ_WRITE,
group: str | None = None,
handler: Handler | None = None,
initial_value: T | None = None,
allowed_values: list[T] | None = None,
description: str | None = None,
) -> None:
super().__init__(
datatype, # type: ignore
access_mode,
group,
handler,
allowed_values, # type: ignore
group=group,
handler=handler,
initial_value=initial_value,
allowed_values=allowed_values, # type: ignore
description=description,
)

async def process(self, value: T) -> None:
Expand Down
46 changes: 28 additions & 18 deletions src/fastcs/backend.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import asyncio
from collections import defaultdict
from collections.abc import Callable
from concurrent.futures import Future
from types import MethodType

from softioc.asyncio_dispatcher import AsyncioDispatcher
Expand All @@ -21,7 +20,7 @@
self._controller = controller

self._initial_tasks = [controller.connect]
self._scan_tasks: list[Future] = []
self._scan_tasks: list[asyncio.Task] = []

asyncio.run_coroutine_threadsafe(
self._controller.initialise(), self._loop
Expand All @@ -41,22 +40,31 @@
_link_single_controller_put_tasks(single_mapping)
_link_attribute_sender_class(single_mapping)

def __del__(self):
self.stop_scan_tasks()

Check warning on line 44 in src/fastcs/backend.py

View check run for this annotation

Codecov / codecov/patch

src/fastcs/backend.py#L44

Added line #L44 was not covered by tests

def run(self):
self._run_initial_tasks()
self._start_scan_tasks()

self.start_scan_tasks()
self._run()

def _run_initial_tasks(self):
for task in self._initial_tasks:
future = asyncio.run_coroutine_threadsafe(task(), self._loop)
future.result()

def _start_scan_tasks(self):
scan_tasks = _get_scan_tasks(self._mapping)
def start_scan_tasks(self):
self._scan_tasks = [
self._loop.create_task(coro()) for coro in _get_scan_coros(self._mapping)
]

for task in scan_tasks:
asyncio.run_coroutine_threadsafe(task(), self._loop)
def stop_scan_tasks(self):
for task in self._scan_tasks:
if not task.done():
try:
task.cancel()
except asyncio.CancelledError:
pass

Check warning on line 67 in src/fastcs/backend.py

View check run for this annotation

Codecov / codecov/patch

src/fastcs/backend.py#L66-L67

Added lines #L66 - L67 were not covered by tests

def _run(self):
raise NotImplementedError("Specific Backend must implement _run")
Expand Down Expand Up @@ -98,15 +106,15 @@
return callback


def _get_scan_tasks(mapping: Mapping) -> list[Callable]:
def _get_scan_coros(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
scan_coros = _get_periodic_scan_coros(scan_dict)
return scan_coros


def _add_scan_method_tasks(
Expand All @@ -124,6 +132,8 @@
for attribute in single_mapping.attributes.values():
match attribute:
case AttrR(updater=Updater(update_period=update_period)) as attribute:
if update_period is None:
continue

Check warning on line 136 in src/fastcs/backend.py

View check run for this annotation

Codecov / codecov/patch

src/fastcs/backend.py#L136

Added line #L136 was not covered by tests
callback = _create_updater_callback(
attribute, single_mapping.controller
)
Expand All @@ -144,18 +154,18 @@
return callback


def _get_periodic_scan_tasks(scan_dict: dict[float, list[Callable]]) -> list[Callable]:
periodic_scan_tasks: list[Callable] = []
def _get_periodic_scan_coros(scan_dict: dict[float, list[Callable]]) -> list[Callable]:
periodic_scan_coros: list[Callable] = []
for period, methods in scan_dict.items():
periodic_scan_tasks.append(_create_periodic_scan_task(period, methods))
periodic_scan_coros.append(_create_periodic_scan_coro(period, methods))

return periodic_scan_tasks
return periodic_scan_coros


def _create_periodic_scan_task(period, methods: list[Callable]) -> Callable:
async def scan_task() -> None:
def _create_periodic_scan_coro(period, methods: list[Callable]) -> Callable:
async def scan_coro() -> None:
while True:
await asyncio.gather(*[method() for method in methods])
await asyncio.sleep(period)

return scan_task
return scan_coro
25 changes: 17 additions & 8 deletions src/fastcs/backends/epics/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,26 @@


class EpicsBackend(Backend):
def __init__(self, controller: Controller, pv_prefix: str = "MY-DEVICE-PREFIX"):
def __init__(
self,
controller: Controller,
pv_prefix: str = "MY-DEVICE-PREFIX",
ioc_options: EpicsIOCOptions | None = None,
):
super().__init__(controller)

self._pv_prefix = pv_prefix
self._ioc = EpicsIOC(pv_prefix, self._mapping)
self.ioc_options = ioc_options or EpicsIOCOptions()
self._ioc = EpicsIOC(pv_prefix, self._mapping, options=ioc_options)

def create_docs(self, options: EpicsDocsOptions | None = None) -> None:
EpicsDocs(self._mapping).create_docs(options)
def create_docs(self, docs_options: EpicsDocsOptions | None = None) -> None:
EpicsDocs(self._mapping).create_docs(docs_options)

Check warning on line 23 in src/fastcs/backends/epics/backend.py

View check run for this annotation

Codecov / codecov/patch

src/fastcs/backends/epics/backend.py#L23

Added line #L23 was not covered by tests

def create_gui(self, options: EpicsGUIOptions | None = None) -> None:
EpicsGUI(self._mapping, self._pv_prefix).create_gui(options)
def create_gui(self, gui_options: EpicsGUIOptions | None = None) -> None:
assert self.ioc_options.name_options is not None
EpicsGUI(

Check warning on line 27 in src/fastcs/backends/epics/backend.py

View check run for this annotation

Codecov / codecov/patch

src/fastcs/backends/epics/backend.py#L26-L27

Added lines #L26 - L27 were not covered by tests
self._mapping, self._pv_prefix, self.ioc_options.name_options
).create_gui(gui_options)

def _run(self, options: EpicsIOCOptions | None = None):
self._ioc.run(self._dispatcher, self._context, options)
def _run(self):
self._ioc.run(self._dispatcher, self._context)
36 changes: 32 additions & 4 deletions src/fastcs/backends/epics/gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@
from pydantic import ValidationError

from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW
from fastcs.backends.epics.util import (
EpicsNameOptions,
_convert_attribute_name_to_pv_name,
)
from fastcs.cs_methods import Command
from fastcs.datatypes import Bool, Float, Int, String
from fastcs.exceptions import FastCSException
Expand All @@ -39,21 +43,45 @@ class EpicsGUIFormat(Enum):
edl = ".edl"


@dataclass
@dataclass(frozen=True)
class EpicsGUIOptions:
output_path: Path = Path.cwd() / "output.bob"
file_format: EpicsGUIFormat = EpicsGUIFormat.bob
title: str = "Simple Device"


class EpicsGUI:
def __init__(self, mapping: Mapping, pv_prefix: str) -> None:
def __init__(
self,
mapping: Mapping,
pv_prefix: str,
epics_name_options: EpicsNameOptions | None = None,
) -> None:
self._mapping = mapping
self._pv_prefix = pv_prefix
self.epics_name_options = epics_name_options or EpicsNameOptions()

def _get_pv(self, attr_path: list[str], name: str):
attr_prefix = ":".join([self._pv_prefix] + attr_path)
return f"{attr_prefix}:{name.title().replace('_', '')}"
return self.epics_name_options.pv_separator.join(
[
self._pv_prefix,
]
+ [
_convert_attribute_name_to_pv_name(
attr_name,
self.epics_name_options.pv_naming_convention,
is_attribute=False,
)
for attr_name in attr_path
]
+ [
_convert_attribute_name_to_pv_name(
name,
self.epics_name_options.pv_naming_convention,
is_attribute=True,
),
],
)

@staticmethod
def _get_read_widget(attribute: AttrR) -> ReadWidgetUnion:
Expand Down
Loading
Loading