Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/workflows/_release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:
- name: Create GitHub Release
# We pin to the SHA, not the tag, for security reasons.
# https://docs.github.com/en/actions/learn-github-actions/security-hardening-for-github-actions#using-third-party-actions
uses: softprops/action-gh-release@c062e08bd532815e2082a85e87e3ef29c3e6d191 # v2.0.8
uses: softprops/action-gh-release@7b4da11513bf3f43f9999e90eabced41ab8bb048 # v2.2.0
with:
prerelease: ${{ contains(github.ref_name, 'a') || contains(github.ref_name, 'b') || contains(github.ref_name, 'rc') }}
files: "*"
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ jobs:
run: tox -e tests

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
uses: codecov/codecov-action@v5
with:
name: ${{ inputs.python-version }}/${{ inputs.runs-on }}
files: cov.xml
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
strategy:
matrix:
runs-on: ["ubuntu-latest"] # can add windows-latest, macos-latest
python-version: ["3.10", "3.11", "3.12"]
python-version: ["3.11", "3.12"]
include:
# Include one that runs in the dev environment
- runs-on: "ubuntu-latest"
Expand Down
Binary file removed .pyproject.toml.swp
Binary file not shown.
2 changes: 1 addition & 1 deletion docs/tutorials/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Check your version of python

You will need python 3.10 or later. You can check your version of python by
You will need python 3.11 or later. You can check your version of python by
typing into a terminal:

