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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,8 @@ extend-ignore =

[tool:pytest]
# Run pytest with all our checkers, and don't spam us with massive tracebacks on error
# Don't do flake8 here as we need to separate it out for CI
addopts =
--tb=native -vv --doctest-modules --ignore=softioc/iocStats --ignore=epicscorelibs --ignore=docs
--cov=softioc --cov-report term --cov-report xml:cov.xml
--tb=native -vv --ignore=softioc/iocStats --ignore=epicscorelibs --ignore=docs
# Enables all discovered async tests and fixtures to be automatically marked as async, even if
# they don't have a specific marker https://github.com/pytest-dev/pytest-asyncio#auto-mode
asyncio_mode = auto
Expand Down
52 changes: 52 additions & 0 deletions softioc/device_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,60 @@ def __init__(self, name, **kargs):
# a call to get_ioinit_info. This is only a trivial attempt to
# reduce resource consumption.
self.__ioscanpvt = imports.IOSCANPVT()
# CLS: per-field callback registry.
# Keys are uppercase field names (e.g. "SCAN", "VAL") or the
# wildcard "*" which matches every field write.
# Values are lists of callables.
self.__field_callbacks = {}
super().__init__(name, **kargs)

# ---- CLS extension: field-change callbacks ----------------------------

def on_field_change(self, field, callback):
'''Register *callback* to be invoked when *field* is written via
CA or PVA.

Args:
field: EPICS field name (e.g. ``"SCAN"``, ``"VAL"``,
``"DISA"``). Use ``"*"`` to receive notifications for
**every** field write on this record.
callback: ``callback(record_name, field_name, value_string)``
called after each matching write. *value_string* is the
new value formatted by EPICS as a ``DBR_STRING``.

Multiple callbacks per field are supported; they are called in
registration order. The same callable may be registered for
different fields.

Note:
Callbacks fire only for writes originating from Channel
Access or PV Access clients. IOC-shell writes (``dbpf``)
and internal ``record.set()`` calls bypass asTrapWrite and
will **not** trigger callbacks.
'''
field = field.upper() if field != "*" else "*"
self.__field_callbacks.setdefault(field, []).append(callback)

@property
def field_callbacks(self):
'''Read-only view of the registered field-change callbacks.

Returns a dict mapping field names (and ``"*"``) to lists of
callables. Modifying the returned dict has no effect on the
internal registry — use :meth:`on_field_change` to register new
callbacks.
'''
return {k: list(v) for k, v in self.__field_callbacks.items()}

def _get_field_callbacks(self, field):
'''Return the list of callbacks for *field*, including wildcards.

This is an internal helper used by :mod:`~softioc.field_monitor`.
'''
cbs = list(self.__field_callbacks.get(field, []))
cbs.extend(self.__field_callbacks.get("*", []))
return cbs


