+ {% endif %}
+{% endblock %}
diff --git a/docs/conf.py b/docs/conf.py
index df1c61c7..d8cedf34 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -10,197 +10,138 @@
# All configuration values have a default; values that are commented out
# serve to show the default.
-import sys, os
+import os
+import sys
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
-#sys.path.append(os.path.abspath('.'))
+repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
+sys.path.insert(0, repo_root)
-from pkg_resources import require
-require('numpy')
-require('cothread')
-require('epicsdbbuilder')
+import softioc # noqa
+# General information about the project.
+project = u'pythonIoc'
+copyright = u'2008, Diamond Light Source'
+author = 'Michael Abbott'
+
+# The full version, including alpha/beta/rc tags.
+release = softioc.__version__
+
+# The short X.Y version.
+if '+' in release:
+ # Not on a tag
+ version = 'master'
+else:
+ version = release
# -- General configuration -----------------------------------------------------
# Add any Sphinx extension module names here, as strings. They can be extensions
# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
extensions = [
-# 'sphinx.ext.intersphinx', # Cross project documentation?
-# 'sphinx.ext.todo', # Support todo entries in code
- 'sphinx.ext.viewcode', # Adds links to source code
+ # Use this for generating API docs
+ 'sphinx.ext.autodoc',
+ # 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',
+ # Add multiple versions of documentation on CI
+ 'sphinx_multiversion',
]
-viewcode_import = True
+# If true, Sphinx will warn about all references where the target cannot
+# be found.
+nitpicky = True
+
+# A list of (type, target) tuples (by default empty) that should be ignored when
+# generating warnings in "nitpicky mode". Note that type should include the
+# domain name if present. Example entries would be ('py:func', 'int') or
+# ('envvar', 'LD_LIBRARY_PATH').
+nitpick_ignore = [('py:func', 'int')]
+
+# Both the class’ and the __init__ method’s docstring are concatenated and
+# inserted into the main body of the autoclass directive
+autoclass_content = 'both'
+# Order the members by the order they appear in the source code
+autodoc_member_order = 'bysource'
+
+# Don't inherit docstrings from baseclasses
+autodoc_inherit_docstrings = False
+
+# The name of a reST role (builtin or Sphinx extension) to use as the default
+# role, that is, for text marked up `like this`
+default_role = 'any'
# Add any paths that contain templates here, relative to this directory.
-# templates_path = ['_templates']
+templates_path = ['_templates']
# The suffix of source filenames.
source_suffix = '.rst'
-# The encoding of source files.
-#source_encoding = 'utf-8'
-
# The master toctree document.
master_doc = 'index'
-# General information about the project.
-project = u'Python Soft IOC'
-copyright = u'2011, Michael Abbott'
-
-# The version info for the project you're documenting, acts as replacement for
-# |version| and |release|, also used in various other places throughout the
-# built documents.
-#
-# The short X.Y version.
-# version = 'v1-17'
-# # The full version, including alpha/beta/rc tags.
-# release = 'v1-17'
-
-# The language for content autogenerated by Sphinx. Refer to documentation
-# for a list of supported languages.
-#language = None
-
-# There are two options for replacing |today|: either, you set today to some
-# non-false value, then it is used:
-#today = ''
-# Else, today_fmt is used as the format for a strftime call.
-#today_fmt = '%B %d, %Y'
-
-# List of documents that shouldn't be included in the build.
-#unused_docs = []
-
# List of directories, relative to source directory, that shouldn't be searched
# for source files.
exclude_trees = ['_build']
-# The reST default role (used for this markup: `text`) to use for all documents.
-#default_role = None
-
-# If true, '()' will be appended to :func: etc. cross-reference text.
-#add_function_parentheses = True
-
-# If true, the current module name will be prepended to all description
-# unit titles (such as .. function::).
-#add_module_names = True
-
-# If true, sectionauthor and moduleauthor directives will be shown in the
-# output. They are ignored by default.
-#show_authors = False
-
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'sphinx'
-# A list of ignored prefixes for module index sorting.
-#modindex_common_prefix = []
-
+# This means you can link things like `str` and `asyncio` to the relevant
+# docs in the python documentation.
+intersphinx_mapping = dict(
+ python=('https://docs.python.org/3/', None),
+ cothread=("https://cothread.readthedocs.org/en/stable/", None),
+ epicsdbbuilder=(
+ "https://dls-controls.github.io/epicsdbbuilder/master/", None)
+)
# -- Options for HTML output ---------------------------------------------------
# The theme to use for HTML and HTML Help pages. Major themes that come with
# Sphinx are currently 'default' and 'sphinxdoc'.
-html_theme = 'default'
-
-# Theme options are theme-specific and customize the look and feel of a theme
-# further. For a list of options available for each theme, see the
-# documentation.
-#html_theme_options = {}
-
-# Add any paths that contain custom themes here, relative to this directory.
-#html_theme_path = []
-
-# The name for this set of Sphinx documents. If None, it defaults to
-# " v documentation".
-#html_title = None
-
-# A shorter title for the navigation bar. Default is the same as html_title.
-#html_short_title = None
-
-# The name of an image file (relative to this directory) to place at the top
-# of the sidebar.
-#html_logo = None
+try:
+ import sphinx_rtd_theme
+ html_theme = 'sphinx_rtd_theme'
+except ImportError:
+ html_theme = 'default'
-# The name of an image file (within the static path) to use as favicon of the
-# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
-# pixels large.
-#html_favicon = None
+# Options for the sphinx rtd theme, use DLS blue
+html_theme_options = dict(style_nav_header_background='rgb(7, 43, 93)')
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
-# html_static_path = ['_static']
-
-# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
-# using the given strftime format.
-#html_last_updated_fmt = '%b %d, %Y'
-
-# If true, SmartyPants will be used to convert quotes and dashes to
-# typographically correct entities.
-#html_use_smartypants = True
-
-# Custom sidebar templates, maps document names to template names.
-#html_sidebars = {}
-
-# Additional templates that should be rendered to pages, maps page names to
-# template names.
-#html_additional_pages = {}
-
-# If false, no module index is generated.
-#html_use_modindex = True
-
-# If false, no index is generated.
-#html_use_index = True
-
-# If true, the index is split into individual pages for each letter.
-#html_split_index = False
-
-# If true, links to the reST sources are added to the pages.
-#html_show_sourcelink = True
-
-# If true, an OpenSearch description file will be output, and all pages will
-# contain a tag referring to it. The value of this option must be the
-# base URL from which the finished HTML is served.
-#html_use_opensearch = ''
-
-# If nonempty, this is the file name suffix for HTML files (e.g. ".xhtml").
-#html_file_suffix = ''
-
-# Output file base name for HTML help builder.
-# htmlhelp_basename = 'Cothreaddoc'
-
-
-# -- Options for LaTeX output --------------------------------------------------
-
-# The paper size ('letter' or 'a4').
-#latex_paper_size = 'letter'
-
-# The font size ('10pt', '11pt' or '12pt').
-#latex_font_size = '10pt'
+html_static_path = ['_static']
-# Grouping the document tree into LaTeX files. List of tuples
-# (source start file, target name, title, author, documentclass [howto/manual]).
-# latex_documents = [
-# ('index', 'Cothread.tex', u'Cothread Documentation',
-# u'Michael Abbott', 'manual'),
-# ]
+# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
+html_show_sphinx = False
-# The name of an image file (relative to this directory) to place at the top of
-# the title page.
-#latex_logo = None
+# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
+html_show_copyright = True
-# For "manual" documents, if this is true, then toplevel headings are parts,
-# not chapters.
-#latex_use_parts = False
+# Add some CSS classes for columns and other tweaks in a custom css file
+html_css_files = ['theme_overrides.css']
-# Additional stuff for the LaTeX preamble.
-#latex_preamble = ''
+# Logo
+html_logo = 'images/softioc-logo.svg'
+html_favicon = 'images/softioc-favicon.ico'
-# Documents to append as an appendix to all manuals.
-#latex_appendices = []
+# sphinx-multiversion config
+smv_rebuild_tags = False
+smv_tag_whitelist = r'^\d+\.\d+.*$' # only document tags with form 0.9*
+smv_branch_whitelist = r'^master$' # only branch to document is master
+smv_outputdir_format = '{ref.name}'
+smv_prefer_remote_refs = False
+smv_remote_whitelist = 'origin|github'
-# If false, no module index is generated.
-#latex_use_modindex = True
+# Common links that should be available on every page
+rst_epilog = """
+.. _epicscorelibs: https://github.com/mdavidsaver/epicscorelibs
+"""
diff --git a/docs/explanations/why-use-pythonIoc.rst b/docs/explanations/why-use-pythonIoc.rst
new file mode 100644
index 00000000..6a958a07
--- /dev/null
+++ b/docs/explanations/why-use-pythonIoc.rst
@@ -0,0 +1,55 @@
+Why use pythonIOC?
+==================
+
+EPICS IOCs are flexible and modular, why do we need to wrap it in Python? This
+page attempts to answer that question and list a few good use-cases for it.
+
+Calculating PVs from other values
+---------------------------------
+
+Some use cases require PVs to be calculated from multiple sources. This is
+possible in EPICS records with ``calc`` or ``aSub`` records, but pythonIoc
+allows you to write this as:
+
+.. code-block::
+
+ import numpy as np
+ from cothread.catools import caget, camonitor
+ from softioc import builder
+
+ # The PVs we want to average and their initial values
+ PVs = [f"DEVICE{i}:CURRENT" for i in range(100)]
+ values = np.array(caget(PVs))
+
+ # The PV we want to serve
+ avg = builder.aOut("AVERAGE:CURRENT", np.mean(values))
+
+ # Make a monitor on the PVs to keep the value up to date
+ def update_avg(value: float, index: int):
+ values[index] = value
+ avg.set(np.mean(values))
+
+ camonitor(PVs, update_avg)
+
+ADD THE CONCENTRATOR USE CASE HERE
+
+Dynamically created PVs
+-----------------------
+
+Other use cases will do something like:
+
+.. code-block::
+
+ connect to device
+ make PVs
+
+ADD THE PANDA USE CASE HERE
+
+Existing Python Support
+-----------------------
+
+It may be that you have specific device support written in Python that you wish
+to expose as PVs.
+
+NEED A GOOD EXAMPLE HERE
+
diff --git a/docs/how-to/use-asyncio-in-an-ioc.rst b/docs/how-to/use-asyncio-in-an-ioc.rst
new file mode 100644
index 00000000..016d0462
--- /dev/null
+++ b/docs/how-to/use-asyncio-in-an-ioc.rst
@@ -0,0 +1,4 @@
+Use `asyncio` in an IOC
+=======================
+
+Write about the differences creating an IOC using `AsyncioDispatcher`
diff --git a/docs/images/softioc-favicon.ico b/docs/images/softioc-favicon.ico
new file mode 100644
index 00000000..b44d890a
Binary files /dev/null and b/docs/images/softioc-favicon.ico differ
diff --git a/docs/images/softioc-logo.svg b/docs/images/softioc-logo.svg
new file mode 100644
index 00000000..2ad9ccd3
--- /dev/null
+++ b/docs/images/softioc-logo.svg
@@ -0,0 +1,315 @@
+
+
diff --git a/docs/index.rst b/docs/index.rst
index 52efac8a..c17499c9 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -1,23 +1,74 @@
-.. _index:
+.. include:: ../README.rst
+ :end-before: when included in index.rst
-Documentation for Python Soft IOC
-=================================
+How the documentation is structured
+-----------------------------------
-Contents:
+.. rst-class:: columns
-.. toctree::
- :numbered:
- :maxdepth: 2
+:ref:`tutorials`
+~~~~~~~~~~~~~~~~
- pythonsoftioc
- records
- softioc
+Tutorials for installation, library and commandline usage. New users start here.
+.. rst-class:: columns
-Indices and tables
-==================
+:ref:`how-to`
+~~~~~~~~~~~~~
+
+Practical step-by-step guides for the more experienced user.
+
+.. rst-class:: columns
+
+:ref:`explanations`
+~~~~~~~~~~~~~~~~~~~
+
+Explanation of how the library works and why it works that way.
+
+.. rst-class:: columns
+
+:ref:`reference`
+~~~~~~~~~~~~~~~~
+
+Technical reference material, for classes, methods, APIs, commands, and contributing to the project.
+
+.. rst-class:: endcolumns
+
+About the documentation
+~~~~~~~~~~~~~~~~~~~~~~~
+
+`Why is the documentation structured this way? `_
+
+.. toctree::
+ :caption: Tutorials
+ :name: tutorials
+ :maxdepth: 1
+
+ tutorials/installation
+ tutorials/creating-an-ioc
+
+.. toctree::
+ :caption: How-to Guides
+ :name: how-to
+ :maxdepth: 1
+
+ how-to/use-asyncio-in-an-ioc
+
+.. toctree::
+ :caption: Explanations
+ :name: explanations
+ :maxdepth: 1
+
+ explanations/why-use-pythonIoc
+
+.. rst-class:: no-margin-after-ul
+
+.. toctree::
+ :caption: Reference
+ :name: reference
+ :maxdepth: 1
+
+ reference/api
+ reference/contributing
* :ref:`genindex`
-* :ref:`modindex`
-* :ref:`search`
-* `Selected source code <_modules/index.html>`_
diff --git a/docs/records.rst b/docs/records.rst
deleted file mode 100644
index 02b60104..00000000
--- a/docs/records.rst
+++ /dev/null
@@ -1,141 +0,0 @@
-.. _records:
-
-Record Support in the Python Soft IOC
-=====================================
-
-.. module:: softioc.device
- :synopsis: Implementation of Python Device support
-
-
-The Python soft IOC implements EPICS device support (almost) entirely in Python.
-This is used to invoke Python processing in response to record processing,
-making it easy to integrate Python into the EPICS IOC layer.
-
-Records are created dynamically during IOC startup before calling
-:func:`iocInit` and with the help of the :mod:`softioc.builder` module can be
-loaded with :func:`~softioc.builder.LoadDatabase`.
-
-All records are created internally using methods of the :class:`PythonDevice`
-class, one method for each of the supported record types, however the
-corresponding wrapping functions published by :mod:`softioc.builder` should be
-used as they configure sensible defaults and are generally easier to use.
-
-Create IN records (used for publishing data *from* the IOC, the naming of the
-direction is confusing) using the following :mod:`softioc.builder` methods:
-
- :func:`~softioc.builder.aIn`, :func:`~softioc.builder.boolIn`,
- :func:`~softioc.builder.longIn`, :func:`~softioc.builder.stringIn`,
- :func:`~softioc.builder.mbbIn`, :func:`~softioc.builder.Waveform`.
-
-Create OUT records for receiving control information into the IOC using the
-following methods:
-
- :func:`~softioc.builder.aOut`, :func:`~softioc.builder.boolOut`,
- :func:`~softioc.builder.longOut`, :func:`~softioc.builder.stringOut`,
- :func:`~softioc.builder.mbbOut`, :func:`~softioc.builder.WaveformOut`.
-
-For all records the `initial_value` keyword argument can be used to specify the
-records value on startup.
-
-
-Working with IN records
------------------------
-
-EPICS IN records are implemented as subclasses of the
-:class:`ProcessDeviceSupportIn` class which provides the methods documented
-below.
-
-.. class:: ProcessDeviceSupportIn
-
- This class is used to implement Python device support for the record types
- ``ai``, ``bi``, ``longin``, ``mbbi`` and IN ``waveform`` records.
-
- .. method:: set(value, severity=NO_ALARM, alarm=UDF_ALARM, timestamp=None)
-
- Updates the stored value and severity status and triggers an update. If
- ``SCAN`` has been set to ``'I/O Intr'`` (which is the default if the
- :mod:`~softioc.builder` methods have been used) then the record will be
- processed by EPICS and the given value will be published to all users.
-
- Optionally an explicit timestamp can be set. This is a value in seconds
- in the Unix epoch, as returned by :func:`time.time`. This argument only
- has any effect if ``TSE = -2`` was set when the record was created.
-
- Note that when calling :func:`set` for a waveform record the value is
- always copied immediately -- this avoids accidents with mutable values.
-
- .. method:: set_alarm(severity, alarm, timestamp=None)
-
- This is exactly equivalent to calling::
-
- rec.set(rec.get(), severity, alarm, timestamp)
-
- and triggers an alarm status change without changing the value.
-
- .. method:: get()
-
- This returns the value last written to this record with :func:`set`.
-
- Note that channel access puts to a Python soft IOC input record are
- completely ineffective, and this includes waveform records.
-
-
-Working with OUT records
-------------------------
-
-When creating OUT records three further optional keyword arguments can be
-specified:
-
-`on_update`
- If used this should be set to a callable taking exactly one argument. After
- successful record processing this function will be called with the new value
- just written to the record.
-
- Note that this callback occurs at an unpredictable time after record
- processing and if repeated high speed channel access puts are in progress it
- is possible that callbacks may be delayed. Each callback will be passed the
- value at the time the record was processed.
-
- Note also that `on_update` callbacks occur as part of cothread processing
- and normal cothread operations can occur during the callback. However only
- one callback is dispatched at a time, so if a callback blocks it will delay
- `on_update` callbacks for other records.
-
-`on_update_name`
- This is an alternative form of `on_update` with the same behaviour: note
- that at most one of `on_update` and `on_update_name` may be passed. The
- difference is that `on_update_name` is called with the record name as its
- second argument after the value as the first argument.
-
-`validate`
- If used this should be set to a callable taking two arguments. The first
- argument will be the record object, and the second will be the new value
- being written. The `validiate` function can reject the update by returning
- :const:`False` or accept it by returning :const:`True`.
-
- Note that this function is called asynchronously on a thread determined by
- EPICS and it is not safe to perform any cothread actions within this
- callback.
-
-`always_update`
- This flag defaults to :const:`False`, in which case updates to the record
- which don't change its value will be discarded. In particular this means
- that such updates don't call `validate` or `on_update`.
-
-.. class:: ProcessDeviceSupportOut
-
- This class is used to implement Python device support for the record types
- ``ao``, ``bo``, ``longout``, ``mbbo`` and OUT ``waveform`` records. All OUT
- records support the following methods.
-
- .. method:: set(value, process=True)
-
- Updates the value associated with the record. By default this will
- trigger record processing, and so will cause any associated `on_update`
- and `validate` methods to be called. If `process` is :const:`False`
- then neither of these methods will be called, but the value will still
- be updated.
-
- .. method:: get()
-
- Returns the value associated with the record.
diff --git a/docs/reference/api.rst b/docs/reference/api.rst
new file mode 100644
index 00000000..a4bf0e61
--- /dev/null
+++ b/docs/reference/api.rst
@@ -0,0 +1,520 @@
+.. _API:
+
+API
+===
+
+.. automodule:: softioc
+
+ ``softioc``
+ -----------
+
+ The top level softioc module contains a number of packages that can be used
+ in the creation of IOCs:
+
+ `softioc.softioc`
+ This module wraps the basic interface to the EPICS IOC. A large number of
+ interactive EPICS commands are wrapped and can be made available through the
+ interpreter by invoking the interpreter through this module.
+
+ `softioc.asyncio_dispatcher`
+ A dispatcher for `asyncio` based applications instead of the default
+ `cothread` one
+
+ `softioc.alarm`
+ This module simply contains definitions for severity and alarm values taken
+ from the EPICS ``alarm.h`` header file.
+
+ `softioc.builder`
+ This module provides facilities for creating PVs.
+
+ `softioc.pvlog`
+ The act of importing this module configures the IOC to log every external
+ put to the database.
+
+ The following submodules implement internals and should not normally be looked
+ at directly:
+
+ ``softioc.imports``
+ Imports and wraps C functions from EPICS IOC support.
+
+ ``softioc.fields``
+ Used internally as part of record support to implement access to EPICS
+ record fields.
+
+ ``softioc.device_core``
+ Implements the basics of ``Python`` EPICS device support.
+
+ `softioc.device`
+ Implements ``Python`` device support for all the record types supported.
+
+ ``softioc.pythonSoftIoc``
+ Implements `epicsdbbuilder` interface for all of the ``Python`` records.
+
+.. data:: softioc.__version__
+ :type: str
+
+ Version number as calculated by https://github.com/dls-controls/versiongit
+
+.. automodule:: softioc.softioc
+
+ ..
+ NOTE: can't use :members: here as it calls repr on the exit object which
+ makes the process exit!
+
+ Top Level IOC Interface: `softioc.softioc`
+ ------------------------------------------
+
+ This module provides the following functions for general use (available by
+ importing ``*``):
+
+.. autofunction:: iocInit
+
+.. autofunction:: dbLoadDatabase
+
+ .. note::
+
+ This function is not normally called directly, instead
+ `softioc.builder.LoadDatabase` is normally used to create and load the
+ EPICS database on the fly.
+
+ However, if required, an existing EPICS database can be loaded
+ explicitly using this method. Note that `dbLoadDatabase` cannot be
+ called after `iocInit`.
+
+.. autofunction:: devIocStats
+
+.. autofunction:: interactive_ioc
+
+While the interactive shell is running a number of EPICS test functions are made
+available for use together with the constant value `exit` with special
+behaviour: typing `exit` at the interpreter prompt will immediately call
+``epicsExit`` causing the Python interpreter and IOC to terminate.
+
+This module provides Python wrappers for the following EPICS test functions and
+makes them available to the `interactive_ioc` interpreter shell. See the `IOC
+Test Facilities`_ documentation for more details of each function.
+
+.. _IOC Test Facilities:
+ https://docs.epics-controls.org/en/latest/appdevguide/IOCTestFacilities.html
+
+.. autofunction:: dba
+.. autofunction:: dbl
+.. autofunction:: dbnr
+.. autofunction:: dbgrep
+.. autofunction:: dbgf
+.. autofunction:: dbpf
+.. autofunction:: dbpr
+.. autofunction:: dbtr
+.. autofunction:: dbtgf
+.. autofunction:: dbtpf
+.. autofunction:: dbior
+.. autofunction:: dbhcr
+.. autofunction:: gft
+.. autofunction:: pft
+.. autofunction:: tpn
+.. autofunction:: dblsr
+.. autofunction:: dbLockShowLocked
+.. autofunction:: scanppl
+.. autofunction:: scanpel
+.. autofunction:: scanpiol
+.. autofunction:: generalTimeReport
+.. autofunction:: eltc
+
+.. attribute:: exit
+
+ Displaying this value will invoke ``epicsExit()`` causing the IOC to
+ terminate immediately.
+
+.. automodule:: softioc.asyncio_dispatcher
+ :members:
+
+ Asyncio Dispatcher: `softioc.asyncio_dispatcher`
+ ------------------------------------------------
+
+ If your application uses `asyncio` then this module gives an alternative
+ dispatcher for caput requests.
+
+.. automodule:: softioc.builder
+
+ Creating Records: `softioc.builder`
+ -----------------------------------
+
+ This module publishes functions for creating records. All of the other methods
+ in this module must be called before calling :func:`LoadDatabase`, after which
+ no function in this module is usable.
+
+ See `softioc.device` for a detailed explanation of record support and creation,
+ but note that only the following records types have direct support from this
+ module:
+
+ ai, ao, bi, bo, longin, longout, mbbi, mbbo, stringin, stringout, waveform
+
+ The following methods create records of the corresponding type. For all records
+ the `initial_value` parameter can be used to specify an initial value for the
+ record.
+
+ The following optional keyword arguments are available for all of these
+ functions:
+
+ .. _initial_value:
+
+ `initial_value`
+ ~~~~~~~~~~~~~~~
+
+ This is used to specify an initial value for each record.
+
+ .. _on_update:
+
+ `on_update`
+ ~~~~~~~~~~~
+
+ This is only available on OUT records (including those created by
+ :func:`WaveformOut`). This specifies a function that will be called after
+ record processing has completed.
+
+ If used this should be set to a callable taking exactly one argument. After
+ successful record processing this function will be called with the new value
+ just written to the record.
+
+ Note that this callback occurs at an unpredictable time after record
+ processing and if repeated high speed channel access puts are in progress it
+ is possible that callbacks may be delayed. Each callback will be passed the
+ value at the time the record was processed.
+
+ Note also that `on_update` callbacks occur as part of cothread processing
+ and normal cothread operations can occur during the callback. However only
+ one callback is dispatched at a time, so if a callback blocks it will delay
+ `on_update` callbacks for other records.
+
+ .. _on_update_name:
+
+ `on_update_name`
+ ~~~~~~~~~~~~~~~~
+
+ This is an alternative form of `on_update` with the same behaviour: note
+ that at most one of `on_update` and `on_update_name` may be passed. The
+ difference is that `on_update_name` is called with the record name as its
+ second argument after the value as the first argument.
+
+ .. _validate:
+
+ `validate`
+ ~~~~~~~~~~
+
+ Also only available on OUT records, specifies a function called during
+ record processing. If used this should be set to a callable taking two
+ arguments. The first argument will be the record object, and the second
+ will be the new value being written. The `validate` function can reject
+ the update by returning `False` or accept it by returning `True`.
+
+ .. note::
+
+ This function is called asynchronously on a thread determined by
+ EPICS and it is not safe to perform any cothread actions within this
+ callback.
+
+ .. _always_update:
+
+ `always_update`
+ ~~~~~~~~~~~~~~~
+
+ Again only on OUT records, determines whether record writes which don't
+ change the existing value are passed through. If this field is not set then
+ writing to ``.PROC`` will have no visible effect.
+
+ This flag defaults to `False`, in which case updates to the record
+ which don't change its value will be discarded. In particular this means
+ that such updates don't call `validate` or `on_update`.
+
+For all of these functions any EPICS database field can be assigned a value by
+passing it as a keyword argument for the corresponding field name (in upper
+case) or by assigning to the corresponding field of the returned record object.
+Thus the ``**fields`` argument in all of the definitions below refers to both the
+optional keyword arguments listed above and record field names.
+
+All functions return a wrapped `ProcessDeviceSupportIn` or
+`ProcessDeviceSupportOut` instance.
+
+.. function::
+ aIn(name, LOPR=None, HOPR=None, **fields)
+ aOut(name, LOPR=None, HOPR=None, **fields)
+
+ Create ``ai`` and ``ao`` records. The lower and upper limits for the
+ record can be specified, and if specified these will also be used to set the
+ ``EGUL`` and ``EGUF`` fields.
+
+.. function::
+ boolIn(name, ZNAM=None, ONAM=None, **fields)
+ boolOut(name, ZNAM=None, ONAM=None, **fields)
+
+ Create ``bi`` and ``bo`` records with the specified names for false (zero)
+ and true (one).
+
+.. function::
+ longIn(name, LOPR=None, HOPR=None, EGU=None, **fields)
+ longOut(name, DRVL=None, DRVH=None, EGU=None, **fields)
+
+ Create ``longin`` and ``longout`` records with specified limits and units.
+
+.. function::
+ stringIn(name, **fields)
+ stringOut(name, **fields)
+
+ Create ``stringin`` and ``stringout`` records.
+
+.. function::
+ mbbIn(name, *option_values, **fields)
+ mbbOut(name, *option_values, **fields)
+
+ Create ``mbbi`` and ``mbbo`` records. Up to 16 options can be specified as
+ a list of two or three field tuples. The first field of each tuple is the
+ option name, the second field is the option value, and the third optional
+ field is the option severity. For example::
+
+ status = mbbIn('STATUS',
+ ('OK', 0),
+ ('FAILING', 1, alarm.MINOR_ALARM),
+ ('FAILED', 2, alarm.MAJOR_ALARM))
+
+.. function::
+ Waveform(name, [value,] **fields)
+ WaveformOut(name, [value,] **fields)
+
+ Create ``waveform`` records. Depending on whether `Waveform` or
+ `WaveformOut` is called the record is configured to behave as an IN or an
+ OUT record, in particular `on_update` can only be specified when calling
+ `WaveformOut`.
+
+ If ``value`` is specified or if an `initial_value` is specified (only one of
+ these can be used) the value is used to initialise the waveform and to
+ determine its field type and length. If no initial value is specified then
+ the keyword argument ``length`` must be used to specify the length of the
+ waveform.
+
+ The field type can be explicitly specified either by setting the ``datatype``
+ keyword to a Python type name, or by setting ``FTVL`` to the appropriate EPICS
+ field type name. Otherwise the field type is taken from the initial value
+ if given, or defaults to ``'FLOAT'``.
+
+
+The following function generates a specialised record.
+
+.. function:: Action(name, **fields)
+
+ Creates a record (using `boolOut`) which will always call the `on_update`
+ method when processed. Used for action records. The `on_update` keyword
+ should always be passed.
+
+
+The following functions manage record names. The record device name must be
+specified before creating records, then each record will be created with a
+standard two part name of the form ``device:name`` where the ``device`` part is
+specified by the functions below and the ``name`` part is specified in the
+record creation function.
+
+.. function:: SetDeviceName(device_name)
+
+ Sets up the prefix part of the record name, referred to here as the "device"
+ part. This function must be called before creating any records.
+ Note that
+ only this function need be used, the three other functions below are
+ entirely optional.
+
+.. function:: UnsetDevice()
+
+ This can optionally be called after completing the creation of records to
+ prevent the accidential creation of records with the currently set device
+ name.
+
+
+The following helper functions are useful when constructing links between
+records.
+
+.. function::
+ PP(record)
+ CP(record)
+ NP(record)
+ MS(record)
+
+ When assigned to a link field in a record these functions add the
+ appropriate processing attributes to the link. These are not normally used.
+
+
+The following attributes allow more direct access to record creation.
+
+.. attribute:: records
+
+ This is the ``iocbuilder`` records object, and is populated with
+ functions named after each available record type. Records created with
+ these calls are created with soft device support and Python is not involved
+ in their processing.
+
+ The following example shows a calc record being used to post-process a
+ standard Python IOC record::
+
+ from softioc import builder
+ builder.SetDeviceName('XX-XX-XX-01')
+ rec = aIn('VALUE')
+ calc = records.calc('CALC', CALC = 'A*B', A = rec, B = 42)
+ rec.FLNK = PP(calc)
+
+
+Finally, the following function is used to load record definitions before
+starting the IOC.
+
+.. function:: LoadDatabase()
+
+ This must be called exactly once after creating all the records required by
+ the IOC and before calling :func:`~softioc.softioc.iocInit`. After this
+ function has been called none of the functions provided by
+ :mod:`softioc.builder` are usable.
+
+.. automodule:: softioc.alarm
+
+ Alarm Value Definitions: `softioc.alarm`
+ ----------------------------------------
+
+
+The following values can be passed to IN record :meth:`~softioc.device.ProcessDeviceSupportIn.set` and
+:meth:`~softioc.device.ProcessDeviceSupportIn.set_alarm` methods.
+
+.. attribute::
+ NO_ALARM = 0
+ MINOR_ALARM = 1
+ MAJOR_ALARM = 2
+ INVALID_ALARM = 3
+
+ These are severity values. The default severity is ``NO_ALARM``.
+
+.. attribute::
+ READ_ALARM
+ WRITE_ALARM
+ HIHI_ALARM
+ HIGH_ALARM
+ LOLO_ALARM
+ LOW_ALARM
+ STATE_ALARM
+ COS_ALARM
+ COMM_ALARM
+ TIMEOUT_ALARM
+ HW_LIMIT_ALARM
+ CALC_ALARM
+ SCAN_ALARM
+ LINK_ALARM
+ SOFT_ALARM
+ BAD_SUB_ALARM
+ UDF_ALARM
+ DISABLE_ALARM
+ SIMM_ALARM
+ READ_ACCESS_ALARM
+ WRITE_ACCESS_ALARM
+
+ Alarm code definitions. Frankly these values aren't terribly useful, only
+ the severity is used for most notifications, but an alarm code needs to be
+ specified when specifying a non zero severity.
+
+
+.. automodule:: softioc.pvlog
+
+ Automatic PV logging: `softioc.pvlog`
+ -------------------------------------
+
+ Once this module has been imported all channel access writes to any PV published
+ by this IOC will be logged by writing a suitable message to stdout. There is
+ currently no control or customisation of this feature.
+
+.. automodule:: softioc.device
+
+ Record Support in the Python Soft IOC: `softioc.device`
+ -------------------------------------------------------
+
+The Python soft IOC implements EPICS device support (almost) entirely in Python.
+This is used to invoke Python processing in response to record processing,
+making it easy to integrate Python into the EPICS IOC layer.
+
+Records are created dynamically during IOC startup before calling
+:func:`~softioc.softioc.iocInit` and with the help of the `softioc.builder`
+module can be loaded with :func:`~softioc.builder.LoadDatabase`.
+
+All records are created internally using methods of the ``PythonDevice``
+class, one method for each of the supported record types, however the
+corresponding wrapping functions published by `softioc.builder` should be
+used as they configure sensible defaults and are generally easier to use.
+
+Create IN records (used for publishing data *from* the IOC, the naming of the
+direction is confusing) using the following `softioc.builder` methods:
+
+ :func:`~softioc.builder.aIn`, :func:`~softioc.builder.boolIn`,
+ :func:`~softioc.builder.longIn`, :func:`~softioc.builder.stringIn`,
+ :func:`~softioc.builder.mbbIn`, :func:`~softioc.builder.Waveform`.
+
+Create OUT records for receiving control information into the IOC using the
+following methods:
+
+ :func:`~softioc.builder.aOut`, :func:`~softioc.builder.boolOut`,
+ :func:`~softioc.builder.longOut`, :func:`~softioc.builder.stringOut`,
+ :func:`~softioc.builder.mbbOut`, :func:`~softioc.builder.WaveformOut`.
+
+For all records the `initial_value` keyword argument can be used to specify the
+records value on startup.
+
+Working with IN records
+~~~~~~~~~~~~~~~~~~~~~~~
+
+EPICS IN records are implemented as subclasses of the `ProcessDeviceSupportIn`
+class which provides the methods documented below.
+
+.. class:: ProcessDeviceSupportIn
+
+ This class is used to implement Python device support for the record types
+ ``ai``, ``bi``, ``longin``, ``mbbi`` and IN ``waveform`` records.
+
+ .. method:: set(value, severity=NO_ALARM, alarm=UDF_ALARM, timestamp=None)
+
+ Updates the stored value and severity status and triggers an update. If
+ ``SCAN`` has been set to ``'I/O Intr'`` (which is the default if the
+ :mod:`~softioc.builder` methods have been used) then the record will be
+ processed by EPICS and the given value will be published to all users.
+
+ Optionally an explicit timestamp can be set. This is a value in seconds
+ in the Unix epoch, as returned by :func:`time.time`. This argument only
+ has any effect if ``TSE = -2`` was set when the record was created.
+
+ Note that when calling :func:`set` for a waveform record the value is
+ always copied immediately -- this avoids accidents with mutable values.
+
+ .. method:: set_alarm(severity, alarm, timestamp=None)
+
+ This is exactly equivalent to calling::
+
+ rec.set(rec.get(), severity, alarm, timestamp)
+
+ and triggers an alarm status change without changing the value.
+
+ .. method:: get()
+
+ This returns the value last written to this record with :func:`set`.
+
+ Note that channel access puts to a Python soft IOC input record are
+ completely ineffective, and this includes waveform records.
+
+Working with OUT records
+~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. class:: ProcessDeviceSupportOut
+
+ This class is used to implement Python device support for the record types
+ ``ao``, ``bo``, ``longout``, ``mbbo`` and OUT ``waveform`` records. All OUT
+ records support the following methods.
+
+ .. method:: set(value, process=True)
+
+ Updates the value associated with the record. By default this will
+ trigger record processing, and so will cause any associated `on_update`
+ and `validate` methods to be called. If ``process`` is `False`
+ then neither of these methods will be called, but the value will still
+ be updated.
+
+ .. method:: get()
+
+ Returns the value associated with the record.
diff --git a/docs/reference/contributing.rst b/docs/reference/contributing.rst
new file mode 100644
index 00000000..2b6578f6
--- /dev/null
+++ b/docs/reference/contributing.rst
@@ -0,0 +1,3 @@
+.. _contributing:
+
+.. include:: ../../CONTRIBUTING.rst
diff --git a/docs/softioc.rst b/docs/softioc.rst
deleted file mode 100644
index 3d8801c4..00000000
--- a/docs/softioc.rst
+++ /dev/null
@@ -1,433 +0,0 @@
-.. _softioc:
-
-Soft IOC Python Module
-======================
-
-.. module:: softioc
- :synopsis: Top level module of Python Soft IOC facilities
-
-
-The :mod:`softioc` module is an integral part of the Python soft IOC server
-``pythonIoc``. Its path is automatically available to the interpreter and the
-following submodules can be loaded:
-
-:mod:`softioc.softioc`
- This module wraps the basic interface to the EPICS IOC. A large number of
- interactive EPICS commands are wrapped and can be made available through the
- interpreter by invoking the interpreter through this module.
-
-:mod:`softioc.alarm`
- This module simply contains definitions for severity and alarm values taken
- from the EPICS ``alarm.h`` header file.
-
-:mod:`softioc.builder`
- This module provides facilities for creating PVs.
-
-:mod:`softioc.pvlog`
- The act of importing this module configures the IOC to log every external
- put to the database.
-
-
-The following submodules implement internals and should not normally be looked
-at directly:
-
-:mod:`softioc.imports`
- Imports and wraps C functions from EPICS IOC support.
-
-:mod:`softioc.fields`
- Used internally as part of record support to implement access to EPICS
- record fields.
-
-:mod:`softioc.device_core`
- Implements the basics of ``Python`` EPICS device support.
-
-:mod:`softioc.device`
- Implements ``Python`` device support for all the record types supported.
-
-:mod:`softioc.pythonSoftIoc`
- Implements :mod:`iocbuilder` interface for all of the ``Python`` records.
-
-
-Top Level IOC Interface: :mod:`softioc.softioc`
------------------------------------------------
-
-.. module:: softioc.softioc
- :synopsis: Interface to IOC creation and startup functionality
-
-
-This module provides the following functions for general use (available by
-importing ``*``):
-
-.. function:: iocInit()
-
- This must be called exactly once after loading all EPICS database files.
- After this point the EPICS IOC is running and serving PVs.
-
-.. function:: dbLoadDatabase(database, path=None, substitutions=None)
-
- This loads the specified EPICS database into the IOC with any given
- substitutions. Note that this function is not normally called directly,
- instead :mod:`softioc.builder` and its :func:`~softioc.builder.LoadDatabase`
- method is normally used to create and load the EPICS database on the fly.
-
- However, if required, an existing EPICS database can be loaded explicitly
- using this method. Note that :func:`dbLoadDatabase` cannot be called after
- :func:`iocInit`.
-
-.. function:: devIocStats(ioc_name)
-
- This will load a template for the devIocStats library with the specified IOC
- name. This should be called before :func:`iocInit`.
-
-.. function:: interactive_ioc(context={}, call_exit=True)
-
- This is the normal way to run an interactive shell after starting the IOC.
- The `context` argument is a dictionary of values that will be made available
- to the interactive Python shell together with a number of EPICS test
- functions. By default, if `call_exit` is :const:`True`, the IOC will be
- terminated by calling :func:`epicsExit` when the interpreter exits, which
- means that :func:`interactive_ioc` will not return.
-
- While the interactive shell is running a number of EPICS test functions are
- made available for use together with the constant value :const:`exit` with
- special behaviour: typing :const:`exit` at the interpreter prompt will
- immediately call :func:`epicsExit` causing the Python interpreter and IOC to
- terminate.
-
-This module provides Python wrappers for the following EPICS test functions and
-makes them available to the :func:`interactive_ioc` interpreter shell. See the
-EPICS documentation for more details of each function.
-
-.. function:: dba(field)
-
- Prints value of each field in dbAddr structure associated with field.
-
-.. function:: dbl(pattern='', fields='')
-
- Prints the names of records in the database matching pattern. If
- a (space separated) list of fields is also given then the values of
- the fields are also printed.
-
-.. function:: dbnr(all=0)
-
- Print number of records of each record type.
-
-.. function:: dbgrep(pattern)
-
- Lists all record names that match the pattern. '*' matches any number of
- characters in a record name.
-
-.. function:: dbgf(field)
-
- Prints field type and value.
-
-.. function:: dbpf(field, value)
-
- Writes the given value into the field.
-
-.. function:: dbpr(record, interest=0)
-
- Prints all the fields in record up to the indicated interest level:
-
- = ===========================
- 0 Application fields which change during record processing
- 1 Application fields which are fixed during processing
- 2 System developer fields of major interest
- 3 System developer fields of minor interest
- 4 All other fields.
- = ===========================
-
-.. function:: dbtr(record)
-
- Tests processing of the specified record.
-
-.. function:: dbior(driver='', interest=0)
-
- Prints driver reports for the selected driver (or all drivers if
- driver is omitted) at the given interest level.
-
-.. function:: dbhcr()
-
- Prints hardware configuration report.
-
-.. function:: scanppl(rate=0.0)
-
- Prints all records with the selected scan rate (or all if rate=0).
-
-.. function:: scanpel(event=0)
-
- Prints all records with selected event number (or all if event=0).
-
-.. function:: scanpiol()
-
- Prints all records in the I/O event scan lists.
-
-.. function:: generalTimeReport(level=0)
-
- Displays time providers and their status
-
-.. function:: eltc(enable)
-
- Turn EPICS logging on or off.
-
-.. function::
- dbLockShowLocked()
- dblsr()
- dblsr()
- dbtgf()
- dbtpf()
- dbtpn()
- gft()
- pft()
- tpn()
-
- These are all wrappers around the corresponding EPICS function, see the
- EPICS documentation for details of their meaning and behaviour.
-
-.. attribute:: exit
-
- Displaying this value will invoke :func:`epicsExit` causing the IOC to
- terminate immediately.
-
-
-Creating Records: :mod:`softioc.builder`
-----------------------------------------
-
-.. module:: softioc.builder
- :synopsis: Tools for building Python bound PVs
-
-
-This module publishes functions for creating records. All of the other methods
-in this module must be called before calling :func:`LoadDatabase`, after which
-no function in this module is usable.
-
-See :ref:`records` for a detailed explanation of record support and creation,
-but note that only the following records types have direct support from this
-module:
-
- ai, ao, bi, bo, longin, longout, mbbi, mbbo, stringin, stringout, waveform
-
-The following methods create records of the corresponding type. For all records
-the `initial_value` parameter can be used to specify an initial value for the
-record.
-
-The following optional keyword arguments are available for all of these
-functions:
-
-`initial_value`
- This is used to specify an initial value for each record.
-
-`on_update`
- This is only available on OUT records (including those created by
- :func:`WaveformOut`). This specifies a function that will be called after
- record processing has completed.
-
-`on_update_name`
- This is an alternative callback function to use instead of `on_update`.
- This function will be passed the record name as well as updated value.
-
-`validate`
- Also only available on OUT records, specifies a function called during
- record processing. Note that this function is not cothread safe, that is to
- say, it is not called on the cothread thread.
-
-`always_update`
- Again only on OUT records, determines whether record writes which don't
- change the existing value are passed through. If this field is not set then
- writing to ``.PROC`` will have no visible effect.
-
-For all of these functions any EPICS database field can be assigned a value by
-passing it as a keyword argument for the corresponding field name (in upper
-case) or by assigning to the corresponding field of the returned record object.
-Thus the `**fields` argument in all of the definitions below refers to both the
-optional keyword arguments listed above and record field names.
-
-.. function::
- aIn(name, LOPR=None, HOPR=None, **fields)
- aOut(name, LOPR=None, HOPR=None, **fields)
-
- Create ``ai`` and ``ao`` records. The lower and upper limits for the
- record can be specified, and if specified these will also be used to set the
- ``EGUL`` and ``EGUF`` fields.
-
-.. function::
- boolIn(name, ZNAM=None, ONAM=None, **fields)
- boolOut(name, ZNAM=None, ONAM=None, **fields)
-
- Create ``bi`` and ``bo`` records with the specified names for false (zero)
- and true (one).
-
-.. function::
- longIn(name, LOPR=None, HOPR=None, EGU=None, **fields)
- longOut(name, DRVL=None, DRVH=None, EGU=None, **fields)
-
- Create ``longin`` and ``longout`` records with specified limits and units.
-
-.. function::
- stringIn(name, **fields)
- stringOut(name, **fields)
-
- Create ``stringin`` and ``stringout`` records.
-
-.. function::
- mbbIn(name, *option_values, **fields)
- mbbOut(name, *option_values, **fields)
-
- Create ``mbbi`` and ``mbbo`` records. Up to 16 options can be specified as
- a list of two or three field tuples. The first field of each tuple is the
- option name, the second field is the option value, and the third optional
- field is the option severity. For example::
-
- status = mbbIn('STATUS',
- ('OK', 0),
- ('FAILING', 1, alarm.MINOR_ALARM),
- ('FAILED', 2, alarm.MAJOR_ALARM))
-
-.. function::
- Waveform(name, [value,] **fields)
- WaveformOut(name, [value,] **fields)
-
- Create ``waveform`` records. Depending on whether :func:`Waveform` or
- :func:`WaveformOut` is called the record is configured to behave as an IN or
- an OUT record, in particular `on_update` can only be specified when calling
- :func:`WaveformOut`.
-
- If `value` is specified or if an `initial_value` is specified (only one of
- these can be used) the value is used to initialise the waveform and to
- determine its field type and length. If no initial value is specified then
- the keyword argument `length` must be used to specify the length of the
- waveform.
-
- The field type can be explicitly specified either by setting the `datatype`
- keyword to a Python type name, or by setting `FTVL` to the appropriate EPICS
- field type name. Otherwise the field type is taken from the initial value
- if given, or defaults to ``'FLOAT'``.
-
-
-The following function generates a specialised record.
-
-.. function:: Action(name, **fields)
-
- Creates a record (using :func:`boolOut`) which will always call the
- `on_update` method when processed. Used for action records. The
- `on_update` keyword should always be passed.
-
-
-The following functions manage record names. The record device name must be
-specified before creating records, then each record will be created with a
-standard two part name of the form ``device:name`` where the ``device`` part is
-specified by the functions below and the ``name`` part is specified in the
-record creation function.
-
-.. function:: SetDeviceName(device_name)
-
- Sets up the prefix part of the record name, referred to here as the "device"
- part. This function must be called before creating any records.
- Note that
- only this function need be used, the three other functions below are
- entirely optional.
-
-.. function:: UnsetDevice()
-
- This can optionally be called after completing the creation of records to
- prevent the accidential creation of records with the currently set device
- name.
-
-
-The following helper functions are useful when constructing links between
-records.
-
-.. function::
- PP(record)
- CP(record)
- NP(record)
- MS(record)
-
- When assigned to a link field in a record these functions add the
- appropriate processing attributes to the link. These are not normally used.
-
-
-The following attributes allow more direct access to record creation.
-
-.. attribute:: records
-
- This is the :mod:`iocbuilder` records object, and is populated with
- functions named after each available record type. Records created with
- these calls are created with soft device support and Python is not involved
- in their processing.
-
- The following example shows a calc record being used to post-process a
- standard Python IOC record::
-
- from softioc import builder
- builder.SetDeviceName('XX-XX-XX-01')
- rec = aIn('VALUE')
- calc = records.calc('CALC', CALC = 'A*B', A = rec, B = 42)
- rec.FLNK = PP(calc)
-
-
-Finally, the following function is used to load record definitions before
-starting the IOC.
-
-.. function:: LoadDatabase()
-
- This must be called exactly once after creating all the records required by
- the IOC and before calling :func:`~softioc.softioc.iocInit`. After this
- function has been called none of the functions provided by
- :mod:`softioc.builder` are usable.
-
-
-Alarm Value Definitions
------------------------
-
-.. module:: softioc.alarm
- :synopsis: Constant definitions for EPICS severity and alarm values
-
-The following values can be passed to IN record :meth:`set` and
-:meth:`set_alarm` methods.
-
-.. attribute::
- NO_ALARM = 0
- MINOR_ALARM = 1
- MAJOR_ALARM = 2
- INVALID_ALARM = 3
-
- These are severity values. The default severity is :attr:`NO_ALARM`.
-
-.. attribute::
- READ_ALARM
- WRITE_ALARM
- HIHI_ALARM
- HIGH_ALARM
- LOLO_ALARM
- LOW_ALARM
- STATE_ALARM
- COS_ALARM
- COMM_ALARM
- TIMEOUT_ALARM
- HW_LIMIT_ALARM
- CALC_ALARM
- SCAN_ALARM
- LINK_ALARM
- SOFT_ALARM
- BAD_SUB_ALARM
- UDF_ALARM
- DISABLE_ALARM
- SIMM_ALARM
- READ_ACCESS_ALARM
- WRITE_ACCESS_ALARM
-
- Alarm code definitions. Frankly these values aren't terribly useful, only
- the severity is used for most notifications, but an alarm code needs to be
- specified when specifying a non zero severity.
-
-
-Automatic PV logging
---------------------
-
-.. module:: softioc.pvlog
- :synopsis: Enables logging of CA puts to PVs
-
-Once this module has been imported all channel access writes to any PV published
-by this IOC will be logged by writing a suitable message to stdout. There is
-currently no control or customisation of this feature.
diff --git a/docs/pythonsoftioc.rst b/docs/tutorials/creating-an-ioc.rst
similarity index 79%
rename from docs/pythonsoftioc.rst
rename to docs/tutorials/creating-an-ioc.rst
index baa7743c..4de37e3d 100644
--- a/docs/pythonsoftioc.rst
+++ b/docs/tutorials/creating-an-ioc.rst
@@ -1,10 +1,59 @@
-.. _pythonsoftioc:
-
-Python Soft IOC
+Creating an IOC
===============
-The ``pythonIoc`` command is a tool for creating and running an EPICS IOC
-entirely within Python.
+THIS NEEDS UPDATING
+
+Using ``pythonSoftIoc``
+-----------------------
+
+Probably the best way to use ``pythonSoftIoc`` is to start by copying fragments
+of a simple example such as ``CS-DI-IOC-02``. This consists of the following
+elements:
+
+1. A startup shell script ``start-ioc`` which launches the soft IOC using a
+ production build of ``pythonSoftIoc``. This script typically looks like
+ this::
+
+ #!/bin/sh
+
+ PYIOC=/path/to/pythonSoftIoc/pythonIoc
+
+ cd "$(dirname "$0")"
+ exec $PYIOC start_ioc.py "$@"
+
+2. The startup Python script. This establishes the essential component
+ versions (apart from the ``pythonSoftIoc`` version), performs the appropriate
+ initialisation and starts the IOC running. The following template is a
+ useful starting point::
+
+ from pkg_resources import require
+ require('cothread==2.12')
+ require('epicsdbbuilder==1.0')
+
+ # Import the basic framework components.
+ from softioc import softioc, builder
+ import cothread
+
+ # Import any modules required to run the IOC
+ import ...
+
+ # Boilerplate get the IOC started
+ builder.LoadDatabase()
+ softioc.iocInit()
+
+ # Start processes required to be run after iocInit
+ ...
+
+ # Finally leave the IOC running with an interactive shell.
+ softioc.interactive_ioc(globals())
+
+ Note that the use of ``require`` is specific to DLS, and you may have a
+ different way of managing your installations.
+
+.. _numpy: http://www.numpy.org/
+.. _cothread: https://github.com/dls-controls/cothread
+.. _epicsdbbuilder: https://github.com/Araneidae/epicsdbbuilder
+
Introduction
@@ -48,7 +97,7 @@ with one PV::
This example script illustrates the following points.
-- The use of :func:`pkg_resources.require` is standard across all use of the
+- The use of ``pkg_resources.require`` is standard across all use of the
``dls-python`` Python interpreter at Diamond, and in this example we are using
both :mod:`cothread` and :mod:`epicsdbbuilder`. Of course, in an officially
published IOC specific versions must be specified, in this example I'm using
@@ -107,7 +156,7 @@ bit more structure is needed. I recommend at least four files as shown:
``ioc_entry.py``
I recommend that the top level Python script used to launch the IOC contain
- only :func:`pkg_resources.require` statements, simple code to start the body
+ only ``pkg_resources.require`` statements, simple code to start the body
of the IOC, and it should end with standard code to start the IOC. The
following structure can be followed (here I've assumed that the rest of the
IOC is in a single file called ``ioc_body.py``::
@@ -172,9 +221,9 @@ following types:
Occasionally it may be desirable to create a soft record without ``Python``
device support, particularly if any other record type is required. This can be done using the corresponding record creation
-functions provided as methods of :attr:`records`. For example, if a ``calc``
+functions provided as methods of :attr:`softioc.builder.records`. For example, if a ``calc``
record is required then this can be created by calling
-:func:`softioc.builder.records.calc`.
+``softioc.builder.records.calc``.
For all records created by these methods both
:meth:`~softioc.device.ProcessDeviceSupportIn.get` and
@@ -194,7 +243,7 @@ which must be called in this order. After calling
It is sensible to start any server background activity after the IOC has been
initialised by calling :func:`~softioc.softioc.iocInit`. After this has been
-done (:func:`cothread.Spawn` is recommended for initiating persistent background
+done (:class:`cothread.Spawn` is recommended for initiating persistent background
activity) the top level script must pause, as as soon as it exits the IOC will
exit. Calling :func:`~softioc.softioc.interactive_ioc` is recommended for this
as the last statement in the top level script.
diff --git a/docs/tutorials/installation.rst b/docs/tutorials/installation.rst
new file mode 100644
index 00000000..b8d6ade2
--- /dev/null
+++ b/docs/tutorials/installation.rst
@@ -0,0 +1,46 @@
+Installation Tutorial
+=====================
+
+.. note::
+
+ For installation inside DLS, please see the internal documentation on
+ ``dls-python3`` and ``pipenv``. Although these instructions will work
+ inside DLS, they are intended for external use.
+
+ If you want to contribute to the library itself, please following
+ the `contributing` instructions.
+
+Check your version of python
+----------------------------
+
+You will need python 3.7 or later. You can check your version of python by
+typing into a terminal::
+
+ python3 --version
+
+Create a virtual environment
+----------------------------
+
+It is recommended that you install into a “virtual environment” so this
+installation will not interfere with any existing Python software::
+
+ python3 -m venv /path/to/venv
+ source /path/to/venv/bin/activate
+
+
+Installing the library
+----------------------
+
+You can now use ``pip`` to install the library::
+
+ python3 -m pip install softioc
+
+If you require a feature that is not currently released you can also install
+from github::
+
+ python3 -m pip install git+git://github.com/dls-controls/softioc.git
+
+The library should now be installed and the commandline interface on your path.
+You can check the version that has been installed by typing::
+
+ softioc --version
diff --git a/example/createdb.py b/example/createdb.py
deleted file mode 100644
index 50d65060..00000000
--- a/example/createdb.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# Python script to create a .db file
-
-import sys
-
-import versions
-import testing
-
-from softioc.builder import WriteRecords
-WriteRecords(sys.argv[1])
diff --git a/example/runtest b/example/runtest
deleted file mode 100755
index b4b5bcf4..00000000
--- a/example/runtest
+++ /dev/null
@@ -1,3 +0,0 @@
-#!/bin/sh
-cd "$(dirname "$0")"
-../pythonIoc startup.py "$@"
diff --git a/example/startup.py b/example/startup.py
deleted file mode 100644
index 68b5d089..00000000
--- a/example/startup.py
+++ /dev/null
@@ -1,15 +0,0 @@
-# Simple example script for building an example soft IOC.
-
-import versions
-
-import cothread
-from softioc import softioc, builder, pvlog
-
-from testing import *
-
-softioc.devIocStats('TS-DI-TEST-01')
-
-builder.LoadDatabase()
-softioc.iocInit()
-
-softioc.interactive_ioc(globals())
diff --git a/example/versions.py b/example/versions.py
deleted file mode 100644
index c1a47f67..00000000
--- a/example/versions.py
+++ /dev/null
@@ -1,8 +0,0 @@
-'''Version definitions for softIOC. This is normally the first module
-imported, and should only be used to establish module versions.'''
-
-from pkg_resources import require
-
-require('numpy==1.11.1')
-require('cothread==2.14')
-require('epicsdbbuilder==1.2')
diff --git a/iocStats b/iocStats
new file mode 160000
index 00000000..4df9e878
--- /dev/null
+++ b/iocStats
@@ -0,0 +1 @@
+Subproject commit 4df9e87815f6a9432955a3ddb45fafa9fe4a4d40
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 00000000..788e2c44
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,3 @@
+[build-system]
+requires = ["setuptools", "wheel", "setuptools_dso", "epicscorelibs>=7.0.4.99.1"]
+build-backend = "setuptools.build_meta:__legacy__"
diff --git a/python/Makefile b/python/Makefile
deleted file mode 100644
index 26940bc3..00000000
--- a/python/Makefile
+++ /dev/null
@@ -1,7 +0,0 @@
-TOP = ..
-include $(TOP)/configure/CONFIG
-
-# The only thing we need to do is deploy the .dbd in its proper place.
-DBD += device.dbd
-
-include $(TOP)/configure/RULES
diff --git a/python/softioc/__init__.py b/python/softioc/__init__.py
deleted file mode 100644
index 7b9a7500..00000000
--- a/python/softioc/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-'''Python soft IOC module.'''
diff --git a/python/softioc/alarm.py b/python/softioc/alarm.py
deleted file mode 100644
index c6ade99c..00000000
--- a/python/softioc/alarm.py
+++ /dev/null
@@ -1,29 +0,0 @@
-# Severity code definitions taken from EPICS alarm.h
-NO_ALARM = 0
-MINOR_ALARM = 1
-MAJOR_ALARM = 2
-INVALID_ALARM = 3
-
-# Some alarm code definitions taken from EPICS alarm.h
-READ_ALARM = 1
-WRITE_ALARM = 2
-HIHI_ALARM = 3
-HIGH_ALARM = 4
-LOLO_ALARM = 5
-LOW_ALARM = 6
-STATE_ALARM = 7
-COS_ALARM = 8
-COMM_ALARM = 9
-TIMEOUT_ALARM = 10
-HW_LIMIT_ALARM = 11
-CALC_ALARM = 12
-SCAN_ALARM = 13
-LINK_ALARM = 14
-SOFT_ALARM = 15
-BAD_SUB_ALARM = 16
-UDF_ALARM = 17
-DISABLE_ALARM = 18
-SIMM_ALARM = 19
-READ_ACCESS_ALARM = 20
-WRITE_ACCESS_ALARM = 21
-
diff --git a/python/softioc/imports.py b/python/softioc/imports.py
deleted file mode 100644
index c15a5e9a..00000000
--- a/python/softioc/imports.py
+++ /dev/null
@@ -1,173 +0,0 @@
-'''External DLL imports used for implementing Python EPICS device support.
-'''
-
-import os
-import os.path
-import sys
-from ctypes import *
-
-
-def expect_success(status, function, args):
- assert status == 0, 'Expected success'
-
-def expect_true(status, function, args):
- assert status, 'Expected True'
-
-
-if sys.version_info < (3,):
- # Python 2
- auto_encode = c_char_p
- def auto_decode(result, func, args):
- return result
-
-else:
- # Python 3
-
- # Encode all strings to c_char_p
- class auto_encode(c_char_p):
- encoded = []
- @classmethod
- def from_param(cls, value):
- if value is None:
- return value
- else:
- return value.encode()
-
- def auto_decode(result, func, args):
- return result.decode()
-
-
-libPythonSupport = CDLL('libPythonSupport.so')
-
-# void get_field_offsets(
-# const char * record_type, const char * field_names[], int field_count,
-# short field_offset[], short field_size[], short field_type[])
-#
-# Looks up field offset, size and type values for the given record type and
-# the given list of field names.
-get_field_offsets = libPythonSupport.get_field_offsets
-get_field_offsets.argtypes = (
- auto_encode, c_void_p, c_int, c_void_p, c_void_p, c_void_p)
-get_field_offsets.restype = None
-
-# int db_put_field(const char *name, short dbrType, void *pbuffer, long length)
-#
-# Updates value in given field through channel access, so notifications are
-# generated as appropriate.
-db_put_field = libPythonSupport.db_put_field
-db_put_field.argtypes = (auto_encode, c_int, c_void_p, c_long)
-db_put_field.errcheck = expect_success
-
-
-# const char *get_EPICS_BASE(void)
-#
-# Returns the path to EPICS_BASE
-get_EPICS_BASE = libPythonSupport.get_EPICS_BASE
-get_EPICS_BASE.argtypes = ()
-get_EPICS_BASE.restype = c_char_p
-get_EPICS_BASE.errcheck = auto_decode
-
-
-# PyObject *get_DBF_values(void)
-#
-# Returns dictionary mapping DBF_ enum names to values
-get_DBF_values = libPythonSupport.get_DBF_values
-get_DBF_values.restype = py_object
-
-
-# void EpicsPvPutHook(struct asTrapWriteMessage *pmessage, int after)
-#
-# Hook for logging EPICS caput events
-EpicsPvPutHook = libPythonSupport.EpicsPvPutHook
-
-
-EPICS_BASE = get_EPICS_BASE()
-EPICS_HOST_ARCH = os.environ['EPICS_HOST_ARCH']
-
-def EpicsDll(dll):
- return CDLL(
- os.path.join(EPICS_BASE, 'lib', EPICS_HOST_ARCH, 'lib%s.so' % dll))
-
-# A bit tricky: in more recent versions of EPICS all the entry points we want
-# have been gathered into a single .so, but previously they were split among
-# four different ones. Just try both options.
-try:
- libdbCore = EpicsDll('dbCore')
- libregistryIoc = libdbCore
- libdbIoc = libdbCore
- libmiscIoc = libdbCore
- libasIoc = libdbCore
-except OSError:
- # Ok, no dbCore, then we should find everything in these four instead.
- libregistryIoc = EpicsDll('registryIoc')
- libdbIoc = EpicsDll('dbIoc')
- libmiscIoc = EpicsDll('miscIoc')
- libasIoc = EpicsDll('asIoc')
-
-
-
-# int registryDeviceSupportAdd(
-# const char *name,const struct dset *pdset);
-#
-# Registers device support.
-registryDeviceSupportAdd = libregistryIoc.registryDeviceSupportAdd
-registryDeviceSupportAdd.argtypes = (c_char_p, c_void_p)
-registryDeviceSupportAdd.errcheck = expect_true
-
-
-# void scanIoInit(IOSCANPVT *)
-# void scanIoRequest(IOSCANPVT *)
-#
-# Initialise and trigger I/O Intr processing structure.
-IOSCANPVT = c_void_p
-
-scanIoInit = libdbIoc.scanIoInit
-scanIoInit.argtypes = (IOSCANPVT,)
-scanIoInit.restype = None
-
-scanIoRequest = libdbIoc.scanIoRequest
-scanIoRequest.argtypes = (IOSCANPVT,)
-scanIoRequest.restype = None
-
-dbLoadDatabase = libdbIoc.dbLoadDatabase
-dbLoadDatabase.argtypes = (auto_encode, auto_encode, auto_encode)
-dbLoadDatabase.errcheck = expect_success
-
-
-# unsigned short recGblResetAlarms(void *precord)
-#
-# Raises event processing if any alarm status has changed, and resets NSTA
-# and NSEV fields for further processing.
-recGblResetAlarms = libdbIoc.recGblResetAlarms
-recGblResetAlarms.argtypes = (c_void_p,)
-recGblResetAlarms.restype = c_short
-
-
-iocInit = libmiscIoc.iocInit
-iocInit.argtypes = ()
-
-epicsExit = libmiscIoc.epicsExit
-epicsExit.argtypes = ()
-
-
-# Import for libas
-
-# int asSetFilename(const char *acf)
-#
-# Set access control file
-asSetFilename = libasIoc.asSetFilename
-asSetFilename.argtypes = (auto_encode,)
-
-# asTrapWriteId asTrapWriteRegisterListener(asTrapWriteListener func)
-#
-# Install caput hook
-asTrapWriteRegisterListener = libasIoc.asTrapWriteRegisterListener
-
-
-__all__ = [
- 'get_field_offsets',
- 'registryDeviceSupportAdd',
- 'IOSCANPVT', 'scanIoRequest', 'scanIoInit',
- 'dbLoadDatabase',
- 'recGblResetAlarms',
-]
diff --git a/python/softioc/pvlog.py b/python/softioc/pvlog.py
deleted file mode 100644
index b245ead1..00000000
--- a/python/softioc/pvlog.py
+++ /dev/null
@@ -1,10 +0,0 @@
-# Support for PV put logging
-#
-# Importing this module configures PV put logging
-
-import os
-from . import imports
-
-imports.asSetFilename(
- os.path.join(os.path.dirname(__file__), '..', 'access.acf'))
-imports.asTrapWriteRegisterListener(imports.EpicsPvPutHook)
diff --git a/python/softioc/softioc.py b/python/softioc/softioc.py
deleted file mode 100644
index 1a1798fd..00000000
--- a/python/softioc/softioc.py
+++ /dev/null
@@ -1,179 +0,0 @@
-'''Top level import script for soft IOC support.'''
-
-import os
-import sys
-from ctypes import *
-
-from . import imports
-
-__all__ = ['dbLoadDatabase', 'iocInit', 'interactive_ioc']
-
-
-iocInit = imports.iocInit
-epicsExit = imports.epicsExit
-
-def safeEpicsExit():
- '''Calls epicsExit() after ensuring Python exit handlers called.'''
- if hasattr(sys, 'exitfunc'):
- try:
- # Calling epicsExit() will bypass any atexit exit handlers, so call
- # them explicitly now.
- sys.exitfunc()
- finally:
- # Make sure we don't try the exit handlers more than once!
- del sys.exitfunc
- epicsExit()
-
-
-# The following identifiers will be exported to interactive shell.
-command_names = []
-
-
-# IOC Test facilities
-def ExportTest(name, argtypes, defaults=(), description='no description yet'):
- f = getattr(imports.libdbIoc, name)
- f.argtypes = argtypes
- f.restype = None
-
- length = len(argtypes)
- def call_f(*args):
- missing = length - len(args)
- if missing > 0:
- # Add in the missing arguments from the given defaults
- args = args + defaults[-missing:]
- f(*args)
-
- call_f.__doc__ = description
- call_f.__name__ = name
- globals()[name] = call_f
- command_names.append(name)
-
-
-auto_encode = imports.auto_encode
-
-
-ExportTest('dba', (auto_encode,), (),
- '''dba(field)
-
- Prints value of each field in dbAddr structure associated with field.''')
-
-ExportTest('dbl', (auto_encode, auto_encode,), ('', ''),
- '''dbl(pattern='', fields='')
-
- Prints the names of records in the database matching pattern. If
- a (space separated) list of fields is also given then the values of
- the fields are also printed.''')
-
-ExportTest('dbnr', (c_int,), (0,),
- '''dbnr(all=0)
-
- Print number of records of each record type.''')
-
-ExportTest('dbgrep', (auto_encode,), (),
- '''dbgrep(pattern)
-
- Lists all record names that match the pattern. * matches any number of
- characters in a record name.''')
-
-ExportTest('dbgf', (auto_encode,), (),
- '''dbgf(field)
-
- Prints field type and value.''')
-
-ExportTest('dbpf', (auto_encode, auto_encode,), (),
- '''dbpf(field, value)
-
- Writes the given value into the field.''')
-
-ExportTest('dbpr', (auto_encode, c_int,), (0,),
- '''dbpr(record, interest=0)
-
- Prints all the fields in record up to the indicated interest level:
-
- 0 Application fields which change during record processing
- 1 Application fields which are fixed during processing
- 2 System developer fields of major interest
- 3 System developer fields of minor interest
- 4 All other fields.''')
-
-ExportTest('dbtr', (auto_encode,), (),
- '''dbtr(record)
-
- Tests processing of the specified record.''')
-
-ExportTest('dbtgf', (auto_encode,))
-ExportTest('dbtpf', (auto_encode, auto_encode,))
-
-ExportTest('dbior', (auto_encode, c_int,), ('', 0,),
- '''dbior(driver='', interest=0)
-
- Prints driver reports for the selected driver (or all drivers if
- driver is omitted) at the given interest level.''')
-
-ExportTest('dbhcr', (), (), '''Prints hardware configuration report.''')
-
-ExportTest('gft', (auto_encode,))
-ExportTest('pft', (auto_encode,))
-ExportTest('dbtpn', (auto_encode, auto_encode,))
-ExportTest('tpn', (auto_encode, auto_encode,))
-ExportTest('dblsr', (auto_encode, c_int,))
-ExportTest('dbLockShowLocked', (c_int,))
-
-ExportTest('scanppl', (c_double,), (0.0,),
- '''scanppl(rate=0.0)
-
- Prints all records with the selected scan rate (or all if rate=0).''')
-
-ExportTest('scanpel', (c_int,), (0,),
- '''scanpel(event=0)
-
- Prints all records with selected event number (or all if event=0).''')
-
-ExportTest('scanpiol', (), (),
- '''Prints all records in the I/O event scan lists.''')
-
-ExportTest('generalTimeReport', (c_int,), (0,),
- '''Displays time providers and their status''')
-
-ExportTest('eltc', (c_int,), (),
- '''Turn EPICS logging on or off.''')
-
-
-# Hacked up exit object so that when soft IOC framework sends us an exit command
-# we actually exit.
-class Exiter:
- def __repr__(self):
- safeEpicsExit()
- def __call__(self):
- safeEpicsExit()
-
-exit = Exiter()
-command_names.append('exit')
-
-
-def dbLoadDatabase(database, path = None, substitutions = None):
- '''Loads a database file and applies any given substitutions.'''
- imports.dbLoadDatabase(database, path, substitutions)
-
-def devIocStats(ioc_name):
- dbLoadDatabase(
- 'ioc.db', os.path.join(os.getenv('HERE'), 'db'),
- 'IOCNAME=%s,name=' % ioc_name)
-
-
-def interactive_ioc(context = {}, call_exit = True):
- '''Fires up the interactive IOC prompt with the given context.'''
- # Add all our commands to the given context.
- exports = dict((key, globals()[key]) for key in command_names)
- import code
-
- if sys.version_info < (3, 6):
- interact_args = {}
- else:
- # This suppresses irritating exit message introduced by Python3. Alas,
- # this option is only available in Python 3.6!
- interact_args = dict(exitmsg = '')
- code.interact(local = dict(exports, **context), **interact_args)
-
- if call_exit:
- safeEpicsExit()
diff --git a/pythonIoc.in b/pythonIoc.in
deleted file mode 100644
index d0a3ac31..00000000
--- a/pythonIoc.in
+++ /dev/null
@@ -1,41 +0,0 @@
-#!/bin/bash
-
-# Wrapper for invoking the Python softIoc. This IOC needs to be called with a
-# complete path and with PYTHONPATH set up correctly. It's easier to
-# configure this here in shell script than in the program itself!
-
-export HERE="$(readlink -fn "$(dirname "$0")")"
-export PYTHONPATH="$PYTHONPATH${PYTHONPATH:+:}$HERE/python"
-export EPICS_BASE='@@EPICS_BASE@@'
-export EPICS_HOST_ARCH='@@EPICS_HOST_ARCH@@'
-export PYTHONHOME='@@PYTHONHOME@@'
-
-case "$1" in
- --debug)
- # Run under gdb
- shift
- TEMP="$(mktemp)"
- trap 'rm -f "$TEMP"' EXIT
- echo run "$@" >>"$TEMP"
- gdb -x "$TEMP" "$HERE/bin/$EPICS_HOST_ARCH/softIoc"
- ;;
-
- --valgrind)
- # Run under valgrind with Python suppression file.
- shift
- PYTHON_SRC=/dls_sw/prod/tools/RHEL5/src/Python-2.6.4
- SUPP="$PYTHON_SRC"/Misc/valgrind-python.supp
- TEMP=$(mktemp)
- trap 'rm -f "$TEMP"' EXIT
- # Hack up the suppression file. Unfortunately this only works with the
- # one particular version of the file (line numbers!)
- sed '127,161{/^###/s///}' "$SUPP" >"$TEMP"
- valgrind --tool=memcheck --suppressions="$TEMP" \
- "$HERE/bin/$EPICS_HOST_ARCH/softIoc" "$@"
- ;;
-
- *)
- # Normal operation
- exec "$HERE/bin/$EPICS_HOST_ARCH/softIoc" "$@"
- ;;
-esac
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 00000000..9a7b883e
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,47 @@
+[metadata]
+name = softioc
+description = Embed an EPICS IOC in a Python process
+url = https://github.com/dls-controls/pythonIoc
+author = Michael Abbott
+author_email = Michael.Abbott@diamond.ac.uk
+license = Apache License 2.0
+long_description = file: README.rst
+long_description_content_type = text/x-rst
+classifiers =
+ Development Status :: 5 - Production/Stable
+ License :: OSI Approved :: Apache Software License
+ Programming Language :: Python :: 2.7
+ Programming Language :: Python :: 3.7
+ Programming Language :: Python :: 3.8
+ Programming Language :: Python :: 3.9
+
+[options]
+packages = softioc
+python_requires = >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, !=3.6.*
+
+[options.entry_points]
+# Include a command line script
+console_scripts =
+ pythonIoc = softioc.__main__:main
+
+[options.package_data]
+softioc =
+ access.acf
+ device.dbd
+ devIocStats.dbd
+ iocStatsDb/*
+
+[flake8]
+max-line-length = 80
+extend-ignore =
+ F401 F403 F405 # Allow from module import *
+ E251 # Allow call(param = value)
+ E301 E302 E303 E305 # Allow any number of blank lines
+
+[tool:pytest]
+# Run pytest with all our checkers, and don't spam us with massive tracebacks on error
+addopts = --tb=native -vv --doctest-modules --ignore=iocStats --ignore=epicscorelibs
+
+[coverage:run]
+# This is covered in the versiongit test suite so exclude it here
+omit = */_version_git.py
diff --git a/setup.py b/setup.py
new file mode 100644
index 00000000..17be87f0
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,103 @@
+import os
+import sys
+
+from setuptools.command.develop import develop
+import epicscorelibs.path
+import epicscorelibs.version
+from setuptools_dso import Extension, setup
+from epicscorelibs.config import get_config_var
+
+# Place the directory containing _version_git on the path
+for path, _, filenames in os.walk(os.path.dirname(os.path.abspath(__file__))):
+ if "_version_git.py" in filenames:
+ sys.path.append(path)
+ break
+
+from _version_git import __version__, get_cmdclass # noqa
+
+sources = ['softioc/extension.c']
+
+devIocStats_OSI = [
+ "devIocStatsAnalog.c",
+ "devIocStatsString.c",
+ "devIocStatsWaveform.c",
+ "devIocStatsSub.c",
+ "devIocStatsTest.c",
+ "devIocStats.h",
+]
+
+devIocStats_OSD = [
+ "osdCpuUsage.c",
+ "osdCpuUtilization.c",
+ "osdFdUsage.c",
+ "osdMemUsage.c",
+ "osdWorkspaceUsage.c",
+ "osdClustInfo.c",
+ "osdSuspTasks.c",
+ "osdIFErrors.c",
+ "osdBootInfo.c",
+ "osdSystemInfo.c",
+ "osdHostInfo.c",
+ "osdPIDInfo.c",
+ "devIocStatsOSD.h",
+]
+
+devIocStats_src = os.path.join("iocStats", "devIocStats")
+devIocStats_os = os.path.join(devIocStats_src, "os", get_config_var('OS_CLASS'))
+devIocStats_default = os.path.join(devIocStats_src, "os", "default")
+
+def _add_file(f):
+ if f.endswith(".h"):
+ # Only add header files if making an sdist
+ # https://github.com/pypa/packaging-problems/issues/84#issuecomment-383718492
+ should_add = "sdist" in sys.argv
+ else:
+ should_add = True
+ if should_add:
+ sources.append(f)
+
+for f in devIocStats_OSI:
+ _add_file(os.path.join(devIocStats_src, f))
+for f in devIocStats_OSD:
+ if os.path.exists(os.path.join(devIocStats_os, f)):
+ _add_file(os.path.join(devIocStats_os, f))
+ else:
+ _add_file(os.path.join(devIocStats_default, f))
+
+# Extension with all our C code
+ext = Extension(
+ name='softioc._extension',
+ sources = sources,
+ include_dirs=[
+ epicscorelibs.path.include_path,
+ devIocStats_src, devIocStats_os, devIocStats_default
+ ],
+ dsos = ['epicscorelibs.lib.dbCore', 'epicscorelibs.lib.Com'],
+ define_macros = get_config_var('CPPFLAGS'),
+ extra_compile_args = get_config_var('CFLAGS') + ["-std=c99"],
+ extra_link_args = get_config_var('LDFLAGS'),
+)
+
+# Add custom develop to add soft link to epicscorelibs in .
+class Develop(develop):
+ def install_for_development(self):
+ develop.install_for_development(self)
+ # Make a link here to epicscorelibs so `pip install -e .` works
+ # If we don't do this dbCore can't be found when _extension is
+ # built into .
+ link = os.path.join(self.egg_path, "epicscorelibs")
+ if not os.path.exists(link):
+ os.symlink(os.path.join(self.install_dir, "epicscorelibs"), link)
+
+setup(
+ cmdclass=dict(develop=Develop, **get_cmdclass()),
+ version=__version__,
+ ext_modules = [ext],
+ install_requires = [
+ # Dependency version declared in pyproject.toml
+ epicscorelibs.version.abi_requires(),
+ "numpy",
+ "epicsdbbuilder>=1.4"
+ ],
+ zip_safe = False, # setuptools_dso is not compatible with eggs!
+)
diff --git a/softIocApp/Makefile b/softIocApp/Makefile
deleted file mode 100644
index 8babbc53..00000000
--- a/softIocApp/Makefile
+++ /dev/null
@@ -1,81 +0,0 @@
-##########################################################################
-# Copyright (c) 2003 The University of Chicago, as Operator of Argonne
-# National Laboratory.
-# Copyright (c) 2003 The Regents of the University of California, as
-# Operator of Los Alamos National Laboratory.
-# EPICS BASE Versions 3.13.7 and higher are distributed subject to the
-# Software License Agreement found in the file LICENSE that is included
-# with this distribution.
-##########################################################################
-
-TOP=..
-
-include $(TOP)/configure/CONFIG
-
-
-# Discover the correct name for the python library and its location. This is
-# surprisingly tricky, as we want to support quite a number of different
-# configurations of Python interpreter.
-PYTHON_LIB := $(shell $(PYTHON) -c \
- 'from distutils import sysconfig; \
- print(sysconfig.get_config_var("LIBRARY")[3:-2])')
-PYTHON_LIB_DIR := $(shell $(PYTHON) -c \
- 'from distutils import sysconfig; \
- print(sysconfig.get_config_var("LIBDIR"))')
-
-
-USR_CFLAGS += -std=gnu99 -Werror
-
-# We just want the include path for the python library, that should be enough.
-# There are a lot of other settings which may conflict with EPICS build
-# settings, so we omit them.
-USR_CFLAGS += $(shell $(PYTHON_CONFIG) --cflags | tr ' ' '\n' | grep '^-I')
-
-# This tells the compiler to ignore errors generated by EPICS includes. We need
-# this because the EPICS headers have non strict prototypes in places.
-USR_CPPFLAGS += -isystem $(EPICS_BASE)/include
-
-
-PROD_IOC_DEFAULT = softIoc
-PROD_IOC_vxWorks = -nil-
-
-DBD += softIoc.dbd
-softIoc_DBD += base.dbd
-
-USR_CFLAGS += -DEPICS_BASE='"$(shell cd "$(EPICS_BASE)" && pwd)"'
-
-softIoc_SRCS += softIoc_registerRecordDeviceDriver.cpp
-softIoc_SRCS += softMain.c
-
-softIoc_LIBS += $(EPICS_BASE_IOC_LIBS)
-softIoc_LIBS += PythonSupport
-softIoc_LIBS += $(PYTHON_LIB)
-
-softIoc_LDFLAGS += $(shell $(PYTHON_CONFIG) --ldflags)
-# For some reason python-config --ldflags doesn't actually tell us where to find
-# the python library if we need telling, so this has to be computed here.
-$(PYTHON_LIB)_DIR = $(PYTHON_LIB_DIR)
-
-# Support for devIocStats
-ifdef DEVIOCSTATS
-softIoc_DBD += devIocStats.dbd
-softIoc_LIBS += devIocStats
-DB += $(DEVIOCSTATS)/db/ioc.db
-endif
-
-
-# ----------------------------------------------------------------------
-# Support library for python component, dynamically loaded to assist with
-# access to record fields.
-
-
-LIBRARY_IOC += PythonSupport
-
-# The following are compiled and added to the support library
-PythonSupport_SRCS += PythonSupport.c
-
-PythonSupport_LIBS += $(EPICS_BASE_IOC_LIBS)
-
-
-# include $(TOP)/configure/RULES_TOP
-include $(TOP)/configure/RULES
diff --git a/softIocApp/softMain.c b/softIocApp/softMain.c
deleted file mode 100644
index c5e20a30..00000000
--- a/softIocApp/softMain.c
+++ /dev/null
@@ -1,97 +0,0 @@
-/* Soft IOC initialisation.
- *
- * This is really pretty well as simple as possible: all real work is
- * delegated to the invoked Python script. */
-
-/* pyconfig.h (included from Python.h) redefines _POSIX_C_SOURCE and
- * _XOPEN_SOURCE, and in ways which conflict with the definitions provided by
- * EPICS. To avoid messages, we let Python.h have its way here.
- * Note also that Python recommends that Python.h be included first. */
-#undef _POSIX_C_SOURCE
-#undef _XOPEN_SOURCE
-#include
-
-#include
-
-#include
-#include
-#include
-#include
-#include
-
-#include "dbAccess.h"
-#include "iocInit.h"
-
-
-/* The global IOC registration function is automatically constructed by the
- * EPICS IOC build process, and this call completes the registration
- * process.
- * Note that although this is declared to return a value, in fact it
- * unconditionally returns 0, so it might as well be a void! */
-extern int softIoc_registerRecordDeviceDriver(struct dbBase *pdbbase);
-
-
-
-/* Loads the global IOC dbd definitions and registers them. */
-
-static bool LoadAndRegisterDbd(void)
-{
- const char *here = getenv("HERE");
- if (here == NULL)
- {
- fprintf(stderr, "Environment variable HERE must be defined\n");
- return false;
- }
- char softIoc_dbd[PATH_MAX];
- bool Ok = snprintf(
- softIoc_dbd, PATH_MAX, "%s/dbd/softIoc.dbd", here) < PATH_MAX;
- if (!Ok)
- {
- fprintf(stderr, "Path to dbd too long.\n");
- return false;
- }
-
- int status = dbLoadDatabase(softIoc_dbd, NULL, NULL);
- if (status != 0)
- fprintf(stderr,
- "Error (%d) loading dbd file \"%s\"\n", status, softIoc_dbd);
- if (status == 0)
- softIoc_registerRecordDeviceDriver(pdbbase);
- return status == 0;
-}
-
-
-int main(int argc, char *argv[])
-{
-#if PY_MAJOR_VERSION == 2
- char **python_argv = argv;
-
-#else
- /* Alas, for Python3 we need convert argv from char** to wchar_t**. */
- wchar_t **python_argv = PyMem_Malloc(sizeof(wchar_t *) * (argc + 1));
- python_argv[argc] = NULL;
-
-#if PY_MINOR_VERSION < 5
- /* This is a tricky space: we're supposed to use Py_DecodeLocale(), but
- * these versions of Python3 don't implement it yet. Do the simplest
- * workaround we can. This code is lifted from Python 3.4 Modules/python.c
- * and simplified as much as possible. */
- char *oldloc = strdup(setlocale(LC_ALL, NULL));
- setlocale(LC_ALL, "");
- for (int i = 0; i < argc; i ++)
- python_argv[i] = _Py_char2wchar(argv[i], NULL);
- setlocale(LC_ALL, oldloc);
- free(oldloc);
-
-#else
- /* This seems to be the "correct" Python 3 way. */
- for (int i = 0; i < argc; i ++)
- python_argv[i] = Py_DecodeLocale(argv[i], NULL);
-#endif
-#endif
-
- if (LoadAndRegisterDbd())
- return Py_Main(argc, python_argv);
- else
- return 3;
-}
diff --git a/python/softioc/README b/softioc/README
similarity index 100%
rename from python/softioc/README
rename to softioc/README
diff --git a/softioc/__init__.py b/softioc/__init__.py
new file mode 100644
index 00000000..35c9b808
--- /dev/null
+++ b/softioc/__init__.py
@@ -0,0 +1,21 @@
+'''Python soft IOC module.'''
+import os
+
+from epicscorelibs import path
+from epicscorelibs.ioc import \
+ iocshRegisterCommon, registerRecordDeviceDriver, pdbbase
+
+# This import will also pull in the extension, which is needed
+# before we call iocshRegisterCommon
+from .imports import dbLoadDatabase
+from ._version_git import __version__
+
+# Need to do this before calling anything in device.py
+iocshRegisterCommon()
+dbLoadDatabase("base.dbd", os.path.join(path.base_path, "dbd"), None)
+dbLoadDatabase("devIocStats.dbd", os.path.dirname(__file__), None)
+
+if registerRecordDeviceDriver(pdbbase):
+ raise RuntimeError('Error registering')
+
+__all__ = ["__version__"]
diff --git a/softioc/__main__.py b/softioc/__main__.py
new file mode 100644
index 00000000..a091af43
--- /dev/null
+++ b/softioc/__main__.py
@@ -0,0 +1,22 @@
+import os
+import sys
+from argparse import ArgumentParser
+import subprocess
+
+from softioc import __version__
+
+
+def main(args=None):
+ parser = ArgumentParser()
+ parser.add_argument("--version", action="version", version=__version__)
+ parser.add_argument("script", help="The python script to run")
+ parser.add_argument(
+ "arg", help="Any arguments to pass to the script", nargs="*")
+ parsed_args = parser.parse_args(args)
+ # Execute as subprocess
+ cmd = [sys.executable, parsed_args.script, *parsed_args.arg]
+ subprocess.Popen(cmd).communicate()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/softioc/_version_git.py b/softioc/_version_git.py
new file mode 100644
index 00000000..ec811d1b
--- /dev/null
+++ b/softioc/_version_git.py
@@ -0,0 +1,97 @@
+# Compute a version number from a git repo or archive
+
+# This file is released into the public domain. Generated by:
+# versiongit-1.0 (https://github.com/dls-controls/versiongit)
+import os
+import re
+import sys
+from subprocess import STDOUT, CalledProcessError, check_output
+
+# These will be filled in if git archive is run or by setup.py cmdclasses
+GIT_REFS = "$Format:%D$"
+GIT_SHA1 = "$Format:%h$"
+
+# Git describe gives us sha1, last version-like tag, and commits since then
+CMD = "git describe --tags --dirty --always --long --match=[0-9]*[-.][0-9]*"
+
+
+def get_version_from_git(path=None):
+ """Try to parse version from git describe, fallback to git archive tags"""
+ tag, plus, suffix = "0.0", "untagged", ""
+ if not GIT_SHA1.startswith("$"):
+ # git archive or the cmdclasses below have filled in these strings
+ sha1 = GIT_SHA1
+ for ref_name in GIT_REFS.split(", "):
+ if ref_name.startswith("tag: "):
+ # git from 1.8.3 onwards labels archive tags "tag: TAGNAME"
+ tag, plus = ref_name[5:], "0"
+ else:
+ if path is None:
+ # If no path to git repo, choose the directory this file is in
+ path = os.path.dirname(os.path.abspath(__file__))
+ # output is TAG-NUM-gHEX[-dirty] or HEX[-dirty]
+ try:
+ cmd_out = check_output(CMD.split(), stderr=STDOUT, cwd=path)
+ except Exception as e:
+ sys.stderr.write("%s: %s\n" % (type(e).__name__, str(e)))
+ if isinstance(e, CalledProcessError):
+ sys.stderr.write("-> %s" % e.output.decode())
+ return "0.0+unknown", None, e
+ else:
+ out = cmd_out.decode().strip()
+ if out.endswith("-dirty"):
+ out = out[:-6]
+ suffix = ".dirty"
+ if "-" in out:
+ # There is a tag, extract it and the other pieces
+ match = re.search(r"^(.+)-(\d+)-g([0-9a-f]+)$", out)
+ tag, plus, sha1 = match.groups()
+ else:
+ # No tag, just sha1
+ sha1 = out
+ # Replace dashes in tag for dots
+ tag = tag.replace("-", ".")
+ if plus != "0" or suffix:
+ # Not on a tag, add additional info
+ tag = "%(tag)s+%(plus)s.g%(sha1)s%(suffix)s" % locals()
+ return tag, sha1, None
+
+
+__version__, git_sha1, git_error = get_version_from_git()
+
+
+def get_cmdclass(build_py=None, sdist=None):
+ """Create cmdclass dict to pass to setuptools.setup that will write a
+ _version_static.py file in our resultant sdist, wheel or egg"""
+ if build_py is None:
+ from setuptools.command.build_py import build_py
+ if sdist is None:
+ from setuptools.command.sdist import sdist
+
+ def make_version_static(base_dir, pkg):
+ vg = os.path.join(base_dir, pkg.split(".")[0], "_version_git.py")
+ if os.path.isfile(vg):
+ lines = open(vg).readlines()
+ with open(vg, "w") as f:
+ for line in lines:
+ # Replace GIT_* with static versions
+ if line.startswith("GIT_SHA1 = "):
+ f.write("GIT_SHA1 = '%s'\n" % git_sha1)
+ elif line.startswith("GIT_REFS = "):
+ f.write("GIT_REFS = 'tag: %s'\n" % __version__)
+ else:
+ f.write(line)
+
+ class BuildPy(build_py):
+ def run(self):
+ build_py.run(self)
+ for pkg in self.packages:
+ make_version_static(self.build_lib, pkg)
+
+ class Sdist(sdist):
+ def make_release_tree(self, base_dir, files):
+ sdist.make_release_tree(self, base_dir, files)
+ for pkg in self.distribution.packages:
+ make_version_static(base_dir, pkg)
+
+ return dict(build_py=BuildPy, sdist=Sdist)
diff --git a/python/access.acf b/softioc/access.acf
similarity index 100%
rename from python/access.acf
rename to softioc/access.acf
diff --git a/softioc/alarm.py b/softioc/alarm.py
new file mode 100644
index 00000000..e1366c3f
--- /dev/null
+++ b/softioc/alarm.py
@@ -0,0 +1,28 @@
+# Severity code definitions taken from EPICS alarm.h
+NO_ALARM = 0
+MINOR_ALARM = 1
+MAJOR_ALARM = 2
+INVALID_ALARM = 3
+
+# Some alarm code definitions taken from EPICS alarm.h
+READ_ALARM = 1
+WRITE_ALARM = 2
+HIHI_ALARM = 3
+HIGH_ALARM = 4
+LOLO_ALARM = 5
+LOW_ALARM = 6
+STATE_ALARM = 7
+COS_ALARM = 8
+COMM_ALARM = 9
+TIMEOUT_ALARM = 10
+HW_LIMIT_ALARM = 11
+CALC_ALARM = 12
+SCAN_ALARM = 13
+LINK_ALARM = 14
+SOFT_ALARM = 15
+BAD_SUB_ALARM = 16
+UDF_ALARM = 17
+DISABLE_ALARM = 18
+SIMM_ALARM = 19
+READ_ACCESS_ALARM = 20
+WRITE_ACCESS_ALARM = 21
diff --git a/softioc/asyncio_dispatcher.py b/softioc/asyncio_dispatcher.py
new file mode 100644
index 00000000..2ee7246f
--- /dev/null
+++ b/softioc/asyncio_dispatcher.py
@@ -0,0 +1,25 @@
+import asyncio
+import inspect
+import threading
+
+
+class AsyncioDispatcher(threading.Thread):
+ """A dispatcher for `asyncio` based IOCs. Means that `on_update` callback
+ functions can be async. Will run an Event Loop in a thread when
+ created.
+ """
+ def __init__(self):
+ super().__init__()
+ #: `asyncio` event loop that the callbacks will run under.
+ self.loop = asyncio.new_event_loop()
+ self.start()
+
+ def run(self):
+ self.loop.run_forever()
+
+ def __call__(self, func, *args):
+ async def async_wrapper():
+ ret = func(*args)
+ if inspect.isawaitable(ret):
+ await ret
+ asyncio.run_coroutine_threadsafe(async_wrapper(), self.loop)
diff --git a/python/softioc/builder.py b/softioc/builder.py
similarity index 89%
rename from python/softioc/builder.py
rename to softioc/builder.py
index 48fc1cd1..6b087e9d 100644
--- a/python/softioc/builder.py
+++ b/softioc/builder.py
@@ -1,14 +1,13 @@
-'''Support for using the builder in cooperation with the python soft ioc.'''
-
import os
import numpy
from .softioc import dbLoadDatabase
from epicsdbbuilder import *
-InitialiseDbd(os.environ['EPICS_BASE'], os.environ['EPICS_HOST_ARCH'])
-LoadDbdFile(os.path.join(os.environ['HERE'], 'dbd/device.dbd'))
-from . import pythonSoftIoc
+InitialiseDbd()
+LoadDbdFile(os.path.join(os.path.dirname(__file__), 'device.dbd'))
+
+from . import pythonSoftIoc # noqa
PythonDevice = pythonSoftIoc.PythonDevice()
@@ -30,34 +29,33 @@ def _in_record(record, name, **fields):
def aIn(name, LOPR=None, HOPR=None, **fields):
- return _in_record('ai', name,
- LOPR = LOPR, HOPR = HOPR, **fields)
+ return _in_record(
+ 'ai', name, LOPR = LOPR, HOPR = HOPR, **fields)
def aOut(name, LOPR=None, HOPR=None, **fields):
fields.setdefault('DRVL', LOPR)
fields.setdefault('DRVH', HOPR)
- return PythonDevice.ao(name,
- LOPR = LOPR, HOPR = HOPR, **fields)
+ return PythonDevice.ao(
+ name, LOPR = LOPR, HOPR = HOPR, **fields)
def boolIn(name, ZNAM=None, ONAM=None, **fields):
return _in_record('bi', name, ZNAM = ZNAM, ONAM = ONAM, **fields)
def boolOut(name, ZNAM=None, ONAM=None, **fields):
- return PythonDevice.bo(name,
- OMSL = 'supervisory',
- ZNAM = ZNAM, ONAM = ONAM, **fields)
+ return PythonDevice.bo(
+ name, OMSL = 'supervisory', ZNAM = ZNAM, ONAM = ONAM, **fields)
def longIn(name, LOPR=None, HOPR=None, EGU=None, **fields):
fields.setdefault('MDEL', -1)
- return _in_record('longin', name,
- EGU = EGU, LOPR = LOPR, HOPR = HOPR, **fields)
+ return _in_record(
+ 'longin', name, EGU = EGU, LOPR = LOPR, HOPR = HOPR, **fields)
def longOut(name, DRVL=None, DRVH=None, EGU=None, **fields):
- return PythonDevice.longout(name,
- OMSL = 'supervisory',
- DRVL = DRVL, DRVH = DRVH, EGU = EGU, **fields)
+ return PythonDevice.longout(
+ name, OMSL = 'supervisory', DRVL = DRVL, DRVH = DRVH, EGU = EGU,
+ **fields)
# Field name prefixes for mbbi/mbbo records.
diff --git a/softioc/devIocStats.dbd b/softioc/devIocStats.dbd
new file mode 120000
index 00000000..275179c0
--- /dev/null
+++ b/softioc/devIocStats.dbd
@@ -0,0 +1 @@
+../iocStats/devIocStats/devIocStats.dbd
\ No newline at end of file
diff --git a/python/device.dbd b/softioc/device.dbd
similarity index 100%
rename from python/device.dbd
rename to softioc/device.dbd
diff --git a/python/softioc/device.py b/softioc/device.py
similarity index 89%
rename from python/softioc/device.py
rename to softioc/device.py
index db044108..c91d8545 100644
--- a/python/softioc/device.py
+++ b/softioc/device.py
@@ -1,17 +1,18 @@
import os
import time
-import traceback
+import inspect
from ctypes import *
import numpy
-import cothread
-
from . import alarm
from .fields import DbfCodeToNumpy, DbrToDbfCode
-from .imports import dbLoadDatabase, recGblResetAlarms
+from .imports import dbLoadDatabase, recGblResetAlarms, db_put_field
from .device_core import DeviceSupportCore, RecordLookup
-from . import imports
+
+# This is set from softioc.iocInit
+# dispatcher(func, *args) will queue a callback to happen
+dispatcher = None
class ProcessDeviceSupportCore(DeviceSupportCore, RecordLookup):
@@ -81,10 +82,6 @@ def get(self):
class ProcessDeviceSupportOut(ProcessDeviceSupportCore):
_link_ = 'OUT'
- # Create our own cothread callback queue so that our callbacks processing
- # doesn't interfere with other callback processing.
- __Callback = cothread.cothread._Callback()
-
def __init__(self, name, **kargs):
on_update = kargs.pop('on_update', None)
on_update_name = kargs.pop('on_update_name', None)
@@ -98,7 +95,7 @@ def __init__(self, name, **kargs):
else:
self.__on_update = None
- self.__validate = kargs.pop('validate', None)
+ self.__validate = kargs.pop('validate', None)
self.__always_update = kargs.pop('always_update', False)
self._value = kargs.pop('initial_value', None)
self.__enable_write = True
@@ -135,7 +132,7 @@ def _process(self, record):
self._value = value
if self.__on_update and self.__enable_write:
- self.__Callback(self.__on_update, value)
+ dispatcher(self.__on_update, value)
return 0
@@ -204,7 +201,7 @@ def set(self, value, process=True):
else:
datatype, length, data, array = self.value_to_dbr(value)
self.__enable_write = process
- imports.db_put_field(
+ db_put_field(
_record.NAME, DbrToDbfCode[datatype], data, length)
self.__enable_write = True
@@ -215,32 +212,36 @@ def get(self):
def _Device(Base, record_type, rval=False, mlst=False, default=0):
'''Wrapper for generating simple records.'''
val_field = 'RVAL' if rval else 'VAL'
+
class GenericDevice(Base):
_record_type_ = record_type
_device_name_ = 'devPython_' + record_type
- _val_field_ = val_field
- _default_ = default
- _fields_ = ['UDF', val_field]
- if mlst: _fields_.append('MLST')
+ _val_field_ = val_field
+ _default_ = default
+ _fields_ = ['UDF', val_field]
+ if mlst:
+ _fields_.append('MLST')
GenericDevice.__name__ = record_type
return GenericDevice
-_In = ProcessDeviceSupportIn
+_In = ProcessDeviceSupportIn
_Out = ProcessDeviceSupportOut
-def _Device_In (type, **kargs):
+
+def _Device_In(type, **kargs):
return _Device(_In, type, **kargs)
+
def _Device_Out(type, rval=False, mlst=True):
return _Device(_Out, type, rval=rval, mlst=mlst, default=None)
-longin = _Device_In ('longin')
-longout = _Device_Out('longout')
-bi = _Device_In ('bi', rval=True)
-bo = _Device_Out('bo', rval=True)
-stringin = _Device_In ('stringin', mlst=False, default='')
-stringout = _Device_Out('stringout', mlst=False)
-mbbi = _Device_In ('mbbi', rval=True)
-mbbo = _Device_Out('mbbo', rval=True)
+longin = _Device_In('longin')
+longout = _Device_Out('longout')
+bi = _Device_In('bi', rval=True)
+bo = _Device_Out('bo', rval=True)
+stringin = _Device_In('stringin', mlst=False, default='')
+stringout = _Device_Out('stringout', mlst=False)
+mbbi = _Device_In('mbbi', rval=True)
+mbbo = _Device_Out('mbbo', rval=True)
NO_CONVERT = 2
@@ -256,10 +257,10 @@ def _Device_Out(type, rval=False, mlst=True):
class ai(ProcessDeviceSupportIn):
_record_type_ = 'ai'
_device_name_ = 'devPython_ai'
- _val_field_ = 'VAL'
- _default_ = 0.0
- _fields_ = ['UDF', 'VAL']
- _dset_extra_ = dset_process_linconv
+ _val_field_ = 'VAL'
+ _default_ = 0.0
+ _fields_ = ['UDF', 'VAL']
+ _dset_extra_ = dset_process_linconv
def _process(self, record):
_value = self._value
@@ -273,9 +274,9 @@ def _process(self, record):
class ao(ProcessDeviceSupportOut):
_record_type_ = 'ao'
_device_name_ = 'devPython_ao'
- _val_field_ = 'VAL'
- _fields_ = ['UDF', 'VAL', 'MLST']
- _dset_extra_ = dset_process_linconv
+ _val_field_ = 'VAL'
+ _fields_ = ['UDF', 'VAL', 'MLST']
+ _dset_extra_ = dset_process_linconv
def init_record(self, record):
self.__super.init_record(record)
@@ -306,12 +307,14 @@ def _read_value(self, record):
def _write_value(self, record, value):
value = numpy.require(value, dtype = self.dtype)
- if value.shape == (): value.shape = (1,)
+ if value.shape == ():
+ value.shape = (1,)
assert value.ndim == 1, 'Can\'t write multidimensional arrays'
nelm = record.NELM
nord = len(value)
- if nord > nelm: nord = nelm
+ if nord > nelm:
+ nord = nelm
memmove(
record.BPTR, value.ctypes.data_as(c_void_p),
self.dtype.itemsize * nord)
@@ -321,7 +324,7 @@ def _write_value(self, record, value):
class waveform(WaveformBase, ProcessDeviceSupportIn):
_record_type_ = 'waveform'
_device_name_ = 'devPython_waveform'
- _default_ = ()
+ _default_ = ()
# Because arrays are mutable values it's ever so easy to accidentially call
# set() with a value which subsequently changes. To avoid this common class
@@ -340,7 +343,8 @@ def set(self, value,
value = numpy.require(value, dtype = self.dtype)
self._value = (+value, severity, alarm, timestamp)
- if value.shape == (): value.shape = (1,)
+ if value.shape == ():
+ value.shape = (1,)
assert value.ndim == 1, 'Can\'t write multidimensional arrays'
self.trigger()
@@ -352,4 +356,4 @@ class waveform_out(WaveformBase, ProcessDeviceSupportOut):
# Ensure the .dbd file is loaded.
-dbLoadDatabase(os.path.join(os.environ['HERE'], 'dbd/device.dbd'), None, None)
+dbLoadDatabase("device.dbd", os.path.dirname(__file__), None)
diff --git a/python/softioc/device_core.py b/softioc/device_core.py
similarity index 99%
rename from python/softioc/device_core.py
rename to softioc/device_core.py
index 9024fa59..ab2ce92f 100644
--- a/python/softioc/device_core.py
+++ b/softioc/device_core.py
@@ -28,7 +28,8 @@ def __init__(cls, name, bases, dict):
# Binds cls.__super_cls().method to the appropriate superclass
# class method. Unfortunately the .__super form doesn't work
# with class methods, only instance methods.
- setattr(cls, '_%s__super_cls' % name,
+ setattr(
+ cls, '_%s__super_cls' % name,
classmethod(lambda child: super(cls, child)))
# Finally call the class initialisatio nmethod.
cls.__init_class__()
@@ -104,7 +105,7 @@ def __init_class__(cls):
# Now construct the device support table. We build on the common
# base and add any device specific definitions here.
class DSET(DSET_BASE):
- _fields_ = cls._dset_extra_[0]
+ _fields_ = cls._dset_extra_[0]
_record_offsets_ = cls._dset_extra_[1]
dset = DSET(
number = len(DSET_BASE._fields_) + len(DSET._fields_) - 1)
diff --git a/softIocApp/PythonSupport.c b/softioc/extension.c
similarity index 62%
rename from softIocApp/PythonSupport.c
rename to softioc/extension.c
index 7a3dfb37..c04ff630 100644
--- a/softIocApp/PythonSupport.c
+++ b/softioc/extension.c
@@ -1,6 +1,5 @@
-/* See note in softMain.c about these #undefs. */
-#undef _POSIX_C_SOURCE
-#undef _XOPEN_SOURCE
+
+#define PY_SSIZE_T_CLEAN
#include
#include
@@ -10,39 +9,32 @@
#include
#include
#include
-
-
-/* The interface to the caput event callback has changed as of EPICS 3.15, and
- * we need to compile as appropriate. */
-#define BASE_3_15 (EPICS_VERSION * 100 + EPICS_REVISION >= 315)
-#if BASE_3_15
#include
-#endif
-
-
-
-/* Returns the EPICS_BASE path used to build this IOC. */
-const char *get_EPICS_BASE(void)
-{
- return EPICS_BASE; // Passed as #define from Makefile
-}
-
+#include
+#include
/* In Python3 this function has been renamed. */
#if PY_MAJOR_VERSION >= 3
#define PyInt_FromLong(value) PyLong_FromLong(value)
#endif
+/* Reference stealing version of PyDict_SetItemString */
+static void set_dict_item_steal(
+ PyObject *dict, const char *name, PyObject *py_value)
+{
+ PyDict_SetItemString(dict, name, py_value);
+ Py_DECREF(py_value);
+}
+
/* Helper for function below. */
#define ADD_ENUM(dict, name) \
- PyDict_SetItemString(dict, #name, PyInt_FromLong(name))
+ set_dict_item_steal(dict, #name, PyInt_FromLong(name))
/* Alas, EPICS has changed the numerical assignments of the DBF_ enums between
* versions, so to avoid unpleasant surprises, we compute thes values here in C
* and pass them back to the Python layer. */
-PyObject *get_DBF_values(void)
+static PyObject *get_DBF_values(PyObject *self, PyObject *args)
{
- PyGILState_STATE gstate = PyGILState_Ensure();
PyObject *dict = PyDict_New();
ADD_ENUM(dict, DBF_STRING);
ADD_ENUM(dict, DBF_CHAR);
@@ -60,58 +52,65 @@ PyObject *get_DBF_values(void)
ADD_ENUM(dict, DBF_OUTLINK);
ADD_ENUM(dict, DBF_FWDLINK);
ADD_ENUM(dict, DBF_NOACCESS);
- PyGILState_Release(gstate);
return dict;
}
/* Given an array of field names, this routine looks up each field name in
* the EPICS database and returns the corresponding field offset. */
-
-void get_field_offsets(
- const char * record_type, const char * field_names[], int field_count,
- short field_offset[], short field_size[], short field_type[])
+static PyObject *get_field_offsets(PyObject *self, PyObject *args)
{
- int status;
+ const char *record_type;
+ if (!PyArg_ParseTuple(args, "s", &record_type))
+ return NULL;
+
DBENTRY dbentry;
dbInitEntry(pdbbase, &dbentry);
- status = dbFindRecordType(&dbentry, record_type);
+ int status = dbFindRecordType(&dbentry, record_type);
if (status != 0)
printf("Unable to find record type \"%s\" (error %d)\n",
record_type, status);
else
status = dbFirstField(&dbentry, 0);
+
+ PyObject *dict = PyDict_New();
while (status == 0)
{
const char * field_name = dbGetFieldName(&dbentry);
- int i;
- for (i = 0; i < field_count; i ++)
- {
- if (strcmp(field_names[i], field_name) == 0)
- {
- field_offset[i] = dbentry.pflddes->offset;
- field_size[i] = dbentry.pflddes->size;
- field_type[i] = dbentry.pflddes->field_type;
- }
- }
+ PyObject *ost = Py_BuildValue("iii",
+ dbentry.pflddes->offset,
+ dbentry.pflddes->size,
+ dbentry.pflddes->field_type);
+ set_dict_item_steal(dict, field_name, ost);
status = dbNextField(&dbentry, 0);
}
dbFinishEntry(&dbentry);
+ return dict;
}
/* Updates PV field with integrated db lookup. Safer to do this in C as we need
* an intermediate copy of the dbAddr structure, which changes size between
* EPICS releases. */
-int db_put_field(const char *name, short dbrType, void *pbuffer, long length)
+static PyObject *db_put_field(PyObject *self, PyObject *args)
{
+ const char *name;
+ short dbrType;
+ void *pbuffer;
+ long length;
+ if (!PyArg_ParseTuple(args, "shnl", &name, &dbrType, &pbuffer, &length))
+ return NULL;
+
struct dbAddr dbAddr;
- int rc = dbNameToAddr(name, &dbAddr);
- if (rc == 0)
- rc = dbPutField(&dbAddr, dbrType, pbuffer, length);
- return rc;
+ if (dbNameToAddr(name, &dbAddr))
+ return PyErr_Format(
+ PyExc_RuntimeError, "dbNameToAddr failed for %s", name);
+ if (dbPutField(&dbAddr, dbrType, pbuffer, length))
+ return PyErr_Format(
+ PyExc_RuntimeError, "dbPutField failed for %s", name);
+ Py_RETURN_NONE;
}
@@ -178,12 +177,8 @@ static void PrintValue(struct formatted *formatted)
void EpicsPvPutHook(struct asTrapWriteMessage *pmessage, int after)
{
-#if BASE_3_15
struct dbChannel *pchan = pmessage->serverSpecific;
dbAddr *dbaddr = &pchan->addr;
-#else
- dbAddr *dbaddr = pmessage->serverSpecific;
-#endif
struct formatted *value = FormatValue(dbaddr);
if (after)
@@ -205,3 +200,53 @@ void EpicsPvPutHook(struct asTrapWriteMessage *pmessage, int after)
/* Just save the old value for logging after. */
pmessage->userPvt = value;
}
+
+
+static PyObject *install_pv_logging(PyObject *self, PyObject *args)
+{
+ const char *acf_file;
+
+ if (!PyArg_ParseTuple(args, "s", &acf_file))
+ return NULL;
+
+ asSetFilename(acf_file);
+ asTrapWriteRegisterListener(EpicsPvPutHook);
+ Py_RETURN_NONE;
+}
+
+static struct PyMethodDef softioc_methods[] = {
+ {"get_DBF_values", get_DBF_values, METH_VARARGS,
+ "Get a map of DBF names to values"},
+ {"get_field_offsets", get_field_offsets, METH_VARARGS,
+ "Get offset, size and type for each record field"},
+ {"db_put_field", db_put_field, METH_VARARGS,
+ "Put a database field to a value"},
+ {"install_pv_logging", install_pv_logging, METH_VARARGS,
+ "Install caput logging to stdout"},
+ {NULL, NULL, 0, NULL} /* Sentinel */
+};
+
+#if PY_MAJOR_VERSION >= 3
+static struct PyModuleDef softioc_module = {
+ PyModuleDef_HEAD_INIT,
+ "softioc._extension",
+ NULL,
+ -1,
+ softioc_methods,
+};
+#endif
+
+#if PY_MAJOR_VERSION >= 3
+# define PyMOD(NAME) PyObject* PyInit_##NAME (void)
+#else
+# define PyMOD(NAME) void init##NAME (void)
+#endif
+
+PyMOD(_extension)
+{
+#if PY_MAJOR_VERSION >= 3
+ return PyModule_Create(&softioc_module);
+#else
+ Py_InitModule("softioc._extension", softioc_methods);
+#endif
+}
diff --git a/python/softioc/fields.py b/softioc/fields.py
similarity index 61%
rename from python/softioc/fields.py
rename to softioc/fields.py
index 6a598773..103b74ab 100644
--- a/python/softioc/fields.py
+++ b/softioc/fields.py
@@ -7,8 +7,8 @@
from .imports import get_field_offsets, get_DBF_values
import numpy
-from cothread.dbr import *
-from cothread.dbr import ca_timestamp, EPICS_epoch
+from epicscorelibs.ca.dbr import *
+from epicscorelibs.ca.dbr import ca_timestamp, EPICS_epoch
# Pick up the DBF_ definitions from the C helper layer. This is safer than
@@ -26,43 +26,43 @@
# downstream for no good purpose -- for example, enums are of type DBF_ULONG,
# but this cannot be written with caput.)
DbfCodeToCtypes = {
- DBF_CHAR : c_byte,
- DBF_UCHAR : c_ubyte,
- DBF_SHORT : c_int16,
- DBF_USHORT : c_uint16,
- DBF_LONG : c_int32,
- DBF_ULONG : c_int32, # Should be uint32, but causes trouble later.
- DBF_FLOAT : c_float,
- DBF_DOUBLE : c_double,
- DBF_ENUM : c_uint16,
- DBF_MENU : c_uint16,
- DBF_INLINK : c_char_p,
- DBF_OUTLINK : c_char_p,
- DBF_NOACCESS : c_void_p,
+ DBF_CHAR: c_byte,
+ DBF_UCHAR: c_ubyte,
+ DBF_SHORT: c_int16,
+ DBF_USHORT: c_uint16,
+ DBF_LONG: c_int32,
+ DBF_ULONG: c_int32, # Should be uint32, but causes trouble later.
+ DBF_FLOAT: c_float,
+ DBF_DOUBLE: c_double,
+ DBF_ENUM: c_uint16,
+ DBF_MENU: c_uint16,
+ DBF_INLINK: c_char_p,
+ DBF_OUTLINK: c_char_p,
+ DBF_NOACCESS: c_void_p,
}
# Mapping for record field type to numpy type.
DbfCodeToNumpy = {
- DBF_STRING : numpy.dtype('S40'),
- DBF_CHAR : numpy.dtype('int8'),
- DBF_UCHAR : numpy.dtype('uint8'),
- DBF_SHORT : numpy.dtype('int16'),
- DBF_USHORT : numpy.dtype('uint16'),
- DBF_LONG : numpy.dtype('int32'),
- DBF_ULONG : numpy.dtype('uint32'),
- DBF_FLOAT : numpy.dtype('float32'),
- DBF_DOUBLE : numpy.dtype('float64'),
+ DBF_STRING: numpy.dtype('S40'),
+ DBF_CHAR: numpy.dtype('int8'),
+ DBF_UCHAR: numpy.dtype('uint8'),
+ DBF_SHORT: numpy.dtype('int16'),
+ DBF_USHORT: numpy.dtype('uint16'),
+ DBF_LONG: numpy.dtype('int32'),
+ DBF_ULONG: numpy.dtype('uint32'),
+ DBF_FLOAT: numpy.dtype('float32'),
+ DBF_DOUBLE: numpy.dtype('float64'),
}
# Mapping from basic DBR_ codes to DBF_ values
DbrToDbfCode = {
- DBR_STRING : DBF_STRING,
- DBR_SHORT : DBF_SHORT,
- DBR_FLOAT : DBF_FLOAT,
- DBR_ENUM : DBF_ENUM,
- DBR_CHAR : DBF_CHAR,
- DBR_LONG : DBF_LONG,
- DBR_DOUBLE : DBF_DOUBLE
+ DBR_STRING: DBF_STRING,
+ DBR_SHORT: DBF_SHORT,
+ DBR_FLOAT: DBF_FLOAT,
+ DBR_ENUM: DBF_ENUM,
+ DBR_CHAR: DBF_CHAR,
+ DBR_LONG: DBF_LONG,
+ DBR_DOUBLE: DBF_DOUBLE
}
@@ -82,35 +82,10 @@ class RecordFactory(object):
def __init__(self, record_type, fields):
'''Uses the EPICS static database to discover the offset in the record
type and the size of each of the specified fields.'''
- length = len(fields)
- field_name_strings = [
- create_string_buffer(field.encode())
- for field in fields]
-
- field_names = (c_void_p * len(field_name_strings))()
- for i, field in enumerate(field_name_strings):
- field_names[i] = addressof(field)
-
- field_offsets = numpy.empty(length, dtype = numpy.int16)
- field_sizes = numpy.zeros(length, dtype = numpy.int16)
- field_types = numpy.empty(length, dtype = numpy.int16)
-
- get_field_offsets(
- record_type, field_names, length,
- field_offsets.ctypes.data_as(c_void_p),
- field_sizes.ctypes.data_as(c_void_p),
- field_types.ctypes.data_as(c_void_p))
- assert field_sizes.all(), 'At least one field seems to be missing'
-
- # The following rather convoluted expression converts the separate
- # arrays of field names and attributes into a dictionary looking up
- # each field and returning the appropriate list of attributes.
- # One final adjustment needed is that all the numpy.int16 values
- # need to be converted back into regular integers to ensure good
- # processing downstream.
- self.fields = dict(zip(fields, zip(
- *map(lambda l: map(int, l),
- (field_offsets, field_sizes, field_types)))))
+ self.fields = get_field_offsets(record_type)
+ missing = set(fields) - set(self.fields)
+ assert not missing, \
+ "Fields not supported by %s: %s" % (record_type, sorted(missing))
def __call__(self, record):
'''Converts a raw pointer to a record structure into a _Record object
diff --git a/softioc/imports.py b/softioc/imports.py
new file mode 100644
index 00000000..fc4e0e3a
--- /dev/null
+++ b/softioc/imports.py
@@ -0,0 +1,111 @@
+'''External DLL imports used for implementing Python EPICS device support.
+'''
+
+import os
+import os.path
+import sys
+from ctypes import *
+
+# Use the libs with the right windows flags
+from epicscorelibs.ioc import dbCore, Com
+
+from . import _extension
+
+
+# These are in the extension
+def get_DBF_values():
+ """Return {DBF_name: DBF_int_value} mapping"""
+ return _extension.get_DBF_values()
+
+def get_field_offsets(record_type):
+ """Return {field_name: (offset, size, field_type)}"""
+ return _extension.get_field_offsets(record_type)
+
+def db_put_field(name, dbr_type, pbuffer, length):
+ """Put field where pbuffer is void* pointer. Returns RC"""
+ return _extension.db_put_field(name, dbr_type, pbuffer, length)
+
+def install_pv_logging(acf_file):
+ """Install pv logging"""
+ _extension.install_pv_logging(acf_file)
+
+def expect_success(status, function, args):
+ assert status == 0, 'Expected success'
+
+def expect_true(status, function, args):
+ assert status, 'Expected True'
+
+
+if sys.version_info < (3,):
+ # Python 2
+ auto_encode = c_char_p
+ def auto_decode(result, func, args):
+ return result
+
+else:
+ # Python 3
+
+ # Encode all strings to c_char_p
+ class auto_encode(c_char_p):
+ encoded = []
+ @classmethod
+ def from_param(cls, value):
+ if value is None:
+ return value
+ else:
+ return value.encode()
+
+ def auto_decode(result, func, args):
+ return result.decode()
+
+
+# int registryDeviceSupportAdd(
+# const char *name,const struct dset *pdset);
+#
+# Registers device support.
+registryDeviceSupportAdd = dbCore.registryDeviceSupportAdd
+registryDeviceSupportAdd.argtypes = (c_char_p, c_void_p)
+registryDeviceSupportAdd.errcheck = expect_true
+
+
+# void scanIoInit(IOSCANPVT *)
+# void scanIoRequest(IOSCANPVT *)
+#
+# Initialise and trigger I/O Intr processing structure.
+IOSCANPVT = c_void_p
+
+scanIoInit = dbCore.scanIoInit
+scanIoInit.argtypes = (IOSCANPVT,)
+scanIoInit.restype = None
+
+scanIoRequest = dbCore.scanIoRequest
+scanIoRequest.argtypes = (IOSCANPVT,)
+scanIoRequest.restype = None
+
+dbLoadDatabase = dbCore.dbLoadDatabase
+dbLoadDatabase.argtypes = (auto_encode, auto_encode, auto_encode)
+dbLoadDatabase.errcheck = expect_success
+
+
+# unsigned short recGblResetAlarms(void *precord)
+#
+# Raises event processing if any alarm status has changed, and resets NSTA
+# and NSEV fields for further processing.
+recGblResetAlarms = dbCore.recGblResetAlarms
+recGblResetAlarms.argtypes = (c_void_p,)
+recGblResetAlarms.restype = c_short
+
+iocInit = dbCore.iocInit
+iocInit.argtypes = ()
+
+epicsExit = Com.epicsExit
+epicsExit.argtypes = ()
+
+
+__all__ = [
+ 'get_field_offsets',
+ 'registryDeviceSupportAdd',
+ 'IOSCANPVT', 'scanIoRequest', 'scanIoInit',
+ 'dbLoadDatabase',
+ 'recGblResetAlarms',
+]
diff --git a/softioc/iocStatsDb b/softioc/iocStatsDb
new file mode 120000
index 00000000..c17da227
--- /dev/null
+++ b/softioc/iocStatsDb
@@ -0,0 +1 @@
+../iocStats/iocAdmin/Db
\ No newline at end of file
diff --git a/softioc/pvlog.py b/softioc/pvlog.py
new file mode 100644
index 00000000..6a4fb9aa
--- /dev/null
+++ b/softioc/pvlog.py
@@ -0,0 +1,9 @@
+# Support for PV put logging
+#
+# Importing this module configures PV put logging
+
+import os
+from . import imports
+
+imports.install_pv_logging(
+ os.path.join(os.path.dirname(__file__), 'access.acf'))
diff --git a/python/softioc/pythonSoftIoc.py b/softioc/pythonSoftIoc.py
similarity index 97%
rename from python/softioc/pythonSoftIoc.py
rename to softioc/pythonSoftIoc.py
index 49200588..84880c3e 100644
--- a/python/softioc/pythonSoftIoc.py
+++ b/softioc/pythonSoftIoc.py
@@ -87,7 +87,7 @@ def __init_class__(cls):
'ai', 'bi', 'longin', 'mbbi', 'stringin',
'ao', 'bo', 'longout', 'mbbo', 'stringout', 'waveform']:
builder = getattr(epicsdbbuilder.records, name)
- record = getattr(device, name)
+ record = getattr(device, name)
setattr(cls, name, cls.makeRecord(builder, record))
cls.waveform_out = cls.makeRecord(
epicsdbbuilder.records.waveform, device.waveform_out,
@@ -96,7 +96,7 @@ def __init_class__(cls):
class makeRecord:
def __init__(self, builder, record, dtyp = 'Python'):
self.builder = builder
- self.record = record
+ self.record = record
self.dtyp = dtyp
def __call__(self, name, **fields):
diff --git a/softioc/softioc.py b/softioc/softioc.py
new file mode 100644
index 00000000..cf09bc8e
--- /dev/null
+++ b/softioc/softioc.py
@@ -0,0 +1,300 @@
+import os
+import sys
+from ctypes import *
+
+from epicsdbbuilder.recordset import recordset
+
+from . import imports, device
+
+__all__ = ['dbLoadDatabase', 'iocInit', 'interactive_ioc']
+
+
+epicsExit = imports.epicsExit
+
+
+def iocInit(dispatcher=None):
+ """This must be called exactly once after loading all EPICS database files.
+ After this point the EPICS IOC is running and serving PVs.
+
+ Args:
+ dispatcher: A callable with signature ``dispatcher(func, *args)``. Will
+ be called in response to caput on a record. If not supplied use
+ `cothread` as a dispatcher.
+
+ See Also:
+ `softioc.asyncio_dispatcher` is a dispatcher for `asyncio` applications
+ """
+ if dispatcher is None:
+ # Fallback to cothread
+ import cothread
+ # Create our own cothread callback queue so that our callbacks
+ # processing doesn't interfere with other callback processing.
+ dispatcher = cothread.cothread._Callback()
+ # Set the dispatcher for record processing callbacks
+ device.dispatcher = dispatcher
+ imports.iocInit()
+
+
+def safeEpicsExit():
+ '''Calls epicsExit() after ensuring Python exit handlers called.'''
+ if hasattr(sys, 'exitfunc'):
+ try:
+ # Calling epicsExit() will bypass any atexit exit handlers, so call
+ # them explicitly now.
+ sys.exitfunc()
+ finally:
+ # Make sure we don't try the exit handlers more than once!
+ del sys.exitfunc
+ epicsExit()
+
+# The following identifiers will be exported to interactive shell.
+command_names = []
+
+
+# IOC Test facilities
+def ExportTest(name, argtypes, defaults=(), description='no description yet',
+ lib=imports.dbCore):
+ f = getattr(lib, name)
+ f.argtypes = argtypes
+ f.restype = None
+
+ length = len(argtypes)
+
+ def call_f(*args):
+ missing = length - len(args)
+ if missing > 0:
+ # Add in the missing arguments from the given defaults
+ args = args + defaults[-missing:]
+ f(*args)
+
+ call_f.__doc__ = description
+ call_f.__name__ = name
+ globals()[name] = call_f
+ command_names.append(name)
+
+
+auto_encode = imports.auto_encode
+
+
+ExportTest('dba', (auto_encode,), (), '''\
+dba(field)
+
+Prints value of each field in dbAddr structure associated with field.''')
+
+ExportTest('dbl', (auto_encode, auto_encode,), ('', ''), '''\
+dbl(pattern='', fields='')
+
+Prints the names of records in the database matching pattern. If a (space
+separated) list of fields is also given then the values of the fields are also
+printed.''')
+
+ExportTest('dbnr', (c_int,), (0,), '''\
+dbnr(all=0)
+
+Print number of records of each record type.''')
+
+ExportTest('dbgrep', (auto_encode,), (), '''\
+dbgrep(pattern)
+
+Lists all record names that match the pattern. * matches any number of
+characters in a record name.''')
+
+ExportTest('dbgf', (auto_encode,), (), '''\
+dbgf(field)
+
+Prints field type and value.''')
+
+ExportTest('dbpf', (auto_encode, auto_encode,), (), '''\
+dbpf(field, value)
+
+Writes the given value into the field.''')
+
+ExportTest('dbpr', (auto_encode, c_int,), (0,), '''\
+dbpr(record, interest=0)
+
+Prints all the fields in record up to the indicated interest level:
+
+= ========================================================
+0 Application fields which change during record processing
+1 Application fields which are fixed during processing
+2 System developer fields of major interest
+3 System developer fields of minor interest
+4 All other fields
+= ========================================================''')
+
+ExportTest('dbtr', (auto_encode,), (), '''\
+dbtr(record)
+
+Tests processing of the specified record.''')
+
+ExportTest('dbtgf', (auto_encode,), (), '''\
+dbtgf(field_name)
+
+This performs a dbNameToAddr and then calls dbGetField with all possible request
+types and options. It prints the results of each call. This routine is of most
+interest to system developers for testing database access.''')
+
+ExportTest('dbtpf', (auto_encode, auto_encode,), (), '''\
+dbtpf(field_name, value)
+
+This command performs a dbNameToAddr, then calls dbPutField, followed by dbgf
+for each possible request type. This routine is of interest to system developers
+for testing database access.''')
+
+ExportTest('dbtpn', (auto_encode, auto_encode,), (), '''\
+dbtpn(field, value)
+
+This command performs a dbProcessNotify request. If a non-null value argument
+string is provided it issues a putProcessRequest to the named record; if no
+value is provided it issues a processGetRequest. This routine is mainly of
+interest to system developers for testing database access.''')
+
+ExportTest('dbior', (auto_encode, c_int,), ('', 0,), '''\
+dbior(driver='', interest=0)
+
+Prints driver reports for the selected driver (or all drivers if driver is
+omitted) at the given interest level.''')
+
+ExportTest('dbhcr', (), (), '''\
+dbhcr()
+
+Prints hardware configuration report.''')
+
+ExportTest('gft', (auto_encode,), (), '''\
+gft(field)
+
+Get Field Test for old database access''')
+
+ExportTest('pft', (auto_encode,), (), '''\
+pft(field, value)
+
+Put Field Test for old database access''')
+
+ExportTest('tpn', (auto_encode, auto_encode,), (), '''\
+tpn(field, value)
+
+Test Process Notify for old database access''')
+
+ExportTest('dblsr', (auto_encode, c_int,), (), '''\
+dblsr(recordname, level)
+
+This command generates a report showing the lock set to which each record
+belongs. If recordname is 0, "", or "*" all records are shown, otherwise only
+records in the same lock set as recordname are shown.
+
+level can have the following values:
+
+= =======================================================
+0 Show lock set information only
+1 Show each record in the lock set
+2 Show each record and all database links in the lock set
+= =======================================================''')
+
+ExportTest('dbLockShowLocked', (c_int,), (), '''\
+dbLockShowLocked(level)
+
+This command generates a report showing all locked locksets, the records they
+contain, the lockset state and the thread that currently owns the lockset. The
+level argument is passed to epicsMutexShow to adjust the information reported
+about each locked epicsMutex.''')
+
+ExportTest('scanppl', (c_double,), (0.0,), '''\
+scanppl(rate=0.0)
+
+Prints all records with the selected scan rate (or all if rate=0).''')
+
+ExportTest('scanpel', (c_int,), (0,), '''\
+scanpel(event=0)
+
+Prints all records with selected event number (or all if event=0).''')
+
+ExportTest('scanpiol', (), (), '''\
+scanpiol()
+
+Prints all records in the I/O event scan lists.''')
+
+ExportTest('generalTimeReport', (c_int,), (0,), '''\
+generalTimeReport(int level)
+
+This routine displays the time providers and their priority levels that have
+registered with the General Time subsystem for both current and event times. At
+level 1 it also shows the current time as obtained from each provider.''',
+ lib=imports.Com)
+
+ExportTest('eltc', (c_int,), (), '''\
+eltc(noYes)
+
+TThis determines if error messages are displayed on the IOC console. 0 means no
+and any other value means yes.''',
+ lib=imports.Com)
+
+
+# Hacked up exit object so that when soft IOC framework sends us an exit command
+# we actually exit.
+class Exiter:
+ def __repr__(self):
+ safeEpicsExit()
+ def __call__(self):
+ safeEpicsExit()
+
+exit = Exiter()
+command_names.append('exit')
+
+
+def dbLoadDatabase(database, path = None, substitutions = None):
+ '''Loads a database file and applies any given substitutions.'''
+ imports.dbLoadDatabase(database, path, substitutions)
+
+def _add_records_from_file(dir, file, macros):
+ # This is very naive, for instance macros are added to but never removed,
+ # but it works well enough for devIocStats
+ with open(os.path.join(dir, file)) as f:
+ for line in f.readlines():
+ line = line.rstrip()
+ if line.startswith('substitute'):
+ # substitute "QUEUE=scanOnce, QUEUE_CAPS=SCANONCE
+ for sub in line.split('"')[1].split(','):
+ k, v = sub.split('=')
+ macros[k.strip()] = v.strip()
+ elif line.startswith('include'):
+ # include "iocQueue.db"
+ _add_records_from_file(dir, line.split('"')[1], macros)
+ else:
+ # A record line
+ for k, v in macros.items():
+ line = line.replace('$(%s)' % k, v)
+ recordset.AddBodyLine(line)
+
+
+def devIocStats(ioc_name):
+ '''This will load a template for the devIocStats library with the specified
+ IOC name. This should be called before `iocInit`'''
+ macros = dict(IOCNAME=ioc_name, TODFORMAT='%m/%d/%Y %H:%M:%S')
+ iocstats_dir = os.path.join(os.path.dirname(__file__), 'iocStatsDb')
+ _add_records_from_file(iocstats_dir, 'ioc.template', macros)
+
+
+def interactive_ioc(context = {}, call_exit = True):
+ '''Fires up the interactive IOC prompt with the given context.
+
+ Args:
+ context: A dictionary of values that will be made available to the
+ interactive Python shell together with a number of EPICS test
+ functions
+ call_exit: If `True`, the IOC will be terminated by calling epicsExit
+ which means that `interactive_ioc` will not return
+ '''
+ # Add all our commands to the given context.
+ exports = dict((key, globals()[key]) for key in command_names)
+ import code
+
+ if sys.version_info < (3, 6):
+ interact_args = {}
+ else:
+ # This suppresses irritating exit message introduced by Python3. Alas,
+ # this option is only available in Python 3.6!
+ interact_args = dict(exitmsg = '')
+ code.interact(local = dict(exports, **context), **interact_args)
+
+ if call_exit:
+ safeEpicsExit()
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 00000000..382e0951
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,5 @@
+import sys
+
+if sys.version_info < (3,):
+ # Python2 has no asyncio, so ignore these tests
+ collect_ignore = ["test_asyncio.py", "sim_asyncio_ioc.py"]
diff --git a/tests/expected_records.db b/tests/expected_records.db
new file mode 100644
index 00000000..53c7c1ea
--- /dev/null
+++ b/tests/expected_records.db
@@ -0,0 +1,715 @@
+# This file was automatically generated on Thu 29 Apr 2021 14:11:13 BST.
+#
+# *** Please do not edit this file: edit the source file instead. ***
+#
+
+# Used by Channel Access Security to determine access to this IOC.
+record(mbbo, "TS-DI-TEST-01:ACCESS")
+{
+ field(DESC, "TS-DI-TEST-01 Acc Mode")
+ field(PINI, "YES")
+ field(ZRST, "Running")
+ field(ZRSV, "NO_ALARM")
+ field(ONST, "Maintenance")
+ field(ONSV, "MINOR")
+ field(TWST, "Test")
+ field(TWSV, "MINOR")
+ field(THST, "OFFLINE")
+ field(THSV, "MAJOR")
+ info(autosaveFields, "VAL")
+}
+record(stringin, "TS-DI-TEST-01:STARTTOD")
+{
+ field(DESC, "Time and date of startup")
+ field(DTYP, "Soft Timestamp")
+ field(PINI, "YES")
+ field(INP, "@%m/%d/%Y %H:%M:%S")
+}
+record(stringin, "TS-DI-TEST-01:TOD")
+{
+ field(DESC, "Current time and date")
+ field(DTYP, "Soft Timestamp")
+ field(SCAN, "1 second")
+ field(INP, "@%m/%d/%Y %H:%M:%S")
+}
+record(calcout, "TS-DI-TEST-01:HEARTBEAT")
+{
+ field(DESC, "1 Hz counter since startup")
+ field(CALC, "(A<2147483647)?A+1:1")
+ field(SCAN, "1 second")
+ field(INPA, "TS-DI-TEST-01:HEARTBEAT")
+}
+# if autosave is working, START_CNT creates a running count of
+# number of times the IOC was started.
+record(calcout, "TS-DI-TEST-01:START_CNT")
+{
+ field(DESC, "Increments at startup")
+ field(CALC, "A+1")
+ field(PINI, "YES")
+ field(INPA, "TS-DI-TEST-01:START_CNT")
+ info(autosaveFields_pass0, "VAL")
+}
+#
+# Using an existing internal set of subroutines, this
+# PV updates the Access Security mechanism dynamically.
+# The .acf file is re-read.
+#
+record( sub, "TS-DI-TEST-01:READACF")
+{
+ field( DESC, "TS-DI-TEST-01 ACF Update")
+ field( INAM, "asSubInit")
+ field( SNAM, "asSubProcess")
+ field( BRSV, "INVALID")
+}
+record(sub, "TS-DI-TEST-01:SYSRESET")
+{
+ alias("TS-DI-TEST-01:SysReset")
+ field(DESC, "IOC Restart" )
+ field(SNAM, "rebootProc")
+ field(BRSV,"INVALID")
+ field(L,"1")
+}
+
+record(ai, "TS-DI-TEST-01:CA_CLNT_CNT") {
+ field(DESC, "Number of CA Clients")
+ field(SCAN, "I/O Intr")
+ field(DTYP, "IOC stats")
+ field(INP, "@ca_clients")
+ field(HOPR, "200")
+ field(HIHI, "175")
+ field(HIGH, "100")
+ field(HHSV, "MAJOR")
+ field(HSV, "MINOR")
+ info(autosaveFields_pass0, "HOPR LOPR HIHI HIGH LOW LOLO HHSV HSV LSV LLSV")
+}
+
+record(ai, "TS-DI-TEST-01:CA_CONN_CNT") {
+ field(DESC, "Number of CA Connections")
+ field(SCAN, "I/O Intr")
+ field(DTYP, "IOC stats")
+ field(INP, "@ca_connections")
+ field(HOPR, "5000")
+ field(HIHI, "4500")
+ field(HIGH, "4000")
+ field(HHSV, "MAJOR")
+ field(HSV, "MINOR")
+ info(autosaveFields_pass0, "HOPR LOPR HIHI HIGH LOW LOLO HHSV HSV LSV LLSV")
+}
+
+record(ai, "TS-DI-TEST-01:RECORD_CNT") {
+ field(DESC, "Number of Records")
+ field(PINI, "YES")
+ field(DTYP, "IOC stats")
+ field(INP, "@records")
+}
+
+record(ai, "TS-DI-TEST-01:FD_MAX") {
+ field(DESC, "Max File Descriptors")
+ field(PINI, "YES")
+ field(DTYP, "IOC stats")
+ field(INP, "@maxfd")
+}
+
+record(ai, "TS-DI-TEST-01:FD_CNT") {
+ field(DESC, "Allocated File Descriptors")
+ field(SCAN, "I/O Intr")
+ field(DTYP, "IOC stats")
+ field(FLNK, "TS-DI-TEST-01:FD_FREE PP MS")
+ field(INP, "@fd")
+}
+
+record(calc, "TS-DI-TEST-01:FD_FREE") {
+ field(DESC, "Available FDs")
+ field(CALC, "B>0?B-A:C")
+ field(INPA, "TS-DI-TEST-01:FD_CNT NPP MS")
+ field(INPB, "TS-DI-TEST-01:FD_MAX NPP MS")
+ field(INPC, "1000")
+ field(HOPR, "150")
+ field(LOLO, "5")
+ field(LOW, "20")
+ field(LLSV, "MAJOR")
+ field(LSV, "MINOR")
+ info(autosaveFields_pass0, "HOPR LOPR LOW LOLO LSV LLSV")
+}
+
+record(ai, "TS-DI-TEST-01:SYS_CPU_LOAD") {
+ field(DESC, "System CPU Load")
+ field(SCAN, "I/O Intr")
+ field(DTYP, "IOC stats")
+ field(INP, "@sys_cpuload")
+ field(EGU, "%")
+ field(PREC, "1")
+ field(HOPR, "100")
+ field(HIHI, "80")
+ field(HIGH, "70")
+ field(HHSV, "MAJOR")
+ field(HSV, "MINOR")
+ info(autosaveFields_pass0, "HOPR LOPR HIHI HIGH LOW LOLO HHSV HSV LSV LLSV")
+}
+
+record(ai, "TS-DI-TEST-01:IOC_CPU_LOAD") {
+ alias("TS-DI-TEST-01:LOAD")
+ field(DESC, "IOC CPU Load")
+ field(SCAN, "I/O Intr")
+ field(DTYP, "IOC stats")
+ field(INP, "@ioc_cpuload")
+ field(EGU, "%")
+ field(PREC, "1")
+ field(HOPR, "100")
+ field(HIHI, "80")
+ field(HIGH, "70")
+ field(HHSV, "MAJOR")
+ field(HSV, "MINOR")
+ info(autosaveFields_pass0, "HOPR LOPR HIHI HIGH LOW LOLO HHSV HSV LSV LLSV")
+}
+
+record(ai, "TS-DI-TEST-01:CPU_CNT") {
+ field(DESC, "Number of CPUs")
+ field(DTYP, "IOC stats")
+ field(INP, "@no_of_cpus")
+ field(PINI, "YES")
+}
+
+record(ai, "TS-DI-TEST-01:SUSP_TASK_CNT") {
+ field(DESC, "Number Suspended Tasks")
+ field(SCAN, "I/O Intr")
+ field(DTYP, "IOC stats")
+ field(INP, "@suspended_tasks")
+ field(HIHI, "1")
+ field(HHSV, "MAJOR")
+ info(autosaveFields_pass0, "HOPR LOPR HIHI HIGH LOW LOLO HHSV HSV LSV LLSV")
+}
+
+record(ai, "TS-DI-TEST-01:MEM_USED") {
+ field(DESC, "Allocated Memory")
+ field(SCAN, "I/O Intr")
+ field(DTYP, "IOC stats")
+ field(INP, "@allocated_bytes")
+ field(EGU, "byte")
+}
+
+record(ai, "TS-DI-TEST-01:MEM_FREE") {
+ field(DESC, "Free Memory")
+ field(SCAN, "I/O Intr")
+ field(DTYP, "IOC stats")
+ field(INP, "@free_bytes")
+ field(EGU, "byte")
+ field(LLSV, "MAJOR")
+ field(LSV, "MINOR")
+ info(autosaveFields_pass0, "HOPR LOPR LOW LOLO LSV LLSV")
+}
+
+record(ai, "TS-DI-TEST-01:MEM_MAX") {
+ field(DESC, "Maximum Memory")
+ field(SCAN, "I/O Intr")
+ field(DTYP, "IOC stats")
+ field(INP, "@total_bytes")
+ field(EGU, "byte")
+}
+
+record(ao, "TS-DI-TEST-01:CA_UPD_TIME") {
+ field(DESC, "CA Check Update Period")
+ field(DTYP, "IOC stats")
+ field(OUT, "@ca_scan_rate")
+ field(EGU, "sec")
+ field(DRVH, "60")
+ field(DRVL, "1")
+ field(HOPR, "60")
+ field(VAL, "15")
+ field(PINI, "YES")
+}
+
+record(ao, "TS-DI-TEST-01:FD_UPD_TIME") {
+ field(DESC, "FD Check Update Period")
+ field(DTYP, "IOC stats")
+ field(OUT, "@fd_scan_rate")
+ field(EGU, "sec")
+ field(DRVH, "60")
+ field(DRVL, "1")
+ field(HOPR, "60")
+ field(VAL, "20")
+ field(PINI, "YES")
+}
+
+record(ao, "TS-DI-TEST-01:LOAD_UPD_TIME") {
+ field(DESC, "CPU Check Update Period")
+ field(DTYP, "IOC stats")
+ field(OUT, "@cpu_scan_rate")
+ field(EGU, "sec")
+ field(DRVH, "60")
+ field(DRVL, "1")
+ field(HOPR, "60")
+ field(VAL, "10")
+ field(PINI, "YES")
+}
+
+record(ao, "TS-DI-TEST-01:MEM_UPD_TIME") {
+ field(DESC, "Memory Check Update Period")
+ field(DTYP, "IOC stats")
+ field(OUT, "@memory_scan_rate")
+ field(EGU, "sec")
+ field(DRVH, "60")
+ field(DRVL, "1")
+ field(HOPR, "60")
+ field(VAL, "10")
+ field(PINI, "YES")
+}
+
+record(stringin, "TS-DI-TEST-01:ST_SCRIPT1") {
+ field(DESC, "Startup Script Part1")
+ field(DTYP, "IOC stats")
+ field(INP, "@startup_script_1")
+ field(PINI, "YES")
+}
+
+record(stringin, "TS-DI-TEST-01:ST_SCRIPT2") {
+ field(DESC, "Startup Script Part2")
+ field(DTYP, "IOC stats")
+ field(INP, "@startup_script_2")
+ field(PINI, "YES")
+}
+
+record(waveform, "TS-DI-TEST-01:ST_SCRIPT") {
+ field(DESC, "Startup Script")
+ field(DTYP, "IOC stats")
+ field(INP, "@startup_script")
+ field(NELM, "120")
+ field(FTVL, "CHAR")
+ field(PINI, "YES")
+}
+
+record(stringin, "TS-DI-TEST-01:KERNEL_VERS") {
+ field(DESC, "Kernel Version")
+ field(DTYP, "IOC stats")
+ field(INP, "@kernel_ver")
+ field(PINI, "YES")
+}
+
+record(stringin, "TS-DI-TEST-01:EPICS_VERS") {
+ field(DESC, "EPICS Version")
+ field(DTYP, "IOC stats")
+ field(INP, "@epics_ver")
+ field(PINI, "YES")
+}
+
+record(waveform, "TS-DI-TEST-01:EPICS_VERSION") {
+ field(DESC, "EPICS Version")
+ field(DTYP, "IOC stats")
+ field(INP, "@epics_ver")
+ field(NELM, "120")
+ field(FTVL, "CHAR")
+ field(PINI, "YES")
+}
+
+record(stringin, "TS-DI-TEST-01:HOSTNAME") {
+ field(DESC, "Host Name")
+ field(DTYP, "IOC stats")
+ field(INP, "@hostname")
+ field(PINI, "YES")
+}
+
+record(stringin, "TS-DI-TEST-01:APP_DIR1") {
+ field(DESC, "Application Directory Part 1")
+ field(DTYP, "IOC stats")
+ field(INP, "@pwd1")
+ field(PINI, "YES")
+}
+
+record(stringin, "TS-DI-TEST-01:APP_DIR2") {
+ field(DESC, "Application Directory Part 2")
+ field(DTYP, "IOC stats")
+ field(INP, "@pwd2")
+ field(PINI, "YES")
+}
+
+record(waveform, "TS-DI-TEST-01:APP_DIR") {
+ field(DESC, "Application Directory")
+ field(DTYP, "IOC stats")
+ field(INP, "@pwd")
+ field(NELM, "160")
+ field(FTVL, "CHAR")
+ field(PINI, "YES")
+}
+
+record(stringin, "TS-DI-TEST-01:UPTIME") {
+ field(DESC, "Elapsed Time since Start")
+ field(SCAN, "1 second")
+ field(DTYP, "IOC stats")
+ field(INP, "@up_time")
+ field(PINI, "YES")
+}
+
+record(stringin, "TS-DI-TEST-01:ENGINEER") {
+ field(DESC, "Engineer")
+ field(DTYP, "IOC stats")
+ field(INP, "@engineer")
+ field(PINI, "YES")
+}
+
+record(stringin, "TS-DI-TEST-01:LOCATION") {
+ field(DESC, "Location")
+ field(DTYP, "IOC stats")
+ field(INP, "@location")
+ field(PINI, "YES")
+}
+
+record(ai, "TS-DI-TEST-01:PROCESS_ID") {
+ field(DESC, "Process ID")
+ field(PINI, "YES")
+ field(DTYP, "IOC stats")
+ field(INP, "@proc_id")
+}
+
+record(ai, "TS-DI-TEST-01:PARENT_ID") {
+ field(DESC, "Parent Process ID")
+ field(PINI, "YES")
+ field(DTYP, "IOC stats")
+ field(INP, "@parent_proc_id")
+}
+
+record(ai, "TS-DI-TEST-01:SCANONCE_Q_SIZE") {
+ field(DESC, "max # entries in IOC scanOnce queue")
+ field(DTYP, "IOC stats")
+ field(INP, "@scanOnceQueueSize")
+ field(PINI, "YES")
+}
+record(ai, "TS-DI-TEST-01:CB_Q_SIZE") {
+ field(DESC, "max # entries in IOC callback queues")
+ field(DTYP, "IOC stats")
+ field(INP, "@cbQueueSize")
+ field(PINI, "YES")
+}
+
+record(ai, "TS-DI-TEST-01:SCANONCE_Q_HIGH") {
+ field(DESC, "max # of elmts in IOC's scanOnce queue")
+ field(SCAN, "I/O Intr")
+ field(DTYP, "IOC stats")
+ field(INP, "@scanOnceQueueHiWtrMrk")
+ field(PINI, "YES")
+ field(FLNK, "TS-DI-TEST-01:SCANONCE_Q_HIGHPER")
+}
+
+record(calc, "TS-DI-TEST-01:SCANONCE_Q_HIGHPER") {
+ field(DESC, "Max. usage of IOC's scanOnce queue")
+ field(INPA, "TS-DI-TEST-01:SCANONCE_Q_HIGH NPP MS")
+ field(INPB, "TS-DI-TEST-01:SCANONCE_Q_SIZE NPP MS")
+ field(CALC, "100*A/B")
+ field(LOPR, "0")
+ field(HOPR, "100")
+ field(EGU, "%")
+}
+
+record(ai, "TS-DI-TEST-01:SCANONCE_Q_USED") {
+ field(DESC, "# of entries in IOC's scanOnce queue")
+ field(SCAN, "I/O Intr")
+ field(DTYP, "IOC stats")
+ field(INP, "@scanOnceQueueUsed")
+ field(PINI, "YES")
+ field(FLNK, "TS-DI-TEST-01:SCANONCE_Q_USEDPER")
+}
+
+record(calc, "TS-DI-TEST-01:SCANONCE_Q_USEDPER") {
+ field(DESC, "Percentage of IOC's scanOnce queue used")
+ field(INPA, "TS-DI-TEST-01:SCANONCE_Q_USED NPP MS")
+ field(INPB, "TS-DI-TEST-01:SCANONCE_Q_SIZE NPP MS")
+ field(CALC, "100*A/B")
+ field(LOPR, "0")
+ field(HOPR, "100")
+ field(EGU, "%")
+}
+
+record(ai, "TS-DI-TEST-01:SCANONCE_Q_OVERRUNS") {
+ field(DESC, "# of overruns of IOC's scanOnce queue")
+ field(SCAN, "I/O Intr")
+ field(DTYP, "IOC stats")
+ field(INP, "@scanOnceQueueOverruns")
+ field(PINI, "YES")
+}
+
+record(ai, "TS-DI-TEST-01:CBLOW_Q_HIGH") {
+ field(DESC, "max # of elmts in IOC's cbLow queue")
+ field(SCAN, "I/O Intr")
+ field(DTYP, "IOC stats")
+ field(INP, "@cbLowQueueHiWtrMrk")
+ field(PINI, "YES")
+ field(FLNK, "TS-DI-TEST-01:CBLOW_Q_HIGHPER")
+}
+
+record(calc, "TS-DI-TEST-01:CBLOW_Q_HIGHPER") {
+ field(DESC, "Max. usage of IOC's cbLow queue")
+ field(INPA, "TS-DI-TEST-01:CBLOW_Q_HIGH NPP MS")
+ field(INPB, "TS-DI-TEST-01:CB_Q_SIZE NPP MS")
+ field(CALC, "100*A/B")
+ field(LOPR, "0")
+ field(HOPR, "100")
+ field(EGU, "%")
+}
+
+record(ai, "TS-DI-TEST-01:CBLOW_Q_USED") {
+ field(DESC, "# of entries in IOC's cbLow queue")
+ field(SCAN, "I/O Intr")
+ field(DTYP, "IOC stats")
+ field(INP, "@cbLowQueueUsed")
+ field(PINI, "YES")
+ field(FLNK, "TS-DI-TEST-01:CBLOW_Q_USEDPER")
+}
+
+record(calc, "TS-DI-TEST-01:CBLOW_Q_USEDPER") {
+ field(DESC, "Percentage of IOC's cbLow queue used")
+ field(INPA, "TS-DI-TEST-01:CBLOW_Q_USED NPP MS")
+ field(INPB, "TS-DI-TEST-01:CB_Q_SIZE NPP MS")
+ field(CALC, "100*A/B")
+ field(LOPR, "0")
+ field(HOPR, "100")
+ field(EGU, "%")
+}
+
+record(ai, "TS-DI-TEST-01:CBLOW_Q_OVERRUNS") {
+ field(DESC, "# of overruns of IOC's cbLow queue")
+ field(SCAN, "I/O Intr")
+ field(DTYP, "IOC stats")
+ field(INP, "@cbLowQueueOverruns")
+ field(PINI, "YES")
+}
+
+record(ai, "TS-DI-TEST-01:CBMEDIUM_Q_HIGH") {
+ field(DESC, "max # of elmts in IOC's cbMedium queue")
+ field(SCAN, "I/O Intr")
+ field(DTYP, "IOC stats")
+ field(INP, "@cbMediumQueueHiWtrMrk")
+ field(PINI, "YES")
+ field(FLNK, "TS-DI-TEST-01:CBMEDIUM_Q_HIGHPER")
+}
+
+record(calc, "TS-DI-TEST-01:CBMEDIUM_Q_HIGHPER") {
+ field(DESC, "Max. usage of IOC's cbMedium queue")
+ field(INPA, "TS-DI-TEST-01:CBMEDIUM_Q_HIGH NPP MS")
+ field(INPB, "TS-DI-TEST-01:CB_Q_SIZE NPP MS")
+ field(CALC, "100*A/B")
+ field(LOPR, "0")
+ field(HOPR, "100")
+ field(EGU, "%")
+}
+
+record(ai, "TS-DI-TEST-01:CBMEDIUM_Q_USED") {
+ field(DESC, "# of entries in IOC's cbMedium queue")
+ field(SCAN, "I/O Intr")
+ field(DTYP, "IOC stats")
+ field(INP, "@cbMediumQueueUsed")
+ field(PINI, "YES")
+ field(FLNK, "TS-DI-TEST-01:CBMEDIUM_Q_USEDPER")
+}
+
+record(calc, "TS-DI-TEST-01:CBMEDIUM_Q_USEDPER") {
+ field(DESC, "Percentage of IOC's cbMedium queue used")
+ field(INPA, "TS-DI-TEST-01:CBMEDIUM_Q_USED NPP MS")
+ field(INPB, "TS-DI-TEST-01:CB_Q_SIZE NPP MS")
+ field(CALC, "100*A/B")
+ field(LOPR, "0")
+ field(HOPR, "100")
+ field(EGU, "%")
+}
+
+record(ai, "TS-DI-TEST-01:CBMEDIUM_Q_OVERRUNS") {
+ field(DESC, "# of overruns of IOC's cbMedium queue")
+ field(SCAN, "I/O Intr")
+ field(DTYP, "IOC stats")
+ field(INP, "@cbMediumQueueOverruns")
+ field(PINI, "YES")
+}
+
+record(ai, "TS-DI-TEST-01:CBHIGH_Q_HIGH") {
+ field(DESC, "max # of elmts in IOC's cbHigh queue")
+ field(SCAN, "I/O Intr")
+ field(DTYP, "IOC stats")
+ field(INP, "@cbHighQueueHiWtrMrk")
+ field(PINI, "YES")
+ field(FLNK, "TS-DI-TEST-01:CBHIGH_Q_HIGHPER")
+}
+
+record(calc, "TS-DI-TEST-01:CBHIGH_Q_HIGHPER") {
+ field(DESC, "Max. usage of IOC's cbHigh queue")
+ field(INPA, "TS-DI-TEST-01:CBHIGH_Q_HIGH NPP MS")
+ field(INPB, "TS-DI-TEST-01:CB_Q_SIZE NPP MS")
+ field(CALC, "100*A/B")
+ field(LOPR, "0")
+ field(HOPR, "100")
+ field(EGU, "%")
+}
+
+record(ai, "TS-DI-TEST-01:CBHIGH_Q_USED") {
+ field(DESC, "# of entries in IOC's cbHigh queue")
+ field(SCAN, "I/O Intr")
+ field(DTYP, "IOC stats")
+ field(INP, "@cbHighQueueUsed")
+ field(PINI, "YES")
+ field(FLNK, "TS-DI-TEST-01:CBHIGH_Q_USEDPER")
+}
+
+record(calc, "TS-DI-TEST-01:CBHIGH_Q_USEDPER") {
+ field(DESC, "Percentage of IOC's cbHigh queue used")
+ field(INPA, "TS-DI-TEST-01:CBHIGH_Q_USED NPP MS")
+ field(INPB, "TS-DI-TEST-01:CB_Q_SIZE NPP MS")
+ field(CALC, "100*A/B")
+ field(LOPR, "0")
+ field(HOPR, "100")
+ field(EGU, "%")
+}
+
+record(ai, "TS-DI-TEST-01:CBHIGH_Q_OVERRUNS") {
+ field(DESC, "# of overruns of IOC's cbHigh queue")
+ field(SCAN, "I/O Intr")
+ field(DTYP, "IOC stats")
+ field(INP, "@cbHighQueueOverruns")
+ field(PINI, "YES")
+}
+
+record(ai, "TS-DI-TEST-01:AI")
+{
+ field(DTYP, "Python")
+ field(INP, "@TS-DI-TEST-01:AI")
+ field(PINI, "YES")
+ field(SCAN, "I/O Intr")
+}
+
+record(ao, "TS-DI-TEST-01:AO")
+{
+ field(DTYP, "Python")
+ field(OUT, "@TS-DI-TEST-01:AO")
+}
+
+record(bi, "TS-DI-TEST-01:BOOLIN")
+{
+ field(DTYP, "Python")
+ field(INP, "@TS-DI-TEST-01:BOOLIN")
+ field(ONAM, "False")
+ field(PINI, "YES")
+ field(SCAN, "I/O Intr")
+ field(ZNAM, "True")
+}
+
+record(bo, "TS-DI-TEST-01:BOOLOUT")
+{
+ field(DTYP, "Python")
+ field(OMSL, "supervisory")
+ field(ONAM, "One")
+ field(OUT, "@TS-DI-TEST-01:BOOLOUT")
+ field(ZNAM, "Zero")
+}
+
+record(longin, "TS-DI-TEST-01:LONGIN")
+{
+ field(DTYP, "Python")
+ field(INP, "@TS-DI-TEST-01:LONGIN")
+ field(MDEL, "-1")
+ field(PINI, "YES")
+ field(SCAN, "I/O Intr")
+}
+
+record(longout, "TS-DI-TEST-01:LONGOUT")
+{
+ field(DTYP, "Python")
+ field(OMSL, "supervisory")
+ field(OUT, "@TS-DI-TEST-01:LONGOUT")
+}
+
+record(waveform, "TS-DI-TEST-01:LONGSTRING")
+{
+ field(DTYP, "Python")
+ field(FTVL, "UCHAR")
+ field(INP, "@TS-DI-TEST-01:LONGSTRING")
+ field(NELM, "256")
+ field(SCAN, "I/O Intr")
+}
+
+record(mbbi, "TS-DI-TEST-01:MBBI")
+{
+ field(DTYP, "Python")
+ field(INP, "@TS-DI-TEST-01:MBBI")
+ field(ONST, "Two")
+ field(ONVL, "1")
+ field(PINI, "YES")
+ field(SCAN, "I/O Intr")
+ field(TWST, "Three")
+ field(TWVL, "2")
+ field(ZRST, "One")
+ field(ZRVL, "0")
+}
+
+record(mbbo, "TS-DI-TEST-01:MBBO")
+{
+ field(DTYP, "Python")
+ field(OMSL, "supervisory")
+ field(ONST, "Zwei")
+ field(ONVL, "1")
+ field(OUT, "@TS-DI-TEST-01:MBBO")
+ field(TWST, "Drei")
+ field(TWVL, "2")
+ field(ZRST, "Ein")
+ field(ZRVL, "0")
+}
+
+record(waveform, "TS-DI-TEST-01:SIN")
+{
+ field(DTYP, "Python")
+ field(FTVL, "DOUBLE")
+ field(INP, "@TS-DI-TEST-01:SIN")
+ field(NELM, "1024")
+ field(SCAN, "I/O Intr")
+}
+
+record(longout, "TS-DI-TEST-01:SINN")
+{
+ field(DRVH, "1024")
+ field(DRVL, "0")
+ field(DTYP, "Python")
+ field(OMSL, "supervisory")
+ field(OUT, "@TS-DI-TEST-01:SINN")
+}
+
+record(ao, "TS-DI-TEST-01:SINP")
+{
+ field(DTYP, "Python")
+ field(OUT, "@TS-DI-TEST-01:SINP")
+}
+
+record(stringin, "TS-DI-TEST-01:STRINGIN")
+{
+ field(DTYP, "Python")
+ field(INP, "@TS-DI-TEST-01:STRINGIN")
+ field(PINI, "YES")
+ field(SCAN, "I/O Intr")
+}
+
+record(stringout, "TS-DI-TEST-01:STRINGOUT")
+{
+ field(DTYP, "Python")
+ field(OUT, "@TS-DI-TEST-01:STRINGOUT")
+}
+
+record(waveform, "TS-DI-TEST-01:WAVEFORM")
+{
+ field(DTYP, "Python")
+ field(FTVL, "DOUBLE")
+ field(INP, "@TS-DI-TEST-01:WAVEFORM")
+ field(NELM, "32")
+ field(PINI, "YES")
+ field(SCAN, "I/O Intr")
+}
+
+record(waveform, "TS-DI-TEST-01:WAVEFORM2")
+{
+ field(DTYP, "Python")
+ field(FTVL, "FLOAT")
+ field(INP, "@TS-DI-TEST-01:WAVEFORM2")
+ field(NELM, "10")
+ field(SCAN, "I/O Intr")
+}
+
+record(waveform, "TS-DI-TEST-01:WAVEFORM_OUT")
+{
+ field(DTYP, "PythonWfOut")
+ field(FTVL, "DOUBLE")
+ field(INP, "@TS-DI-TEST-01:WAVEFORM_OUT")
+ field(NELM, "32")
+}
diff --git a/example/graph.edl b/tests/graph.edl
similarity index 100%
rename from example/graph.edl
rename to tests/graph.edl
diff --git a/tests/sim_asyncio_ioc.py b/tests/sim_asyncio_ioc.py
new file mode 100644
index 00000000..62eaac28
--- /dev/null
+++ b/tests/sim_asyncio_ioc.py
@@ -0,0 +1,36 @@
+from argparse import ArgumentParser
+
+import asyncio
+import time
+import sys
+
+from softioc import softioc, builder, asyncio_dispatcher, pvlog
+
+
+if __name__ == "__main__":
+ # Being run as an IOC, so parse args and set prefix
+ parser = ArgumentParser()
+ parser.add_argument('prefix', help="The PV prefix for the records")
+ parsed_args = parser.parse_args()
+ builder.SetDeviceName(parsed_args.prefix)
+
+ import sim_records
+
+ async def callback(value):
+ await asyncio.sleep(0.5)
+ print("async update %s" % value)
+ # Make sure it goes as epicsExit will not flush this for us
+ sys.stdout.flush()
+ sim_records.t_ai.set(value)
+
+ t_ao = builder.aOut('AO2', initial_value=12.45, on_update=callback)
+
+ # Run the IOC
+ builder.LoadDatabase()
+ softioc.iocInit(asyncio_dispatcher.AsyncioDispatcher())
+ # Wait for some prints to have happened
+ time.sleep(1)
+ # Make sure coverage is written on epicsExit
+ from pytest_cov.embed import cleanup
+ sys.exitfunc = cleanup
+ softioc.interactive_ioc()
diff --git a/tests/sim_cothread_ioc.py b/tests/sim_cothread_ioc.py
new file mode 100644
index 00000000..b9f57603
--- /dev/null
+++ b/tests/sim_cothread_ioc.py
@@ -0,0 +1,20 @@
+from argparse import ArgumentParser
+
+from softioc import softioc, builder, pvlog
+
+
+if __name__ == "__main__":
+ import cothread
+
+ # Being run as an IOC, so parse args and set prefix
+ parser = ArgumentParser()
+ parser.add_argument('prefix', help="The PV prefix for the records")
+ parsed_args = parser.parse_args()
+ builder.SetDeviceName(parsed_args.prefix)
+
+ import sim_records
+
+ # Run the IOC
+ builder.LoadDatabase()
+ softioc.iocInit()
+ cothread.WaitForQuit()
diff --git a/example/testing.py b/tests/sim_records.py
old mode 100644
new mode 100755
similarity index 54%
rename from example/testing.py
rename to tests/sim_records.py
index 0aaae69d..a6d8c396
--- a/example/testing.py
+++ b/tests/sim_records.py
@@ -2,11 +2,19 @@
from __future__ import print_function
+from epicsdbbuilder import GetRecordNames, WriteRecords
+
+from softioc import softioc
from softioc.builder import *
-import numpy
-SetDeviceName('TS-DI-TEST-01')
+import numpy
+names = GetRecordNames()
+if names.prefix:
+ ioc_name = names.prefix[0]
+else:
+ ioc_name = 'TS-DI-TEST-01'
+ SetDeviceName(ioc_name)
def on_update(value):
print('on_update', repr(value))
@@ -14,42 +22,34 @@ def on_update(value):
def on_update_name(value, name):
print('on_update', name, ':', repr(value))
-t_ai = aIn('AI')
-t_boolin = boolIn('BOOLIN', 'True', 'False')
-t_longin = longIn('LONGIN')
-t_stringin = stringIn('STRINGIN')
-t_mbbi = mbbIn('MBBI', 'One', 'Two', 'Three')
-
-t_ao = aOut ('AO',
- initial_value = 12.45, on_update_name = on_update_name)
-t_boolout = boolOut ('BOOLOUT', 'Zero', 'One',
- initial_value = True, on_update = on_update)
-t_longout = longOut ('LONGOUT',
- initial_value = 2008, on_update = on_update)
-t_stringout = stringOut ('STRINGOUT',
- initial_value = 'watevah', on_update = on_update)
-t_mbbo = mbbOut ('MBBO', 'Ein', 'Zwei', 'Drei',
- initial_value = 1, on_update = on_update)
+t_ai = aIn('AI', initial_value=12.34)
+t_boolin = boolIn('BOOLIN', 'True', 'False', initial_value=False)
+t_longin = longIn('LONGIN', initial_value=33)
+t_stringin = stringIn('STRINGIN', initial_value="Testing string")
+t_mbbi = mbbIn('MBBI', 'One', 'Two', 'Three', initial_value=2)
+
+t_ao = aOut('AO', initial_value=12.45, on_update_name=on_update_name)
+t_boolout = boolOut(
+ 'BOOLOUT', 'Zero', 'One', initial_value=True, on_update=on_update)
+t_longout = longOut('LONGOUT', initial_value=2008, on_update=on_update)
+t_stringout = stringOut(
+ 'STRINGOUT', initial_value='watevah', on_update=on_update)
+t_mbbo = mbbOut(
+ 'MBBO', 'Ein', 'Zwei', 'Drei', initial_value=1, on_update=on_update)
def update_sin_wf(value):
print('update_sin_wf', value)
sin_wf.set(numpy.sin(
numpy.linspace(0, 2*numpy.pi*sin_ph.get(), sin_len.get())))
sin_wf = Waveform('SIN', datatype = float, length = 1024)
-sin_len = longOut('SINN', 0, 1024,
- initial_value = 1024, on_update = update_sin_wf)
+sin_len = longOut(
+ 'SINN', 0, 1024, initial_value=1024, on_update=update_sin_wf)
sin_ph = aOut('SINP', initial_value = 0.0, on_update = update_sin_wf)
-t_ai.set(12.34)
-t_boolin.set(False)
-t_longin.set(33)
-t_stringin.set('Testing string')
-t_mbbi.set(2)
-
wf_len = 32
wf = numpy.sin(numpy.linspace(0, 2*numpy.pi, wf_len))
-t_waveform_in = Waveform('WAVEFORM', wf)
+t_waveform_in = Waveform('WAVEFORM', wf)
t_waveform_out = WaveformOut('WAVEFORM_OUT', wf, on_update = on_update)
t_waveform_in2 = Waveform('WAVEFORM2', length = 10)
@@ -70,6 +70,7 @@ def UpdateOut():
t_stringout.set('Another different string')
t_mbbo.set(2)
+softioc.devIocStats(ioc_name)
__all__ = [
't_ai', 't_boolin', 't_longin', 't_stringin', 't_mbbi',
@@ -78,3 +79,6 @@ def UpdateOut():
'wf_len',
'Update', 'UpdateOut'
]
+
+if __name__ == "__main__":
+ WriteRecords("expected_records.db")
diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py
new file mode 100644
index 00000000..10e3e443
--- /dev/null
+++ b/tests/test_asyncio.py
@@ -0,0 +1,69 @@
+# Will be ignored on Python2 by conftest.py settings
+
+import random
+import string
+import subprocess
+import sys
+import os
+import atexit
+import pytest
+
+PV_PREFIX = "".join(random.choice(string.ascii_uppercase) for _ in range(12))
+
+
+@pytest.fixture
+def asyncio_ioc():
+ sim_ioc = os.path.join(os.path.dirname(__file__), "sim_asyncio_ioc.py")
+ cmd = [sys.executable, sim_ioc, PV_PREFIX]
+ proc = subprocess.Popen(
+ cmd, stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ yield proc
+ if proc.returncode is None:
+ # still running, kill it and print the output
+ proc.kill()
+ out, err = proc.communicate()
+ print(out.decode())
+ print(err.decode(), file=sys.stderr)
+
+
+@pytest.mark.asyncio
+async def test_asyncio_ioc(asyncio_ioc):
+ import asyncio
+ from aioca import caget, caput, camonitor, CANothing, _catools
+ # Unregister the atexit handler as it conflicts with cothread
+ atexit.unregister(_catools._catools_atexit)
+
+ # Start
+ assert await caget(PV_PREFIX + ":UPTIME") in [
+ "00:00:00", "00:00:01", "00:00:02", "00:00:03"
+ ]
+ # WAVEFORM
+ await caput(PV_PREFIX + ":SINN", 4, wait=True)
+ q = asyncio.Queue()
+ m = camonitor(PV_PREFIX + ":SIN", q.put, notify_disconnect=True)
+ assert len(await asyncio.wait_for(q.get(), 1)) == 4
+ # AO
+ assert await caget(PV_PREFIX + ":AO2") == 12.45
+ await caput(PV_PREFIX + ":AO2", 3.56, wait=True)
+ await asyncio.sleep(0.1)
+ assert await caget(PV_PREFIX + ":AI") == 12.34
+ await asyncio.sleep(0.6)
+ assert await caget(PV_PREFIX + ":AI") == 3.56
+ # Wait for a bit longer for the print output to flush
+ await asyncio.sleep(2)
+ # Stop
+ out, err = asyncio_ioc.communicate(b"exit\n", timeout=5)
+ out = out.decode()
+ err = err.decode()
+ # Disconnect
+ assert isinstance(await asyncio.wait_for(q.get(), 10), CANothing)
+ m.close()
+ # check closed and output
+ assert "%s:SINN.VAL 1024 -> 4" % PV_PREFIX in out
+ assert 'update_sin_wf 4' in out
+ assert "%s:AO2.VAL 12.45 -> 3.56" % PV_PREFIX in out
+ assert 'async update 3.56' in out
+ assert 'Starting iocInit' in err
+ assert 'iocRun: All initialization complete' in err
+ assert '(InteractiveConsole)' in err
diff --git a/tests/test_cothread.py b/tests/test_cothread.py
new file mode 100644
index 00000000..41b2f7c1
--- /dev/null
+++ b/tests/test_cothread.py
@@ -0,0 +1,64 @@
+import random
+import string
+import subprocess
+import sys
+import os
+import signal
+import pytest
+
+PV_PREFIX = "".join(random.choice(string.ascii_uppercase) for _ in range(12))
+
+
+if sys.platform.startswith("win"):
+ pytest.skip("Cothread doesn't work on windows", allow_module_level=True)
+
+
+@pytest.fixture
+def cothread_ioc():
+ sim_ioc = os.path.join(os.path.dirname(__file__), "sim_cothread_ioc.py")
+ cmd = [sys.executable, sim_ioc, PV_PREFIX]
+ proc = subprocess.Popen(
+ cmd, stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ yield proc
+ if proc.returncode is None:
+ # still running, kill it and print the output
+ proc.kill()
+ out, err = proc.communicate()
+ print(out.decode())
+ print(err.decode())
+
+
+
+def test_cothread_ioc(cothread_ioc):
+ import epicscorelibs.path.cothread
+ import cothread
+ from cothread.catools import ca_nothing, caget, caput, camonitor
+
+ # Start
+ assert caget(PV_PREFIX + ":UPTIME") in ["00:00:00", "00:00:01"]
+ # WAVEFORM
+ caput(PV_PREFIX + ":SINN", 4, wait=True)
+ q = cothread.EventQueue()
+ m = camonitor(PV_PREFIX + ":SIN", q.Signal, notify_disconnect=True)
+ assert len(q.Wait(1)) == 4
+ # STRINGOUT
+ assert caget(PV_PREFIX + ":STRINGOUT") == "watevah"
+ caput(PV_PREFIX + ":STRINGOUT", "something", wait=True)
+ assert caget(PV_PREFIX + ":STRINGOUT") == "something"
+ # Stop
+ cothread_ioc.send_signal(signal.SIGINT)
+ # Disconnect
+ assert isinstance(q.Wait(10), ca_nothing)
+ m.close()
+ # check closed and output
+ out, err = cothread_ioc.communicate()
+ out = out.decode()
+ err = err.decode()
+ # check closed and output
+ assert "%s:SINN.VAL 1024 -> 4" % PV_PREFIX in out
+ assert 'update_sin_wf 4' in out
+ assert "%s:STRINGOUT.VAL watevah -> something" % PV_PREFIX in out
+ assert 'on_update \'something\'' in out
+ assert 'Starting iocInit' in err
+ assert 'iocRun: All initialization complete' in err
diff --git a/tests/test_records.py b/tests/test_records.py
new file mode 100644
index 00000000..a3778a5b
--- /dev/null
+++ b/tests/test_records.py
@@ -0,0 +1,12 @@
+import os
+
+from softioc import builder
+
+import sim_records
+
+
+def test_records(tmp_path):
+ path = str(tmp_path / "records.db")
+ builder.WriteRecords(path)
+ expected = os.path.join(os.path.dirname(__file__), "expected_records.db")
+ assert open(path).readlines()[4:] == open(expected).readlines()[4:]