```
Expand Down
16 changes: 12 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,21 @@ name = "fastcs-pandablocks"
classifiers = [
"Development Status :: 3 - Alpha",
"License :: OSI Approved :: Apache Software License",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
description = "A softioc to control a PandABlocks-FPGA."
dependencies = [] # Add project dependencies here, e.g. ["click", "numpy"]
dependencies = [
"fastcs@git+https://github.com/DiamondLightSource/FastCS@panda-conversion-improvements",
"pandablocks~=0.10.0",
"numpy<2", # until https://github.com/mdavidsaver/p4p/issues/145 is fixed
"pydantic>2",
"h5py",
]
dynamic = ["version"]
license.file = "LICENSE"
readme = "README.md"
requires-python = ">=3.10"
requires-python = ">=3.11"

[project.optional-dependencies]
dev = [
Expand All @@ -37,7 +42,7 @@ dev = [
]

[project.scripts]
fastcs-PandABlocks = "fastcs_pandablocks.__main__:main"
fastcs-pandablocks = "fastcs_pandablocks.__main__:main"

[project.urls]
GitHub = "https://github.com/PandABlocks-ioc/fastcs-PandABlocks"
Expand Down Expand Up @@ -114,3 +119,6 @@ lint.select = [
# See https://github.com/DiamondLightSource/python-copier-template/issues/154
# Remove this line to forbid private member access in tests
"tests/**/*" = ["SLF001"]
# Ruff wants us to use `(|)` instead of `Union[,]` in types, but pyright warns against
# it. For now we'll use `Union` and turn the ruff error off.
"**" = ["UP007"]
60 changes: 54 additions & 6 deletions src/fastcs_pandablocks/__init__.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,59 @@
"""Top level API.
"""Contains logic relevant to fastcs. Will use `fastcs_pandablocks.panda`."""

.. data:: __version__
:type: str
from pathlib import Path

Version number as calculated by https://github.com/pypa/setuptools_scm
"""
from fastcs.backends.epics.backend import EpicsBackend
from fastcs.backends.epics.gui import EpicsGUIFormat
from fastcs.backends.epics.ioc import EpicsIOCOptions
from fastcs.backends.epics.util import EpicsNameOptions, PvNamingConvention

from ._version import __version__
from .gui import PandaGUIOptions
from .panda.controller import PandaController

__all__ = ["__version__"]
DEFAULT_POLL_PERIOD = 0.1


def ioc(
epics_prefix: str,
hostname: str,
screens_directory: Path | None = None,
clear_bobfiles: bool = False,
poll_period: float = DEFAULT_POLL_PERIOD,
naming_convention: PvNamingConvention = PvNamingConvention.CAPITALIZED,
pv_separator: str = ":",
):
name_options = EpicsNameOptions(
pv_naming_convention=naming_convention, pv_separator=pv_separator
)
epics_ioc_options = EpicsIOCOptions(terminal=True, name_options=name_options)

controller = PandaController(hostname, poll_period)
backend = EpicsBackend(
controller, pv_prefix=epics_prefix, ioc_options=epics_ioc_options
)

if clear_bobfiles and not screens_directory:
raise ValueError("`clear_bobfiles` is True with no `screens_directory`")

if screens_directory:
if not screens_directory.is_dir():
raise ValueError(
f"`screens_directory` {screens_directory} is not a directory"
)
if not clear_bobfiles:
if list(screens_directory.iterdir()):
raise RuntimeError("`screens_directory` is not empty.")

backend.create_gui(
PandaGUIOptions(
output_path=screens_directory / "output.bob",
file_format=EpicsGUIFormat.bob,
title="PandA",
)
)

backend.run()


__all__ = ["__version__", "ioc", "DEFAULT_POLL_PERIOD"]
86 changes: 81 additions & 5 deletions src/fastcs_pandablocks/__main__.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,99 @@
"""Interface for ``python -m fastcs_pandablocks``."""

from argparse import ArgumentParser
from collections.abc import Sequence
import argparse
import logging
from pathlib import Path

from fastcs.backends.epics.util import PvNamingConvention

from fastcs_pandablocks import DEFAULT_POLL_PERIOD, ioc

from . import __version__

__all__ = ["main"]


def main(args: Sequence[str] | None = None) -> None:
def main():
"""Argument parser for the CLI."""
parser = ArgumentParser()
parser = argparse.ArgumentParser(
description="Connect to the given HOST and create an IOC with the given PREFIX."
)
parser.add_argument(
"-v",
"--version",
action="version",
version=__version__,
)
parser.parse_args(args)

subparsers = parser.add_subparsers(dest="command", required=True)
run_parser = subparsers.add_parser(
"run", help="Run the IOC with the given HOST and PREFIX."
)
run_parser.add_argument("hostname", type=str, help="The host to connect to.")
run_parser.add_argument("prefix", type=str, help="The prefix for the IOC.")
run_parser.add_argument(
"--screens-dir",
type=str,
help=(
"Provide an existing directory to export generated bobfiles to, if no "
"directory is provided then bobfiles will not be generated."
),
)
run_parser.add_argument(
"--clear-bobfiles",
action="store_true",
help=(
"Overwrite existing bobfiles from the given `screens-dir` "
"before generating new ones."
),
)

run_parser.add_argument(
"--log-level",
default="INFO",
choices=["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"],
help="Set the logging level.",
)
run_parser.add_argument(
"--poll-period",
default=DEFAULT_POLL_PERIOD,
type=float,
help="Period in seconds with which to poll the panda.",
)
run_parser.add_argument(
"--pv-naming-convention",
default=PvNamingConvention.CAPITALIZED.name,
choices=[choice.name for choice in PvNamingConvention],
help="Naming convention of the EPICS PVs.",
)
run_parser.add_argument(
"--pv-separator",
default=":",
type=str,
help="Separator to use between EPICS PV sections.",
)

parsed_args = parser.parse_args()
if parsed_args.command != "run":
return

# Set the logging level
level = getattr(logging, parsed_args.log_level.upper(), None)
logging.basicConfig(format="%(levelname)s:%(message)s", level=level)

screens_directory = (
Path(parsed_args.screens_dir) if parsed_args.screens_dir else None
)

ioc(
parsed_args.prefix,
parsed_args.hostname,
screens_directory=screens_directory,
clear_bobfiles=parsed_args.clear_bobfiles,
poll_period=parsed_args.poll_period,
naming_convention=PvNamingConvention(parsed_args.pv_naming_convention),
pv_separator=parsed_args.pv_separator,
)


if __name__ == "__main__":
Expand Down
4 changes: 4 additions & 0 deletions src/fastcs_pandablocks/gui.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from fastcs.backends.epics.gui import EpicsGUIOptions


class PandaGUIOptions(EpicsGUIOptions): ...
62 changes: 62 additions & 0 deletions src/fastcs_pandablocks/handlers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
from dataclasses import asdict
from typing import Any

from fastcs.attributes import Attribute, AttrR, AttrW, Handler, Sender, Updater

from fastcs_pandablocks.types import PandaName


class DefaultFieldSender(Sender):
def __init__(self, panda_name: PandaName):
self.panda_name = panda_name

async def put(self, controller: Any, attr: AttrW, value: str) -> None:
await controller.put_value_to_panda(self.panda_name, value)


class DefaultFieldUpdater(Updater):
#: We update the fields from the top level
update_period = None

def __init__(self, panda_name: PandaName):
self.panda_name = panda_name

async def update(self, controller: Any, attr: AttrR) -> None:
pass # TODO: update the attr with the value from the panda


class DefaultFieldHandler(DefaultFieldSender, DefaultFieldUpdater, Handler):
def __init__(self, panda_name: PandaName):
super().__init__(panda_name)


class EguSender(Sender):
def __init__(self, panda_name: PandaName, attr_to_update: Attribute):
"""Update the attr"""
self.panda_name = panda_name
self.attr_to_update = attr_to_update

async def put(self, controller: Any, attr: AttrW, value: str) -> None:
await controller.put_value_to_panda(self.panda_name, value)
kwargs = asdict(self.attr_to_update.datatype)
kwargs["units"] = value
new_attribute_datatype = type(self.attr_to_update.datatype)(**kwargs)
self.attr_to_update.update_datatype(new_attribute_datatype)


class CaptureHandler(Handler):
update_period = float("inf")

def __init__(self, *args, **kwargs):
pass

async def update(self, controller: Any, attr: AttrR) -> None: ...
async def put(self, controller: Any, attr: AttrW, value: Any) -> None: ...


class DatasetHandler(Handler):
update_period = float("inf")

def __init__(self, *args, **kwargs): ... # TODO: work dataset
async def update(self, controller: Any, attr: AttrR) -> None: ...
async def put(self, controller: Any, attr: AttrW, value: Any) -> None: ...
Empty file.
81 changes: 81 additions & 0 deletions src/fastcs_pandablocks/panda/client_wrapper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""
This method has a `RawPanda` which handles all the io with the client.
"""

import asyncio

from pandablocks.asyncio import AsyncioClient
from pandablocks.commands import (
ChangeGroup,
GetBlockInfo,
GetChanges,
GetFieldInfo,
Put,
)

from fastcs_pandablocks.types import (
PandaName,
RawBlocksType,
RawFieldsType,
RawInitialValuesType,
)


class RawPanda:
def __init__(self, hostname: str):
self._client = AsyncioClient(host=hostname)

async def connect(self):
await self._client.connect()

async def disconnect(self):
await self._client.close()

async def introspect(
self,
) -> tuple[
RawBlocksType, RawFieldsType, RawInitialValuesType, RawInitialValuesType
]:
blocks, fields, labels, initial_values = {}, [], {}, {}

blocks = {
PandaName.from_string(name): block_info
for name, block_info in (await self._client.send(GetBlockInfo())).items()
}
fields = [
{
PandaName(field=name): field_info
for name, field_info in block_values.items()
}
for block_values in await asyncio.gather(
*[self._client.send(GetFieldInfo(str(block))) for block in blocks]
)
]

field_data = (await self._client.send(GetChanges(ChangeGroup.ALL, True))).values

for field_name, value in field_data.items():
if field_name.startswith("*METADATA"):
field_name_without_prefix = field_name.removeprefix("*METADATA.")
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_`: "
f"{field_name} = {value}."
)
labels[
PandaName.from_string(
field_name_without_prefix.removeprefix("LABEL_")
)
] = value
else: # Field is a default value
initial_values[PandaName.from_string(field_name)] = value

return blocks, fields, labels, initial_values

async def send(self, name: str, value: str):
await self._client.send(Put(name, value))

async def get_changes(self) -> dict[str, str]:
return (await self._client.send(GetChanges(ChangeGroup.ALL, False))).values
Loading
Loading