diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..b728efb --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "daily" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..17b05ac --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,19 @@ +changelog: + exclude: + labels: + - Semver-Ignore + categories: + - title: Breaking Changes + labels: + - Semver-Major + - breaking-change + - title: New Features + labels: + - Semver-Minor + - title: Bug Fixes + labels: + - Semver-Patch + - title: Other Changes + labels: + - Semver-Docs + - "*" diff --git a/.github/workflows/Lint-and-test.yml b/.github/workflows/Lint-and-test.yml new file mode 100644 index 0000000..0a9c4da --- /dev/null +++ b/.github/workflows/Lint-and-test.yml @@ -0,0 +1,38 @@ +name: Lint-and-test +on: [push, workflow_call] +jobs: + tests: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ "ubuntu-latest", "windows-latest" ] + version: ['3.12'] + fail-fast: false + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v7 + with: + python-version: ${{ matrix.version }} + - name: install requirements + run: uv sync --extra lint --extra test + - name: run ruff check + run: uv run ruff check + - name: run ruff format --check + run: uv run ruff format --check + - name: run pyright + run: uv run pyright + - name: run pytest + run: uv run pytest + results: + if: ${{ always() }} + runs-on: ubuntu-latest + name: Final Results + needs: [tests] + steps: + - run: exit 1 + # see https://stackoverflow.com/a/67532120/4907315 + if: >- + ${{ + contains(needs.*.result, 'failure') + || contains(needs.*.result, 'cancelled') + }} diff --git a/.github/workflows/check_pr_has_label.yml b/.github/workflows/check_pr_has_label.yml new file mode 100644 index 0000000..2a932a2 --- /dev/null +++ b/.github/workflows/check_pr_has_label.yml @@ -0,0 +1,24 @@ +name: Check PR has release labels +on: + pull_request: + types: + - opened + - reopened + - synchronize + - labeled + - unlabeled + +jobs: + has_label: + name: Check PR has release labels + runs-on: ubuntu-latest + steps: + - run: | + echo "PR does not have a release label." + exit 1 + if: | + !contains(github.event.pull_request.labels.*.name, 'Semver-Patch') && + !contains(github.event.pull_request.labels.*.name, 'Semver-Major') && + !contains(github.event.pull_request.labels.*.name, 'Semver-Minor') && + !contains(github.event.pull_request.labels.*.name, 'Semver-Docs') && + !contains(github.event.pull_request.labels.*.name, 'Semver-Ignore') diff --git a/.github/workflows/dependabot-prs.yml b/.github/workflows/dependabot-prs.yml new file mode 100644 index 0000000..219103b --- /dev/null +++ b/.github/workflows/dependabot-prs.yml @@ -0,0 +1,19 @@ +name: Add dependabot PRs to flash reviews +on: + pull_request: + types: + - opened + - reopened + - labeled + +jobs: + add_flash_review: + name: Add dependabot PRs to flash reviews + runs-on: ubuntu-latest + steps: + - uses: actions/add-to-project@v1.0.2 + with: + project-url: https://github.com/orgs/ISISComputingGroup/projects/17 + github-token: ${{ secrets.PROJECT_TOKEN }} + labeled: dependencies + label-operator: OR diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml new file mode 100644 index 0000000..e3cf686 --- /dev/null +++ b/.github/workflows/documentation.yml @@ -0,0 +1,36 @@ +name: sphinx + +on: [push, pull_request, workflow_call] + +jobs: + docs: + runs-on: windows-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: astral-sh/setup-uv@v7 + with: + python-version: "3.12" + - name: install requirements + run: uv sync --extra doc + - name: Sphinx build + run: uv run sphinx-build -E -a -W --keep-going doc _build + - name: run spellcheck + run: uv run sphinx-build -E -a -W --keep-going -b spelling doc _build + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: documentation + path: | + _build + if-no-files-found: error + retention-days: 7 + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v3 + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + with: + publish_branch: gh-pages + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: _build/ + force_orphan: true diff --git a/.github/workflows/lint-and-test-nightly.yml b/.github/workflows/lint-and-test-nightly.yml new file mode 100644 index 0000000..5119ad4 --- /dev/null +++ b/.github/workflows/lint-and-test-nightly.yml @@ -0,0 +1,9 @@ +name: lint-and-test-nightly +on: + schedule: + - cron: "0 0 * * *" + workflow_dispatch: + +jobs: + lint-and-test-nightly: + uses: ./.github/workflows/Lint-and-test.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..241085f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,88 @@ +name: Publish Python distribution to PyPI +on: push +jobs: + lint-and-test: + if: github.ref_type == 'tag' + name: Run linter and tests + uses: ./.github/workflows/Lint-and-test.yml + build: + needs: lint-and-test + if: github.ref_type == 'tag' + name: build distribution + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + - name: Install pypa/build + run: >- + python3 -m + pip install + build + --user + - name: Build a binary wheel and a source tarball + run: python3 -m build + - name: Store the distribution packages + uses: actions/upload-artifact@v6 + with: + name: python-package-distributions + path: dist/ + publish-to-pypi: + name: >- + Publish Python distribution to PyPI + if: github.ref_type == 'tag' + needs: [lint-and-test, build] + runs-on: ubuntu-latest + environment: + name: release + url: https://pypi.org/p/fastcs-secop + permissions: + id-token: write # IMPORTANT: mandatory for trusted publishing + steps: + - name: Download all the dists + uses: actions/download-artifact@v7 + with: + name: python-package-distributions + path: dist/ + - name: Publish distribution to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + github-release: + name: >- + Sign the Python distribution with Sigstore + and upload them to GitHub Release + needs: [lint-and-test, build, publish-to-pypi] + runs-on: ubuntu-latest + permissions: + contents: write # IMPORTANT: mandatory for making GitHub Releases + id-token: write # IMPORTANT: mandatory for sigstore + steps: + - name: Download all the dists + uses: actions/download-artifact@v7 + with: + name: python-package-distributions + path: dist/ + - name: Sign the dists with Sigstore + uses: sigstore/gh-action-sigstore-python@v3.2.0 + with: + inputs: >- + ./dist/*.tar.gz + ./dist/*.whl + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ github.token }} + - name: Upload artifact signatures to GitHub Release + env: + GITHUB_TOKEN: ${{ github.token }} + # Upload to GitHub Release using the `gh` CLI. + # `dist/` contains the built packages, and the + # sigstore-produced signatures and certificates. + run: >- + gh release upload + '${{ github.ref_name }}' dist/** + --repo '${{ github.repository }}' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3b14865 --- /dev/null +++ b/.gitignore @@ -0,0 +1,168 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +.idea/ +.vscode/ + +coverage_html_report/ + +# Sphinx generated +doc/generated/* +_build/ + +src/fastcs_secop/version.py + +output.bob diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b44c480 --- /dev/null +++ b/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2026, ISIS Experiment Controls Computing + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ac01021 --- /dev/null +++ b/README.md @@ -0,0 +1,4 @@ +# `fastcs-secop` + +Support for [SECoP](https://sampleenvironment.github.io/secop-site/intro/index.html) devices using +[FastCS](https://diamondlightsource.github.io/FastCS/main/index.html). diff --git a/doc/_api.rst b/doc/_api.rst new file mode 100644 index 0000000..0378f50 --- /dev/null +++ b/doc/_api.rst @@ -0,0 +1,11 @@ +:orphan: + +API +=== + +.. autosummary:: + :toctree: generated + :template: custom-module-template.rst + :recursive: + + fastcs_secop diff --git a/doc/_static/css/custom.css b/doc/_static/css/custom.css new file mode 100644 index 0000000..e491bfe --- /dev/null +++ b/doc/_static/css/custom.css @@ -0,0 +1,16 @@ +a:hover { + color: #d2b48c; +} + +.wy-menu-vertical p.caption { + color: #d2b48c; +} + +.sphinx-codeautolink-a{ + border-bottom-color: #d2b48c; + border-bottom-style: solid; + border-bottom-width: 1px; +} +.sphinx-codeautolink-a:hover{ + color: #888888; +} diff --git a/doc/_templates/custom-module-template.rst b/doc/_templates/custom-module-template.rst new file mode 100644 index 0000000..f483c41 --- /dev/null +++ b/doc/_templates/custom-module-template.rst @@ -0,0 +1,40 @@ + + +{{ ('``' + fullname + '``') | underline }} + +{%- set filtered_members = [] %} +{%- for item in members %} + {%- if item in functions + classes + exceptions + attributes %} + {% set _ = filtered_members.append(item) %} + {%- endif %} +{%- endfor %} + +.. automodule:: {{ fullname }} + :members: + :show-inheritance: + + {% block modules %} + {% if modules %} + .. rubric:: Submodules + + .. autosummary:: + :toctree: + :template: custom-module-template.rst + :recursive: + {% for item in modules %} + {{ item }} + {%- endfor %} + {% endif %} + {% endblock %} + + {% block members %} + {% if filtered_members %} + .. rubric:: Members + + .. autosummary:: + :nosignatures: + {% for item in filtered_members %} + {{ item }} + {%- endfor %} + {% endif %} + {% endblock %} \ No newline at end of file diff --git a/doc/conf.py b/doc/conf.py new file mode 100644 index 0000000..dcbde61 --- /dev/null +++ b/doc/conf.py @@ -0,0 +1,97 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +import os +import sys + +from fastcs_secop.version import version + +sys.path.insert(0, os.path.abspath("../src")) + +project = "fastcs-secop" +copyright = "" +author = "ISIS Experiment Controls" +release = version + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +nitpicky = True +nitpick_ignore_regex = [ + ("py:class", r"^.*\.T$"), + ("py:obj", r"^.*\.T$"), + ("py:class", r"^.*\.T.*_co$"), + ("py:obj", r"^.*\.T.*_co$"), +] + +myst_enable_extensions = ["dollarmath", "strikethrough", "colon_fence", "attrs_block"] +suppress_warnings = ["myst.strikethrough"] + +extensions = [ + "myst_parser", + "sphinx.ext.autodoc", + # and making summary tables at the top of API docs + "sphinx.ext.autosummary", + # This can parse google style docstrings + "sphinx.ext.napoleon", + # For linking to external sphinx documentation + "sphinx.ext.intersphinx", + # Add links to source code in API docs + "sphinx.ext.viewcode", + # Mermaid diagrams + "sphinxcontrib.mermaid", + # Documentation links in code blocks + "sphinx_codeautolink", +] +mermaid_d3_zoom = True +napoleon_google_docstring = True +napoleon_numpy_docstring = False + +templates_path = ["_templates"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_context = { + "display_github": True, # Integrate GitHub + "github_user": "ISISComputingGroup", # Username + "github_repo": "fastcs-secop", # Repo name + "github_version": "main", # Version + "conf_py_path": "/doc/", # Path in the checkout to the docs root +} + +html_theme = "sphinx_rtd_theme" +html_theme_options = { + "logo_only": False, + "style_nav_header_background": "#343131", +} +html_static_path = ["_static"] +html_css_files = [ + "css/custom.css", +] +html_logo = "logo.svg" + +autoclass_content = "init" +myst_heading_anchors = 7 +autodoc_preserve_defaults = True + +spelling_lang = "en_GB" +spelling_filters = ["enchant.tokenize.MentionFilter"] +spelling_warning = True +spelling_show_suggestions = True +spelling_suggestion_limit = 3 + +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), + "secop": ("https://sampleenvironment.github.io/secop-site/", None), + "fastcs": ("https://diamondlightsource.github.io/FastCS/main/", None), + "numpy": ("https://numpy.org/doc/stable/", None), + "epics": ("https://docs.epics-controls.org/en/latest/", None), + "epics_base": ("https://docs.epics-controls.org/projects/base/en/latest/", None), +} diff --git a/doc/contributing.md b/doc/contributing.md new file mode 100644 index 0000000..9815608 --- /dev/null +++ b/doc/contributing.md @@ -0,0 +1,42 @@ +# Contributing + +The repository for this project is +[https://github.com/ISISComputingGroup/fastcs-secop](https://github.com/ISISComputingGroup/fastcs-secop). + +Contributions via GitHub issues and pull requests are welcome. If an issue or PR appears to have been ignored, it may have +simply been missed - email [ISISExperimentControls@stfc.ac.uk](mailto:ISISExperimentControls@stfc.ac.uk) if an issue +or PR appears to have been ignored accidentally. + +Some changes may require preparatory changes in [FastCS itself](https://github.com/DiamondLightSource/fastcs). + +## Developer installation + +To install a developer version of the library, run `pip install -e .[dev]` in a python virtual environment. +You can also use `uv pip install -e .[dev]` if you have the [`uv`](https://docs.astral.sh/uv/) tool installed. + +## Linting + +Linting is performed by [ruff](https://docs.astral.sh/ruff/) (formatting & linting) and +[pyright](https://github.com/microsoft/pyright) (type-checking). + +```shell +ruff format +ruff check --fix +pyright +``` + +## Documentation + +Documentation is built using [sphinx](https://www.sphinx-doc.org/en/master/). +To get a local development build of the docs, use `sphinx-autobuild doc _build --watch src`. + +Spell checking is run automatically in CI - if a word is correctly spelt but the spellchecker flags it, +add the word to `doc/spelling_wordlist.txt`. + +The spellchecker can be run manually using `sphinx-build -E -a -W --keep-going -b spelling doc _build` - this is best +run on a Windows machine due to differences in the system spelling dictionary between operating systems. + +## Tests + +Tests run via `pytest`. Some tests spawn a very basic lewis emulator on port 57677 to test a full communication +scenario. This is handled automatically by pytest, but may fail if port 57677 is already in use. diff --git a/doc/index.md b/doc/index.md new file mode 100644 index 0000000..e87fc06 --- /dev/null +++ b/doc/index.md @@ -0,0 +1,29 @@ +# `fastcs-secop` documentation + +The {py:obj}`fastcs_secop` library implements support for the {external+secop:doc}`SECoP protocol ` +using {external+fastcs:doc}`FastCS `. + +```{mermaid} +erDiagram + "EPICS Clients" }o--o| "fastcs + fastcs-secop" : "EPICS CA" + "EPICS Clients" }o--o| "fastcs + fastcs-secop" : "EPICS PVA" + "Tango Clients" }o--o| "fastcs + fastcs-secop" : "Tango" + "fastcs + fastcs-secop" ||--|| "SEC Node" : SECoP +``` + +```{toctree} +:titlesonly: +:caption: Transports +:glob: + +transports/* +``` + +```{toctree} +:titlesonly: +:caption: Reference + +contributing +limitations +_api +``` diff --git a/doc/limitations.md b/doc/limitations.md new file mode 100644 index 0000000..802b477 --- /dev/null +++ b/doc/limitations.md @@ -0,0 +1,61 @@ +# Limitations + +There are some elements of the {external+secop:doc}`SECoP specification ` that +{py:obj}`fastcs_secop` does not currently support. These are detailed below. + +{#limitations_dtype} +## Data-type limitations + +{#limitations_float_format} +### Float formatting + +For double and scaled type parameters, the format string (`fmtstr`) is interpreted only if it is in the `f` format. `g` +and `e` formats are ignored. + +Rationale: FastCS provides a `precision` argument to transports, which represents a number of decimal places. +Other formats are not currently representable in FastCS. + +{#limitations_enum} +### Enums within arrays/structs/tuples + +An enum-type element *within* an array/struct/tuple is treated as its corresponding integer value and loses name-based +functionality. + +Rationale: FastCS does not provide a way to describe an enum nested within a {py:obj}`~fastcs.datatypes.table.Table` +or {py:obj}`~fastcs.datatypes.waveform.Waveform`. Most transports also cannot describe this. + +{#limitations_nested_complex} +### Nested arrays/structs/tuples + +Arrays/structs/tuples nested inside another array/struct/tuple are not supported. Arrays, structs and tuples can only +be made from 'simple' data types (double, int, bool, enum, string). + +Nested arrays create the possibility of ragged arrays, which cannot be expressed using standard {py:obj}`numpy` +datatypes and are not representable using FastCS's current data types. + +In principle, the following types could be supported in future (but are not supported currently): +- Arrays of structs or tuples, using the {py:obj}`~fastcs.datatypes.table.Table` type (for transports that support +the {py:obj}`~fastcs.datatypes.table.Table` FastCS type). +- Nested combinations of structs and tuples, by flattening (for transports that support +the {py:obj}`~fastcs.datatypes.table.Table` FastCS type). + +Workaround: Use {py:obj}`fastcs_secop.SecopQuirks.skip_accessibles` to skip the accessible, or use +{py:obj}`fastcs_secop.SecopQuirks.raw_accessibles` to read/write to the accessible in +'raw' mode, which treats the SECoP JSON value as a string. + +You can also use {py:obj}`fastcs_secop.SecopQuirks.raw_tuple` / {py:obj}`~fastcs_secop.SecopQuirks.raw_struct` +/ {py:obj}`~fastcs_secop.SecopQuirks.raw_array` to unconditionally read any tuple/struct/array channel in raw mode. + +{#limitations_async} +## Asynchronous updates + +Asynchronous updates are not supported by {py:obj}`fastcs_secop`. They are turned off using a +{external+secop:doc}`deactivate message ` at connection time. + +Rationale: FastCS does not currently provide infrastructure to handle asynchronous messages. + +{#limitations_qualifiers} +## Timestamp and error qualifiers + +These are ignored; FastCS currently exposes no mechanism to set these. If such a mechanism is later added to FastCS, +they may become supportable here. diff --git a/doc/logo.svg b/doc/logo.svg new file mode 100644 index 0000000..f79f3d5 --- /dev/null +++ b/doc/logo.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/doc/spelling_wordlist.txt b/doc/spelling_wordlist.txt new file mode 100644 index 0000000..1a83487 --- /dev/null +++ b/doc/spelling_wordlist.txt @@ -0,0 +1,17 @@ +accessible +accessibles +bool +datainfo +datatype +datatypes +deserialisation +deserialised +deserialising +enum +enums +pytest +SECoP +struct +structs +subcontroller +subcontrollers diff --git a/doc/transports/epics_ca.md b/doc/transports/epics_ca.md new file mode 100644 index 0000000..bdef96f --- /dev/null +++ b/doc/transports/epics_ca.md @@ -0,0 +1,33 @@ +# EPICS Channel Access + +EPICS CA transport requires `fastcs[epicsca]` to be installed. + +EPICS CA has a maximum length of 40 on parameter descriptions. Set {py:obj}`SecopQuirks.max_description_length ` to 40 to truncate descriptions. + +## Supported SECoP data types + +EPICS CA transport supports the following {external+secop:doc}`SECoP data types `, using the corresponding {external+epics_base:doc}`EPICS record type `: +- double (`ai`/`ao`) +- scaled (`ai`/`ao`) +- int (`longin`/`longout`) +- bool (`bi`/`bo`) +- enum (`mbbi`/`mbbo`) +- string (`lsi`/`lso`) +- blob (`waveform[char]`) +- array of double/int/bool/{ref}`enum* ` (`waveform`) +- matrix, if the 'matrix' is 1-dimensional (`waveform`) +- command (if arguments and return values are empty or one of the above types) + +Other data types can only be read or written in 'raw' mode. In particular, structs and tuples will need to be read in +raw mode; set {py:obj}`SecopQuirks.raw_struct ` and +{py:obj}`SecopQuirks.raw_tuple `. +{py:obj}`SecopQuirks.raw_matrix ` is also recommended, as only 1-D matrices are +supported by CA. + +{#example_ca_ioc} +## Example CA IOC + +:::python +```{literalinclude} ../../examples/epics_ca.py +``` +::: diff --git a/doc/transports/epics_pva.md b/doc/transports/epics_pva.md new file mode 100644 index 0000000..1eac932 --- /dev/null +++ b/doc/transports/epics_pva.md @@ -0,0 +1,36 @@ +# EPICS PV Access + +EPICS PVA transport requires `fastcs[epicspva]` to be installed. + +## Supported SECoP data types + +EPICS PVA transport supports the following {external+secop:doc}`SECoP data types ` (using the corresponding {external+epics:doc}`PVA normative types `): +- double (`NTScalar[double]`) +- scaled (`NTScalar[double]`) +- int (`NTScalar[int]`) +- bool (`NTScalar[boolean]`) +- enum (`NTEnum`) +- string (`NTScalar[string]`) +- blob (`NTNDArray[ubyte]`) +- array of double/int/bool/{ref}`enum* ` (`NTNDArray`) +- tuple of double/int/bool/{ref}`enum* `/string elements (`NTTable` with one row) +- struct of double/int/bool/{ref}`enum* `/string elements (`NTTable` with one row) +- matrix (`NTNDArray`) +- command (if arguments and return values are empty or one of the above types) + +Other data types can only be read or written in 'raw' mode. + +## PVI + +{py:obj}`fastcs` exports PVI PVs with the PVA transport. + +SECoP modules can be found under the top-level PVI structure, while SECoP accessibles can be found under +`module_name:PVI`. This means that the IOC is self-describing to downstream clients. + +{#example_pva_ioc} +## Example PVA IOC + +:::python +```{literalinclude} ../../examples/epics_pva.py +``` +::: diff --git a/doc/transports/tango.md b/doc/transports/tango.md new file mode 100644 index 0000000..cb91c1b --- /dev/null +++ b/doc/transports/tango.md @@ -0,0 +1,26 @@ +# Tango + +:::{important} +While supported by FastCS, and therefore by {py:obj}`fastcs_secop`, Tango support has not been extensively tested. This +page documents some _known_ limitations. + +Modifications and improvements for Tango support are welcome. See {doc}`/contributing`. +::: + +Tango transport requires `fastcs[tango]` to be installed. + +## Supported SECoP data types + +Tango transport supports the following {external+secop:doc}`SECoP data types `: +- double +- scaled +- int +- bool +- enum +- string +- blob +- array of double/int/bool/{ref}`enum* `/string +- matrix (if the matrix has dimensionality <= 2) +- command (if arguments and return values are empty or one of the above types) + +Other data types can only be read or written in 'raw' mode. diff --git a/examples/epics_ca.py b/examples/epics_ca.py new file mode 100644 index 0000000..e0b9078 --- /dev/null +++ b/examples/epics_ca.py @@ -0,0 +1,51 @@ +"""Example CA IOC using :py:obj:`fastcs_secop`.""" + +import argparse +import asyncio +import logging +import socket + +from fastcs.connections import IPConnectionSettings +from fastcs.launch import FastCS +from fastcs.logging import LogLevel, configure_logging + +from fastcs_secop import SecopController, SecopQuirks + +if __name__ == "__main__": + from fastcs.transports import EpicsIOCOptions + from fastcs.transports.epics.ca import EpicsCATransport + + parser = argparse.ArgumentParser(description="Demo PVA ioc") + parser.add_argument("-i", "--ip", type=str, default="127.0.0.1", help="IP to connect to") + parser.add_argument("-p", "--port", type=int, help="Port to connect to") + args = parser.parse_args() + + configure_logging(level=LogLevel.DEBUG) + logging.basicConfig(level=LogLevel.DEBUG) + + asyncio.get_event_loop().slow_callback_duration = 1000 + + epics_options = EpicsIOCOptions(pv_prefix=f"TE:{socket.gethostname().upper()}:SECOP") + epics_ca = EpicsCATransport(epicsca=epics_options) + + quirks = SecopQuirks( + raw_tuple=True, + raw_struct=True, + raw_matrix=True, + max_description_length=40, + raw_accessibles=[ + ("valve_controller", "_domains_to_extract"), + ("valve_controller", "_terminal_values"), + ], + ) + + controller = SecopController( + settings=IPConnectionSettings(ip=args.ip, port=args.port), + quirks=quirks, + ) + + fastcs = FastCS( + controller, + [epics_ca], + ) + fastcs.run(interactive=True) diff --git a/examples/epics_pva.py b/examples/epics_pva.py new file mode 100644 index 0000000..7342b46 --- /dev/null +++ b/examples/epics_pva.py @@ -0,0 +1,47 @@ +"""Example PVA IOC using :py:obj:`fastcs_secop`.""" + +import argparse +import asyncio +import logging +import socket + +from fastcs.connections import IPConnectionSettings +from fastcs.launch import FastCS +from fastcs.logging import LogLevel, configure_logging + +from fastcs_secop import SecopController, SecopQuirks + +if __name__ == "__main__": + from fastcs.transports import EpicsIOCOptions + from fastcs.transports.epics.pva import EpicsPVATransport + + parser = argparse.ArgumentParser(description="Demo PVA ioc") + parser.add_argument("-i", "--ip", type=str, default="127.0.0.1", help="IP to connect to") + parser.add_argument("-p", "--port", type=int, help="Port to connect to") + args = parser.parse_args() + + configure_logging(level=LogLevel.DEBUG) + logging.basicConfig(level=LogLevel.DEBUG) + + asyncio.get_event_loop().slow_callback_duration = 1000 + + epics_options = EpicsIOCOptions(pv_prefix=f"TE:{socket.gethostname().upper()}:SECOP") + epics_pva = EpicsPVATransport(epicspva=epics_options) + + quirks = SecopQuirks( + raw_accessibles=[ + ("valve_controller", "_domains_to_extract"), + ("valve_controller", "_terminal_values"), + ], + ) + + controller = SecopController( + settings=IPConnectionSettings(ip=args.ip, port=args.port), + quirks=quirks, + ) + + fastcs = FastCS( + controller, + [epics_pva], + ) + fastcs.run(interactive=True) diff --git a/pyproject.toml b/pyproject.toml index 7d93a2e..601c37f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "fastcs-secop" # REQUIRED, is the only field that cannot be marked as dy dynamic = ["version"] description = "SECoP device support using FastCS" readme = "README.md" -requires-python = ">=3.11" +requires-python = ">=3.12" license-files = ["LICENSE"] authors = [ @@ -18,22 +18,18 @@ maintainers = [ ] classifiers = [ - # How mature is this project? Common values are - # 3 - Alpha - # 4 - Beta - # 5 - Production/Stable "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3 :: Only", ] dependencies = [ - "fastcs", + # Pinned until https://github.com/DiamondLightSource/FastCS/pull/295 is merged and released + "fastcs @ git+https://github.com/Tom-Willemsen/FastCS@guard_methods_that_dont_work_on_windows", + "orjson", ] [project.optional-dependencies] @@ -42,16 +38,24 @@ doc = [ "sphinx_rtd_theme", "myst_parser", "sphinx-autobuild", + "sphinx-codeautolink", "sphinxcontrib-mermaid", + "sphinxcontrib-spelling", ] -dev = [ - "fastcs-secop[doc]", - "ruff>=0.8", +lint = [ "pyright", + "ruff", +] +test = [ + "lewis", "pytest", "pytest-asyncio", "pytest-cov", ] +dev = [ + "fastcs-secop[doc,lint,test]", + "fastcs[all]", +] [project.urls] "Homepage" = "https://github.com/isiscomputinggroup/fastcs-secop" @@ -62,10 +66,16 @@ dev = [ testpaths = "tests" asyncio_mode = "auto" addopts = "--cov --cov-report=html -vv" +filterwarnings = [ + 'ignore::tango.PyTangoUserWarning', +] [tool.coverage.run] branch = true source = ["src"] +omit = [ + "version.py", # Autogenerated +] [tool.coverage.report] fail_under = 100 @@ -80,7 +90,7 @@ exclude_lines = [ directory = "coverage_html_report" [tool.pyright] -include = ["src", "tests"] +include = ["src", "examples"] reportConstantRedefinition = true reportDeprecated = true reportInconsistentConstructor = true @@ -98,4 +108,3 @@ reportUntypedFunctionDecorator = true version_file = "src/fastcs_secop/version.py" [tool.build_sphinx] - diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..8221db2 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,56 @@ +line-length = 100 +indent-width = 4 + +[lint] +preview = true +extend-select = [ + "N", # pep8-naming + "D", # pydocstyle + "I", # isort (for imports) + "E501", # Line too long ({width} > {limit}) + "E", # Pycodestyle errors + "W", # Pycodestyle warnings + "F", # Pyflakes + "PL", # Pylint + "B", # Flake8-bugbear + "PIE", # Flake8-pie + "ANN", # Annotations + "ASYNC", # Asyncio-specific checks + "NPY", # Numpy-specific rules + "RUF", # Ruff-specific checks, include some useful asyncio rules + "FURB", # Rules from refurb + "ERA", # Commented-out code + "PT", # Pytest-specific rules + "LOG", # Logging-specific rules + "G", # Logging-specific rules + "UP", # Pyupgrade + "SLF", # Private-member usage + "PERF", # Performance-related rules +] +ignore = [ + "D406", # Section name should end with a newline ("{name}") + "D407", # Missing dashed underline after section ("{name}") + "D213", # Incompatible with D212 + "D203", # Incompatible with D211 + "PLR6301", # Too noisy +] +[lint.per-file-ignores] +"tests/*" = [ + "N802", # Allow test names to be long / not pep8 + "D", # Don't require method documentation for test methods + "ANN", # Don't require tests to use type annotations + "PLR2004", # Allow magic numbers in tests + "PLR0915", # Allow complex tests + "PLR0914", # Allow complex tests + "PLC2701", # Allow tests to import "private" things + "SLF001", # Allow tests to use "private" things + "RUF067", # Standard pattern for LEWiS emulators +] +"doc/conf.py" = [ + "D100" +] + +[lint.pylint] +max-args = 8 +max-returns = 15 +max-branches = 15 diff --git a/src/fastcs_secop/__init__.py b/src/fastcs_secop/__init__.py index e69de29..ab1725a 100644 --- a/src/fastcs_secop/__init__.py +++ b/src/fastcs_secop/__init__.py @@ -0,0 +1,12 @@ +"""SECoP support using FastCS.""" + +from fastcs_secop._controllers import SecopCommandController, SecopController, SecopModuleController +from fastcs_secop._util import SecopError, SecopQuirks + +__all__ = [ + "SecopCommandController", + "SecopController", + "SecopError", + "SecopModuleController", + "SecopQuirks", +] diff --git a/src/fastcs_secop/_controllers.py b/src/fastcs_secop/_controllers.py new file mode 100644 index 0000000..4215376 --- /dev/null +++ b/src/fastcs_secop/_controllers.py @@ -0,0 +1,388 @@ +"""FastCS controllers for SECoP nodes.""" + +import typing +import uuid +from logging import getLogger + +import orjson +from fastcs.attributes import AttrR, AttrRW +from fastcs.connections import IPConnection, IPConnectionSettings +from fastcs.controllers import Controller +from fastcs.methods import command, scan + +from fastcs_secop._io import ( + SecopAttributeIO, + SecopAttributeIORef, + SecopRawAttributeIO, + SecopRawAttributeIORef, + decode, + encode, +) +from fastcs_secop._util import SecopError, SecopQuirks, is_raw, secop_datainfo_to_fastcs_dtype + +logger = getLogger(__name__) + + +class SecopCommandController(Controller): + """SECoP command controller.""" + + def __init__( + self, + *, + connection: IPConnection, + module_name: str, + command_name: str, + datainfo: dict[str, typing.Any], + quirks: SecopQuirks, + ) -> None: + """Subcontroller for a SECoP command. + + This class is automatically added as a subcontroller by + :py:obj:`SecopModuleController` for command-type parameters. + + Args: + connection: The connection to use. + module_name: The module in which this command is defined. + command_name: The name of the command. + datainfo: The datainfo dictionary for this command. + quirks: The quirks configuration (see :py:obj:`~fastcs_secop.SecopQuirks`). + + """ + super().__init__() + + self._connection = connection + self._module_name = module_name + self._command_name = command_name + self._datainfo = datainfo + self._quirks = quirks + + self.raw_args = self._datainfo.get("argument") is not None and is_raw( + self._module_name, self._command_name, self._datainfo["argument"], self._quirks + ) + self.raw_result = self._datainfo.get("result") is not None and is_raw( + self._module_name, self._command_name, self._datainfo["result"], self._quirks + ) + + async def initialise(self) -> None: + """Initialise the command controller. + + This will set up PVs for ``Args`` and ``Result`` (if they have a type). + """ + if self._datainfo.get("argument") is not None: + args_type = secop_datainfo_to_fastcs_dtype( + self._datainfo["argument"], raw=self.raw_args + ) + else: + args_type = None + + if self._datainfo.get("result") is not None: + result_type = secop_datainfo_to_fastcs_dtype( + self._datainfo["result"], raw=self.raw_result + ) + else: + result_type = None + + if args_type is not None: + self.args = AttrRW(description="args", datatype=args_type) + else: + self.args = None + + if result_type is not None: + self.result = AttrR(description="result", datatype=result_type) + else: + self.result = None + + @command() + async def execute(self) -> None: + """Execute the command.""" + try: + prefix = f"do {self._module_name}:{self._command_name}" + response_prefix = f"done {self._module_name}:{self._command_name}" + + if self.args is not None: + if self.raw_args: + cmd = f"{prefix} {self.args.get()}\n" + else: + cmd = f"{prefix} {encode(self.args.get(), self._datainfo['argument'])}\n" + else: + cmd = f"{prefix}\n" + + logger.debug("Sending command: '%s'", cmd) + response = await self._connection.send_query(cmd) + logger.debug("Response: '%s'", response) + + response = response.strip() + if not response.startswith(response_prefix): + logger.error("command '%s' failed (response='%s')", prefix, response) + return + + response = response[len(response_prefix) :].strip() + + if self.result is not None: + if self.raw_result: + await self.result.update(orjson.dumps(orjson.loads(response)[0]).decode()) + else: + await self.result.update( + decode(response, self._datainfo["result"], self.result) + ) + except Exception as e: + logger.error( + "command %s:%s failed: %s: %s", + self._module_name, + self._command_name, + e.__class__.__name__, + e, + ) + + +class SecopModuleController(Controller): + """FastCS controller for a SECoP module.""" + + def __init__( + self, + *, + connection: IPConnection, + module_name: str, + module: dict[str, typing.Any], + quirks: SecopQuirks, + ) -> None: + """FastCS controller for a SECoP module. + + This class is automatically added as a subcontroller by + :py:obj:`SecopController` for each present SECoP module. + + Args: + connection: The connection to use. + module_name: The name of the SECoP module. + module: A deserialised description, in the + :external+secop:doc:`SECoP over-the-wire format `, + of this module. + quirks: Affects how attributes are processed. + See :py:obj:`~fastcs_secop.SecopQuirks` for details. + + """ + self._module_name = module_name + self._module = module + self._quirks = quirks + self._connection = connection + + super().__init__( + ios=[ + SecopAttributeIO(connection=connection), + SecopRawAttributeIO(connection=connection), + ] + ) + + async def initialise(self) -> None: + """Create attributes for all accessibles in this SECoP module.""" + for parameter_name, parameter in self._module["accessibles"].items(): + if (self._module_name, parameter_name) in self._quirks.skip_accessibles: + continue + + logger.debug("Creating attribute for parameter %s", parameter_name) + datainfo = parameter["datainfo"] + + description = parameter.get("description", "")[: self._quirks.max_description_length] + + attr_cls = AttrR if parameter.get("readonly", False) else AttrRW + + raw = is_raw(self._module_name, parameter_name, datainfo, self._quirks) + + if raw: + io_ref = SecopRawAttributeIORef( + module_name=self._module_name, + accessible_name=parameter_name, + update_period=self._quirks.update_period, + ) + else: + io_ref = SecopAttributeIORef( + module_name=self._module_name, + accessible_name=parameter_name, + update_period=self._quirks.update_period, + datainfo=datainfo, + ) + + if datainfo["type"] == "command": + command_controller = SecopCommandController( + module_name=self._module_name, + command_name=parameter_name, + connection=self._connection, + datainfo=datainfo, + quirks=self._quirks, + ) + self.add_sub_controller(parameter_name, command_controller) + await command_controller.initialise() + else: + fastcs_type = secop_datainfo_to_fastcs_dtype(datainfo=datainfo, raw=raw) + + self.add_attribute( + parameter_name, + attr_cls( + fastcs_type, + io_ref=io_ref, + description=description, + ), + ) + + +class SecopController(Controller): + """FastCS Controller for a SECoP node.""" + + def __init__(self, settings: IPConnectionSettings, quirks: SecopQuirks | None = None) -> None: + """FastCS Controller for a SECoP node. + + The intended usage is via :py:obj:`fastcs.control_system.FastCS`: + + .. code-block:: python + + from fastcs_secop import SecopController, SecopQuirks + from fastcs.control_system import FastCS + + controller = SecopController( + settings=IPConnectionSettings(ip="127.0.0.1", port=1234), + quirks=SecopQuirks(...), + ) + + transports = [...] + + fastcs = FastCS( + controller, + transports, + ) + fastcs.run() + + See Also: + :ref:`example_ca_ioc` and :ref:`example_pva_ioc` for examples of full configurations + + Args: + settings: The communication settings (e.g. IP address, port) at which + the SECoP node is reachable. + quirks: :py:obj:`~fastcs_secop.SecopQuirks` that affects how attributes are processed. + + """ + self._ip_settings = settings + self._connection = IPConnection() + self._quirks = quirks or SecopQuirks() + + super().__init__() + + async def connect(self) -> None: + """Connect to the SECoP node.""" + await self._connection.connect(self._ip_settings) + + async def deactivate(self) -> None: + """Turn off asynchronous SECoP communication. + + See :external+secop:doc:`specification/messages/activation` for details. + """ + await self._connection.send_query("deactivate\n") + + @scan(15.0) + async def ping(self) -> None: + """Ping the SECoP device, to check connection is still open. + + Attempts to reconnect if the connection was not open (e.g. closed + by remote end or network break). + """ + try: + token = uuid.uuid4() + await self._connection.send_query(f"ping {token}\n") + except ConnectionError: + logger.info("Detected connection loss, attempting reconnect.") + try: + await self.connect() + await self.deactivate() + logger.info("Reconnect successful.") + except Exception: + logger.info("Reconnect failed.") + + async def check_idn(self) -> None: + """Verify that the device is a SECoP device. + + This is checked using the SECoP + :external+secop:doc:`identification message `. + + Raises: + SecopError: if the device is not a SECoP device. + + """ + identification = await self._connection.send_query("*IDN?\n") + identification = identification.strip() + + try: + manufacturer, product, _, _ = identification.split(",") + except ValueError as e: + raise SecopError("Invalid response to '*IDN?'") from e + + if manufacturer not in { + "ISSE&SINE2020", # SECOP 1.x + "ISSE", # SECOP 2.x + }: + raise SecopError( + f"Device responded to '*IDN?' with bad manufacturer string '{manufacturer}'. " + f"Not a SECoP device?" + ) + + if product != "SECoP": + raise SecopError( + f"Device responded to '*IDN?' with bad product string '{product}'. " + f"Not a SECoP device?" + ) + + logger.info("Connected to SECoP device with IDN='%s'.", identification) + + async def initialise(self) -> None: + """Set up FastCS for this SECoP node. + + This introspects the + :external+secop:doc:`description ` + of the SECoP device to determine the names and contents of the modules + in this SECoP node. + + A subcontroller of type :py:obj:`SecopModuleController` is added for + each discovered module. + + This controller attempts to periodically reconnect to the device if the + connection was closed, and disables asynchronous messages on instantiation. + + Raises: + SecopError: if the device is not a SECoP device, if a reply in an + unexpected format is received, or the SECoP node's configuration + cannot be handled by :py:obj:`fastcs_secop`. + + """ + await self.connect() + await self.check_idn() + await self.deactivate() + await self._create_modules() + + async def _create_modules(self) -> None: + """Create subcontrollers for each SECoP module.""" + descriptor = await self._connection.send_query("describe\n") + if not descriptor.startswith("describing . "): + raise SecopError(f"Invalid response to 'describe': '{descriptor}'.") + + descriptor = orjson.loads(descriptor[len("describing . ") :]) + + description = descriptor["description"] + equipment_id = descriptor["equipment_id"] + + logger.info("SECoP equipment_id = '%s', description = '%s'", equipment_id, description) + logger.debug( + "descriptor = %s", orjson.dumps(descriptor, option=orjson.OPT_INDENT_2).decode() + ) + + modules = descriptor["modules"] + + for module_name, module in modules.items(): + if module_name in self._quirks.skip_modules: + continue + logger.debug("Creating subcontroller for module %s", module_name) + module_controller = SecopModuleController( + connection=self._connection, + module_name=module_name, + module=module, + quirks=self._quirks, + ) + await module_controller.initialise() + self.add_sub_controller(name=module_name, sub_controller=module_controller) diff --git a/src/fastcs_secop/_io.py b/src/fastcs_secop/_io.py new file mode 100644 index 0000000..b397de5 --- /dev/null +++ b/src/fastcs_secop/_io.py @@ -0,0 +1,272 @@ +"""Implementation of IO for SECoP accessibles.""" + +import base64 +import enum +from dataclasses import dataclass, field +from enum import Enum +from logging import getLogger +from typing import Any, TypeAlias, cast + +import numpy as np +import numpy.typing as npt +import orjson +from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrW +from fastcs.connections import IPConnection + +from fastcs_secop._util import ( + SecopError, + secop_dtype_to_numpy_dtype, + struct_structured_dtype, + tuple_structured_dtype, +) + +logger = getLogger(__name__) + + +T: TypeAlias = int | float | str | bool | Enum | npt.NDArray[Any] # noqa: UP040 (sphinx doesn't like it) +"""Generic type parameter for SECoP IO.""" + + +async def secop_read(connection: IPConnection, module_name: str, accessible_name: str) -> str: + """Read a SECoP accessible. + + Args: + connection: Connection reference, + module_name: Module name + accessible_name: Accessible name + + Returns: + The result of reading from the accessible, after JSON deserialisation. + + Raises: + SecopError: If a valid response was not received + + """ + query = f"read {module_name}:{accessible_name}\n" + response = await connection.send_query(query) + response = response.strip() + + prefix = f"reply {module_name}:{accessible_name} " + if not response.startswith(prefix): + raise SecopError(f"Invalid response to 'read' command by SECoP device: '{response}'") + + return response[len(prefix) :] + + +async def secop_change( + connection: IPConnection, module_name: str, accessible_name: str, encoded_value: str +) -> None: + """Change a SECoP accessible. + + Args: + connection: Connection reference, + module_name: Module name + accessible_name: Accessible name + encoded_value: Value to set (as a raw string ready for transport). + + Raises: + SecopError: If a valid response was not received + + """ + query = f"change {module_name}:{accessible_name} {encoded_value}\n" + + response = await connection.send_query(query) + response = response.strip() + + prefix = f"changed {module_name}:{accessible_name} " + + if not response.startswith(prefix): + raise SecopError(f"Invalid response to 'change' command by SECoP device: '{response}'") + + +@dataclass +class SecopAttributeIORef(AttributeIORef): + """AttributeIO parameters for a SECoP parameter (accessible).""" + + module_name: str = "" + accessible_name: str = "" + datainfo: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class SecopRawAttributeIORef(AttributeIORef): + """RawAttributeIO parameters for a SECoP parameter (accessible).""" + + module_name: str = "" + accessible_name: str = "" + + +def decode(raw_value: str, datainfo: dict[str, Any], attr: AttrR[T]) -> T: # noqa ANN401 + """Decode the transported value into a python datatype. + + Args: + value: The value to decode (the raw transported string) + datainfo: The SECoP ``datainfo`` dictionary for this attribute. + + Returns: + Python datatype representation of the transported value. + + """ + value, *_ = orjson.loads(raw_value) + match datainfo["type"]: + case "enum": + return attr.dtype(cast(int, value)) + case "scaled": + return value * datainfo["scale"] + case "blob": + return np.frombuffer(base64.b64decode(value), dtype=np.uint8) + case "array": + inner_np_dtype = secop_dtype_to_numpy_dtype(datainfo["members"]) + return np.array(value, dtype=inner_np_dtype) + case "tuple": + structured_np_dtype = tuple_structured_dtype(datainfo) + return np.array([tuple(value)], dtype=structured_np_dtype) + case "struct": + structured_np_dtype = struct_structured_dtype(datainfo) + arr = np.zeros(shape=(1,), dtype=structured_np_dtype) + for k, v in cast(dict[str, Any], value).items(): + arr[0][k] = v + return arr + case "matrix": + lengths = value["len"][::-1] + return np.frombuffer( + base64.b64decode(value["blob"]), dtype=datainfo["elementtype"] + ).reshape(lengths) + case _: + return value + + +def encode(value: T, datainfo: dict[str, Any]) -> str: + """Encode the transported value to a string for transport. + + Args: + value: The value to encode. + datainfo: The SECoP ``datainfo`` dictionary for this attribute. + + """ + match datainfo["type"]: + case "int" | "bool" | "double" | "string": + return orjson.dumps(value).decode() + case "enum": + assert isinstance(value, enum.Enum) + return orjson.dumps(value.value).decode() + case "scaled": + val = round(value / datainfo["scale"]) + assert isinstance(val, int) + return orjson.dumps(val).decode() + case "blob": + assert isinstance(value, np.ndarray) + return orjson.dumps(base64.b64encode(value.tobytes()).decode()).decode() + case "array": + return orjson.dumps(value, option=orjson.OPT_SERIALIZE_NUMPY).decode() + case "tuple": + assert isinstance(value, np.ndarray) + return orjson.dumps(value.tolist()[0]).decode() + case "struct": + assert isinstance(value, np.ndarray) + ans = {} + assert value.dtype.names is not None + for name in value.dtype.names: + ans[name] = value[name][0] + return orjson.dumps(ans, option=orjson.OPT_SERIALIZE_NUMPY).decode() + case "matrix": + assert isinstance(value, np.ndarray) + return orjson.dumps( + {"len": value.shape[::-1], "blob": base64.b64encode(value.tobytes()).decode()} + ).decode() + case _: + raise SecopError(f"Cannot handle SECoP dtype '{datainfo['type']}'") + + +class SecopAttributeIO(AttributeIO[T, SecopAttributeIORef]): + """IO for a SECoP parameter of any type other than 'command'.""" + + def __init__(self, *, connection: IPConnection) -> None: + """IO for a SECoP parameter of any type other than 'command'.""" + super().__init__() + + self._connection = connection + + async def update(self, attr: AttrR[T, SecopAttributeIORef]) -> None: + """Read value from device and update the value in FastCS.""" + try: + raw_value = await secop_read( + self._connection, attr.io_ref.module_name, attr.io_ref.accessible_name + ) + value = decode(raw_value, attr.io_ref.datainfo, attr) + await attr.update(value) + except ConnectionError: + # Reconnect will be attempted in a periodic scan task + pass + except Exception: + logger.exception("Exception during update()") + + async def send(self, attr: AttrW[T, SecopAttributeIORef], value: T) -> None: + """Send a value from FastCS to the device.""" + try: + encoded_value = encode(value, attr.io_ref.datainfo) + await secop_change( + self._connection, + attr.io_ref.module_name, + attr.io_ref.accessible_name, + encoded_value, + ) + # Ugly, but I can't find a public alternative... + # https://github.com/DiamondLightSource/FastCS/pull/292 + await attr._call_sync_setpoint_callbacks(value) # noqa: SLF001 + except ConnectionError: + # Reconnect will be attempted in a periodic scan task + pass + except Exception as e: + logger.error("Exception during send() for %s: %s: %s", attr, e.__class__.__name__, e) + + +class SecopRawAttributeIO(AttributeIO[str, SecopRawAttributeIORef]): + """Raw IO for a SECoP parameter of any type other than 'command'. + + For "raw" IO, all values are transmitted to/from FastCS as strings. + It is up to the client to interpret those strings correctly. + + This is intended as a fallback mode for transports which cannot represent complex + data types. + + """ + + def __init__(self, *, connection: IPConnection) -> None: + """IO for a SECoP parameter of any type other than 'command'.""" + super().__init__() + + self._connection = connection + + async def update(self, attr: AttrR[str, SecopRawAttributeIORef]) -> None: + """Read value from device and update the value in FastCS.""" + try: + raw_value = await secop_read( + self._connection, attr.io_ref.module_name, attr.io_ref.accessible_name + ) + # Get rid of timestamp and other specifiers, we just want the value + value, *_ = orjson.loads(raw_value) + await attr.update(orjson.dumps(value).decode()) + except ConnectionError: + # Reconnect will be attempted in a periodic scan task + pass + except Exception: + logger.exception("Exception during update()") + + async def send(self, attr: AttrW[str, SecopRawAttributeIORef], value: str) -> None: + """Send a value from FastCS to the device.""" + try: + await secop_change( + self._connection, + attr.io_ref.module_name, + attr.io_ref.accessible_name, + value, + ) + # Ugly, but I can't find a public alternative... + # https://github.com/DiamondLightSource/FastCS/pull/292 + await attr._call_sync_setpoint_callbacks(value) # noqa: SLF001 + except ConnectionError: + # Reconnect will be attempted in a periodic scan task + pass + except Exception: + logger.exception("Exception during send()") diff --git a/src/fastcs_secop/_util.py b/src/fastcs_secop/_util.py new file mode 100644 index 0000000..2d409d4 --- /dev/null +++ b/src/fastcs_secop/_util.py @@ -0,0 +1,193 @@ +import enum +import typing +from collections.abc import Collection +from dataclasses import dataclass, field +from typing import Any + +import numpy as np +import numpy.typing as npt +from fastcs.datatypes import Bool, DataType, Enum, Float, Int, String, Table, Waveform + + +@dataclass(frozen=True) +class SecopQuirks: + """Define special handling for SECoP modules or accessibles. + + Not all combinations of SECoP features can be handled by all + transports. :py:obj:`SecopQuirks` allows specifying non-default + behaviour to work around these limitations. + """ + + update_period: float = 1.0 + """Update period, in seconds.""" + + skip_modules: Collection[str] = field(default_factory=list) + """Skip creating any listed modules.""" + + skip_accessibles: Collection[tuple[str, str]] = field(default_factory=list) + """Skip creating any listed ``(module_name, accessible_name)`` tuples.""" + + raw_accessibles: Collection[tuple[str, str]] = field(default_factory=list) + """Create any listed ``(module_name, accessible_name)`` tuples in raw mode. + + JSON for the specified accessibles will be treated as strings. + """ + + raw_array: bool = False + """If the accessible has an array type, read it in raw mode. + + JSON values for any array-type accessible will be treated as strings. + """ + + raw_matrix: bool = False + """If the accessible has a matrix type, read it in raw mode. + + JSON values for any matrix-type accessible will be treated as strings. + + This is useful for transports which cannot represent arbitrary N-dimensional + arrays. + """ + + raw_tuple: bool = False + """If the accessible has a tuple type, read it in raw mode. + + JSON values for any tuple-type accessible will be treated as strings. + + This is useful for transports which do not support the FastCS + :py:obj:`~fastcs.datatypes.table.Table` type. + """ + + raw_struct: bool = False + """If the accessible has a struct type, read it in raw mode. + + JSON values for any struct-type accessible will be treated as strings. + + This is useful for transports which do not support the FastCS + :py:obj:`~fastcs.datatypes.table.Table` type. + """ + + max_description_length: int | None = None + """Truncate accessible descriptions to this length. + + This is useful for transports such as EPICS CA which have a maximum description length. + """ + + +class SecopError(Exception): + """Error raised to identify a SECoP protocol or configuration problem.""" + + +def format_string_to_prec(fmt_str: str | None) -> int | None: + """Convert a SECoP format-string specifier to a precision.""" + if fmt_str is None: + return None + + if fmt_str.startswith("%.") and fmt_str.endswith("f"): + return int(fmt_str[2:-1]) + + return None + + +def secop_dtype_to_numpy_dtype(secop_datainfo: dict[str, Any]) -> npt.DTypeLike: + dtype = secop_datainfo["type"] + if dtype == "double": + return np.float64 + elif dtype == "int": + return np.int32 + elif dtype == "bool": + return np.uint8 # CA transport doesn't support bool_ + elif dtype == "enum": + return np.int32 + elif dtype == "string": + return f" list[tuple[str, npt.DTypeLike]]: + secop_dtypes = [t for t in datainfo["members"]] + np_dtypes = [secop_dtype_to_numpy_dtype(t) for t in secop_dtypes] + names = [f"e{n}" for n in range(len(datainfo["members"]))] + structured_np_dtype = list(zip(names, np_dtypes, strict=True)) + return structured_np_dtype + + +def struct_structured_dtype(datainfo: dict[str, Any]) -> list[tuple[str, npt.DTypeLike]]: + structured_np_dtype = [ + (k, secop_dtype_to_numpy_dtype(v)) for k, v in datainfo["members"].items() + ] + return structured_np_dtype + + +def secop_datainfo_to_fastcs_dtype(datainfo: dict[str, Any], raw: bool = False) -> DataType[Any]: + """Convert a SECoP datainfo dictionary to a FastCS data type. + + Args: + datainfo: SECoP datainfo dictionary. + raw: whether to read this parameter in 'raw' mode. + + """ + if raw: + return String(2048) + + min_val = datainfo.get("min") + max_val = datainfo.get("max") + + match datainfo["type"]: + case "double" | "scaled": + scale = datainfo.get("scale") + + if min_val is not None and scale is not None: + min_val *= scale + if max_val is not None and scale is not None: + max_val *= scale + + return Float( + units=datainfo.get("unit", None), + min_alarm=min_val, + max_alarm=max_val, + prec=format_string_to_prec(datainfo.get("fmtstr", None)), # type: ignore + ) + case "int": + return Int( + units=datainfo.get("unit", None), + min_alarm=min_val, + max_alarm=max_val, + ) + case "bool": + return Bool() + case "enum": + enum_type = enum.Enum("GeneratedSecopEnum", datainfo["members"]) + return Enum(enum_type) + case "string": + return String() + case "blob": + return Waveform(np.uint8, shape=(datainfo["maxbytes"],)) + case "array": + inner_dtype = datainfo["members"] + np_inner_dtype = secop_dtype_to_numpy_dtype(inner_dtype) + return Waveform(np_inner_dtype, shape=(datainfo["maxlen"],)) + case "tuple": + structured_dtype = tuple_structured_dtype(datainfo) + return Table(structured_dtype) + case "struct": + structured_dtype = struct_structured_dtype(datainfo) + return Table(structured_dtype) + case "matrix": + return Waveform(datainfo["elementtype"], shape=datainfo["maxlen"][::-1]) + case _: + raise SecopError(f"Invalid SECoP dtype for FastCS attribute: {datainfo['type']}") + + +def is_raw( + module_name: str, parameter_name: str, datainfo: dict[str, typing.Any], quirks: SecopQuirks +) -> bool: + return ( + ((module_name, parameter_name) in quirks.raw_accessibles) + or (datainfo["type"] == "array" and quirks.raw_array) + or (datainfo["type"] == "tuple" and quirks.raw_tuple) + or (datainfo["type"] == "struct" and quirks.raw_struct) + or (datainfo["type"] == "matrix" and quirks.raw_matrix) + ) diff --git a/tests/emulators/__init__.py b/tests/emulators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/emulators/simple_secop/__init__.py b/tests/emulators/simple_secop/__init__.py new file mode 100644 index 0000000..c7c0caf --- /dev/null +++ b/tests/emulators/simple_secop/__init__.py @@ -0,0 +1,6 @@ +import lewis + +from .device import SimulatedSecopNode + +framework_version = lewis.__version__ +__all__ = ["SimulatedSecopNode"] diff --git a/tests/emulators/simple_secop/device.py b/tests/emulators/simple_secop/device.py new file mode 100644 index 0000000..a5ed19e --- /dev/null +++ b/tests/emulators/simple_secop/device.py @@ -0,0 +1,225 @@ +import base64 +import math +import time +import typing +from collections import OrderedDict + +from lewis.devices import StateMachineDevice + +from .states import DefaultState + + +class Accessible: + def __init__(self): + self.description = "" + + +class Parameter(Accessible): + def __init__( + self, + value, + *, + dtype="double", + unit="", + prec=3, + desc="", + extra_datainfo: dict[str, typing.Any] | None = None, + value_encoder=lambda x: x, + ): + super().__init__() + self.value = value + self.dtype = dtype + self.unit = unit + self.prec = prec + self.desc = desc + self.extra_datainfo = extra_datainfo or {} + self.value_encoder = value_encoder + + def data_report(self): + return [ + self.value_encoder(self.value), + { + "t": time.time(), + }, + ] + + def descriptor(self) -> dict[str, typing.Any]: + return { + "description": self.desc, + "datainfo": { + "type": self.dtype, + "fmtstr": f"%.{self.prec}f", + "unit": self.unit, + **self.extra_datainfo, + }, + "readonly": False, + } + + def change(self, value): + self.value = value + + +class Command(Accessible): + def __init__(self, arg_datainfo, result_datainfo): + super().__init__() + self.arg_datainfo = arg_datainfo + self.result_datainfo = result_datainfo + + def descriptor(self) -> dict[str, typing.Any]: + return { + "description": "some_command_description", + "datainfo": { + "type": "command", + "argument": self.arg_datainfo, + "result": self.result_datainfo, + }, + } + + +class OneOfEachDtypeModule: + def __init__(self): + self.accessibles = { + "double": Parameter( + 1.2345, unit="mm", prec=4, desc="a double parameter", dtype="double" + ), + "scaled": Parameter( + 42, + unit="uA", + prec=4, + desc="a scaled parameter", + dtype="scaled", + extra_datainfo={"scale": 47, "min": 0, "max": 1_000_000}, + ), + "int": Parameter(73, desc="an integer parameter", dtype="int"), + "bool": Parameter(True, desc="a boolean parameter", dtype="bool"), + "enum": Parameter( + 3, + desc="an enum parameter", + dtype="enum", + extra_datainfo={"members": {"one": 1, "two": 2, "three": 3, "four": 4, "five": 5}}, + ), + "string": Parameter("hello", desc="a string parameter", dtype="string"), + "blob": Parameter( + b"a blob of binary data", + desc="a blob parameter", + dtype="blob", + value_encoder=lambda x: base64.b64encode(x).decode("ascii"), + extra_datainfo={"maxbytes": 512}, + ), + "double_array": Parameter( + [1.414, 1.618, math.e, math.pi], + desc="a double array parameter", + dtype="array", + extra_datainfo={"maxlen": 512, "members": {"type": "double"}}, + ), + "int_array": Parameter( + [1, 1, 2, 3, 5, 8, 13], + desc="an integer array parameter", + dtype="array", + extra_datainfo={"maxlen": 512, "members": {"type": "int"}}, + ), + "bool_array": Parameter( + [True, True, False, True, False, False, True, True], + desc="a bool array parameter", + dtype="array", + extra_datainfo={"maxlen": 512, "members": {"type": "bool"}}, + ), + "enum_array": Parameter( + [1, 2, 3, 2, 1], + desc="an enum array parameter", + dtype="array", + extra_datainfo={ + "maxlen": 512, + "members": { + "type": "enum", + "members": {"one": 1, "two": 2, "three": 3, "four": 4, "five": 5}, + }, + }, + ), + "tuple": Parameter( + [1, 5.678, True, "hiya", 5], + desc="a tuple of int, float, bool", + dtype="tuple", + extra_datainfo={ + "members": [ + {"type": "int"}, + {"type": "double"}, + {"type": "bool"}, + {"type": "string"}, + {"type": "enum"}, + ] + }, + ), + "struct": Parameter( + {"answer": 42, "pi": math.pi, "on_fire": True, "status": "chillin'", "mode": 1}, + desc="a struct of int, float, bool", + dtype="struct", + extra_datainfo={ + "members": { + "answer": {"type": "int"}, + "pi": {"type": "double"}, + "on_fire": {"type": "bool"}, + "status": {"type": "string"}, + "mode": {"type": "enum"}, + } + }, + ), + "matrix": Parameter( + {"len": [2, 3], "blob": "AACAPwAAAEAAAEBAAACAQAAAoEAAAMBA"}, + desc="a matrix parameter", + dtype="matrix", + extra_datainfo={"elementtype": " dict[str, typing.Any]: + return { + "implementation": __name__, + "description": self.description, + "interface_classes": [], + "accessibles": { + name: accessible.descriptor() for name, accessible in self.accessibles.items() + }, + } + + +class SimulatedSecopNode(StateMachineDevice): + def _initialize_data(self): + """Initialize the device's attributes.""" + self.modules = { + "one_of_everything": OneOfEachDtypeModule(), + } + + def _get_state_handlers(self): + return {"default": DefaultState()} + + def _get_initial_state(self): + return "default" + + def _get_transition_handlers(self): + return OrderedDict([]) + + def descriptor(self) -> dict[str, typing.Any]: + return { + "equipment_id": __name__, + "description": "SECoP lewis emulator", + "modules": {name: module.descriptor() for name, module in self.modules.items()}, + } diff --git a/tests/emulators/simple_secop/interfaces/__init__.py b/tests/emulators/simple_secop/interfaces/__init__.py new file mode 100644 index 0000000..2966d6f --- /dev/null +++ b/tests/emulators/simple_secop/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import SimpleSecopStreamInterface + +__all__ = ["SimpleSecopStreamInterface"] diff --git a/tests/emulators/simple_secop/interfaces/stream_interface.py b/tests/emulators/simple_secop/interfaces/stream_interface.py new file mode 100644 index 0000000..195e9d3 --- /dev/null +++ b/tests/emulators/simple_secop/interfaces/stream_interface.py @@ -0,0 +1,69 @@ +import json +import time +import typing + +from lewis.adapters.stream import StreamInterface +from lewis.core.logging import has_log +from lewis.utils.command_builder import CmdBuilder + + +@has_log +class SimpleSecopStreamInterface(StreamInterface): + commands: typing.ClassVar = { + CmdBuilder("idn").escape("*IDN?").optional("\r").eos().build(), + CmdBuilder("ping").escape("ping ").any().optional("\r").eos().build(), + CmdBuilder("deactivate").escape("deactivate").optional(" .").optional("\r").eos().build(), + CmdBuilder("activate").escape("activate").optional(" .").optional("\r").eos().build(), + CmdBuilder("describe").escape("describe").optional("\r").eos().build(), + CmdBuilder("change") + .escape("change ") + .any_except(":") + .escape(":") + .any_except(" ") + .escape(" ") + .arg(".*", argument_mapping=json.loads) + .optional("\r") + .eos() + .build(), + CmdBuilder("read") + .escape("read ") + .any_except(":") + .escape(":") + .any_except("\r") + .optional("\r") + .eos() + .build(), + } + + in_terminator = "\n" + out_terminator = "\n" + + def handle_error(self, request, error): + err_string = f"command was: {request}, error was: {error.__class__.__name__}: {error}\n" + print(err_string) + self.log.error(err_string) + return err_string + + def idn(self): + return "ISSE&SINE2020,SECoP,V0000.00.00,lewis_emulator" + + def ping(self, token): + return f"pong {token} {json.dumps([None, {'t': time.time()}])}" + + def describe(self): + return f"describing . {json.dumps(self._device.descriptor())}" + + def change(self, module: str, accessible: str, value: typing.Any): + self._device.modules[module].accessibles[accessible].change(value) + data_report = self._device.modules[module].accessibles[accessible].data_report() + return f"changed {module}:{accessible} {json.dumps(data_report)}" + + def read(self, module: str, accessible: str): + data_report = self._device.modules[module].accessibles[accessible].data_report() + return f"reply {module}:{accessible} {json.dumps(data_report)}" + + def deactivate(self): + return "inactive" + + def activate(self): + raise ValueError("emulator does not (yet) support sending asynchronous updates.") diff --git a/tests/emulators/simple_secop/states.py b/tests/emulators/simple_secop/states.py new file mode 100644 index 0000000..e4ca48e --- /dev/null +++ b/tests/emulators/simple_secop/states.py @@ -0,0 +1,5 @@ +from lewis.core.statemachine import State + + +class DefaultState(State): + pass diff --git a/tests/test_against_emulator.py b/tests/test_against_emulator.py new file mode 100644 index 0000000..716a554 --- /dev/null +++ b/tests/test_against_emulator.py @@ -0,0 +1,166 @@ +import asyncio +import math +import os.path +import subprocess +import sys +import typing + +import numpy as np +import pytest +from fastcs import FastCS +from fastcs.attributes import AttrR +from fastcs.connections import IPConnectionSettings +from fastcs.logging import LogLevel, configure_logging + +from fastcs_secop import SecopController + +configure_logging(level=LogLevel.TRACE) + + +@pytest.fixture(autouse=True, scope="class") +def emulator(): + proc = subprocess.Popen( + [ + sys.executable, + "-m", + "lewis", + "-k", + "emulators", + "simple_secop", + "-p", + "stream: {bind_address: 127.0.0.1, port: 57677}", + ], + cwd=os.path.dirname(__file__), + ) + try: + yield + finally: + proc.kill() + + +@pytest.fixture +async def controller(): + controller = SecopController( + settings=IPConnectionSettings( + ip="127.0.0.1", + port=57677, + ), + ) + + for _ in range(100): + try: + await controller.connect() + break + except Exception: + await asyncio.sleep(0.1) + else: + raise RuntimeError("Could not connect to emulator within 10s") + + fastcs = FastCS( + controller, + [], + ) + + fastcs_task = asyncio.create_task(fastcs.serve(interactive=False)) + + # Wait for FastCS to have run initialise() & created attributes + max_iters = 100 # 10 seconds + for _ in range(max_iters): + if controller.sub_controllers: + break + await asyncio.sleep(0.1) + else: + raise RuntimeError("No subcontrollers created within 10s of FastCS serve") + + try: + yield controller + finally: + fastcs_task.cancel() + await fastcs_task + + +class TestInitialState: + def test_sub_controllers_created(self, controller): + assert "one_of_everything" in controller.sub_controllers + + @pytest.mark.parametrize( + ("param", "expected_initial_value"), + [ + ("double", 1.2345), + ("scaled", 42 * 47), + ("int", 73), + ("bool", True), + ("string", "hello"), + ], + ) + async def test_attributes_created_for_simple_datatype( + self, controller, param, expected_initial_value + ): + attr: AttrR = typing.cast( + AttrR, controller.sub_controllers["one_of_everything"].attributes[param] + ) + await attr.wait_for_value(expected_initial_value, timeout=2) + + async def test_attributes_created_for_enum_datatype(self, controller): + attr: AttrR = typing.cast( + AttrR, controller.sub_controllers["one_of_everything"].attributes["enum"] + ) + await attr.wait_for_predicate(lambda v: v.name == "three", timeout=2) + + @pytest.mark.parametrize( + ("param", "expected_initial_value"), + [ + ("blob", np.array([c for c in b"a blob of binary data"], dtype=np.uint8)), + ("int_array", np.array([1, 1, 2, 3, 5, 8, 13], dtype=np.int32)), + ("bool_array", np.array([1, 1, 0, 1, 0, 0, 1, 1], dtype=np.uint8)), + ("double_array", np.array([1.414, 1.618, math.e, math.pi], dtype=np.float64)), + ( + "tuple", + np.array( + [(1, 5.678, 1, "hiya", 5)], + dtype=[ + ("e0", np.int32), + ("e1", np.float64), + ("e2", np.uint8), + ("e3", "