def init_record(self, record):
'''Default record initialisation finalisation. This can be overridden
Expand Down
64 changes: 64 additions & 0 deletions softioc/extension.c
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,68 @@ static PyObject *install_pv_logging(PyObject *self, PyObject *args)
}


/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
/* CLS extension: field-write callback support.
*
* A single Python callable (py_field_write_callback) is invoked for every
* CA/PVA field write that passes through asTrapWrite. The Python layer
* (field_monitor.py) demultiplexes the call to per-record, per-field
* callbacks registered by on_field_change().
*
* This hook coexists with the original EpicsPvPutHook (print-logging)
* because asTrapWrite supports multiple registered listeners.
*/

/* Python callable: callback(channel_name: str, value_str: str) */
static PyObject *py_field_write_callback = NULL;

static void FieldWriteHook(struct asTrapWriteMessage *pmessage, int after)
{
if (!after || !py_field_write_callback || py_field_write_callback == Py_None)
return;

struct dbChannel *pchan = pmessage->serverSpecific;
if (!pchan) return;

/* Channel name includes the field suffix, e.g. "MYPV.SCAN". */
const char *channel_name = dbChannelName(pchan);

/* Read the post-write value formatted as a human-readable string.
* MAX_STRING_SIZE (from EPICS base) is 40 — use a generous buffer
* to accommodate array-of-string fields that FormatValue handles. */
char value_str[MAX_STRING_SIZE + 1];
memset(value_str, 0, sizeof(value_str));
long len = 1;
long opts = 0;
dbGetField(&pchan->addr, DBR_STRING, value_str, &opts, &len, NULL);

/* Acquire the GIL and forward to Python. */
PyGILState_STATE gstate = PyGILState_Ensure();
PyObject *result = PyObject_CallFunction(
py_field_write_callback, "ss", channel_name, value_str);
Py_XDECREF(result);
if (PyErr_Occurred())
PyErr_Print();
PyGILState_Release(gstate);
}

static PyObject *register_field_write_listener(PyObject *self, PyObject *args)
{
PyObject *callback;
if (!PyArg_ParseTuple(args, "O", &callback))
return NULL;
if (!PyCallable_Check(callback)) {
PyErr_SetString(PyExc_TypeError, "Argument must be callable");
return NULL;
}
Py_XDECREF(py_field_write_callback);
Py_INCREF(callback);
py_field_write_callback = callback;
asTrapWriteRegisterListener(FieldWriteHook);
Py_RETURN_NONE;
}


/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
/* Process callback support. */

Expand Down Expand Up @@ -339,6 +401,8 @@ static struct PyMethodDef softioc_methods[] = {
"Inform EPICS that asynchronous record processing has completed"},
{"create_callback_capsule", create_callback_capsule, METH_VARARGS,
"Create a CALLBACK structure inside a PyCapsule"},
{"register_field_write_listener", register_field_write_listener, METH_VARARGS,
"Register a Python callable for all CA/PVA field writes (CLS extension)"},
{NULL, NULL, 0, NULL} /* Sentinel */
};

Expand Down
71 changes: 71 additions & 0 deletions softioc/field_monitor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""
CLS extension: field-write monitor.

Bridges the low-level C ``asTrapWrite`` hook to the per-record Python
callbacks registered via :meth:`DeviceSupportCore.on_field_change`.

After ``iocInit()``, call :func:`install_field_monitor` once. From that
point on every CA/PVA-originated write to any record field triggers
:func:`_dispatch_field_write`, which resolves the record, and invokes the
matching callbacks (exact field match **plus** any ``"*"`` wildcard
callbacks).

.. note::

IOC-shell writes (``dbpf``) and internal ``record.set()`` calls bypass
``asTrapWrite`` and will **not** fire callbacks.
"""

import logging

from .device_core import LookupRecord
from . import imports

__all__ = ['install_field_monitor']

_log = logging.getLogger(__name__)


def _parse_channel_name(channel_name):
"""Split a channel name into ``(record_name, field_name)``.

Returns:
tuple: ``("RECNAME", "FIELD")`` or ``("RECNAME", "VAL")`` when
the channel was addressed without a dot suffix.
"""
if "." in channel_name:
return channel_name.rsplit(".", 1)
return channel_name, "VAL"


def _dispatch_field_write(channel_name, value_str):
"""Called from C for every CA/PVA field write (post-write).

Resolves the target record via :func:`LookupRecord` and invokes
every callback registered for the written field **and** any ``"*"``
wildcard callbacks.
"""
rec_name, field = _parse_channel_name(channel_name)

try:
record = LookupRecord(rec_name)
except KeyError:
return # Not one of our soft-IOC records — nothing to do.

for cb in record._get_field_callbacks(field):
try:
cb(rec_name, field, value_str)
except Exception:
_log.exception(
"field-change callback error for %s.%s", rec_name, field
)


def install_field_monitor():
"""Register :func:`_dispatch_field_write` with the C extension.

Must be called **after** ``iocInit()`` and after the access-security
file (containing the ``TRAPWRITE`` rule) has been loaded — both of
which are handled automatically by :func:`softioc.iocInit`.
"""
imports.register_field_write_listener(_dispatch_field_write)
6 changes: 6 additions & 0 deletions softioc/imports.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ def install_pv_logging(acf_file):
'''Install pv logging'''
_extension.install_pv_logging(acf_file)

def register_field_write_listener(callback):
'''CLS extension: register a Python callable for all CA/PVA field writes.
callback(channel_name: str, value_str: str) is called after each write.
'''
_extension.register_field_write_listener(callback)

def create_callback_capsule():
return _extension.create_callback_capsule()

Expand Down
12 changes: 12 additions & 0 deletions softioc/softioc.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,21 @@ def iocInit(dispatcher=None, enable_pva=True):

imports.registerRecordDeviceDriver(pdbbase)

# CLS extension: ensure access security is configured before iocInit.
# Importing pvlog triggers asSetFilename(access.acf) and registers
# the original caput print-logging hook — we preserve that behavior.
# The TRAPWRITE rule in access.acf is required for asTrapWrite
# listeners (including our field-write callbacks) to fire.
from . import pvlog # noqa: F401 — side-effect import sets ACF

imports.iocInit()
autosave.start_autosave_thread()

# CLS extension: register the Python-level field-write dispatcher now
# that the IOC is running and access security is active.
from .field_monitor import install_field_monitor
install_field_monitor()


def safeEpicsExit(code=0):
'''Calls epicsExit() after ensuring Python exit handlers called.'''
Expand Down
9 changes: 9 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,15 @@ def asyncio_ioc_override():
ioc.kill()
aioca_cleanup()


@pytest.fixture
def field_callbacks_ioc():
"""Start a subprocess IOC that registers on_field_change callbacks."""
ioc = SubprocessIOC("sim_field_callbacks_ioc.py")
yield ioc
ioc.kill()
aioca_cleanup()

def reset_device_name():
if GetRecordNames().prefix:
SetDeviceName("")
Expand Down
93 changes: 93 additions & 0 deletions tests/sim_field_callbacks_ioc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""Simulated IOC for CLS field-change callback tests.

Creates a small set of records and registers ``on_field_change`` callbacks via
the CLS extension. Counter PVs are incremented each time a callback fires,
allowing the test client to verify behaviour over CA or PVA.

Records created
---------------
``{prefix}:AO``
The record whose fields the test client writes to.
``{prefix}:SCAN-CB-CNT``
Incremented by the SCAN callback.
``{prefix}:DISA-CB-CNT``
Incremented by the DISA callback.
``{prefix}:VAL-CB-CNT``
Incremented by the VAL callback.
``{prefix}:HIHI-CB-CNT``
Incremented by the HIHI callback (alarm field, DBF_DOUBLE).
``{prefix}:DESC-CB-CNT``
Incremented by the DESC callback (string field, DBF_STRING).
``{prefix}:ANY-CB-CNT``
Incremented by a wildcard ``"*"`` callback (fires on **every** field
write).

Expected behaviour
------------------
- Original (upstream) pythonSoftIOC: ``on_field_change`` does not exist, so
this script raises ``AttributeError`` before printing READY.
- CLS fork: all callbacks register successfully and READY is printed.
"""

import sys
from argparse import ArgumentParser
from multiprocessing.connection import Client

from softioc import softioc, builder, asyncio_dispatcher

from conftest import ADDRESS, select_and_recv


if __name__ == "__main__":
with Client(ADDRESS) as conn:
parser = ArgumentParser()
parser.add_argument("prefix", help="PV prefix for the records")
parsed_args = parser.parse_args()
builder.SetDeviceName(parsed_args.prefix)

# Main record whose fields the test client writes to.
ao = builder.aOut("AO", initial_value=0.0, HIHI=90.0, HIGH=70.0)

# Counter PVs — incremented by the corresponding callback.
scan_cnt = builder.longOut("SCAN-CB-CNT", initial_value=0)
disa_cnt = builder.longOut("DISA-CB-CNT", initial_value=0)
val_cnt = builder.longOut("VAL-CB-CNT", initial_value=0)
hihi_cnt = builder.longOut("HIHI-CB-CNT", initial_value=0)
desc_cnt = builder.longOut("DESC-CB-CNT", initial_value=0)
any_cnt = builder.longOut("ANY-CB-CNT", initial_value=0)

# Boot the IOC.
dispatcher = asyncio_dispatcher.AsyncioDispatcher()
builder.LoadDatabase()
softioc.iocInit(dispatcher)

# ---- Register on_field_change callbacks (CLS extension) ----------
# With upstream pythonSoftIOC this raises AttributeError.

def _make_increment(counter):
"""Return a callback that increments *counter* by one."""
def _cb(rec_name, field, value):
counter.set(counter.get() + 1)
return _cb

# Per-field callbacks.
ao.on_field_change("SCAN", _make_increment(scan_cnt))
ao.on_field_change("DISA", _make_increment(disa_cnt))
ao.on_field_change("VAL", _make_increment(val_cnt))
# DBF_DOUBLE alarm field
ao.on_field_change("HIHI", _make_increment(hihi_cnt))
# DBF_STRING metadata field
ao.on_field_change("DESC", _make_increment(desc_cnt))

# Wildcard callback — fires for every field write on this record.
ao.on_field_change("*", _make_increment(any_cnt))

conn.send("R") # "Ready"

# Keep the process alive until the test tells us to stop.
select_and_recv(conn, "D") # "Done"

sys.stdout.flush()
sys.stderr.flush()

conn.send("D") # "Done" acknowledgement
Loading
Loading