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
134 changes: 132 additions & 2 deletions softioc/device_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,108 @@ 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"``), a list of field names
(e.g. ``["SCAN", "DRVH", "DRVL"]``), or ``"*"`` 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.
'''
if isinstance(field, (list, tuple)):
for f in field:
self.on_field_change(f, callback)
return
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 remove_field_callback(self, field, callback):
'''Remove a specific *callback* previously registered for *field*.

Args:
field: Field name (e.g. ``"SCAN"``) or ``"*"``.
callback: The exact callable that was passed to
:meth:`on_field_change`.

Raises:
ValueError: If *callback* is not registered for *field*.
'''
field = field.upper() if field != "*" else "*"
try:
self.__field_callbacks[field].remove(callback)
except (KeyError, ValueError):
raise ValueError(
f"Callback not registered for field {field!r}")
# Clean up empty lists to keep the dict tidy.
if not self.__field_callbacks[field]:
del self.__field_callbacks[field]

def clear_field_callbacks(self, field=None):
'''Remove all field-change callbacks, or all for a single *field*.

Args:
field: If ``None`` (default), remove **all** callbacks on
this record. Otherwise, remove only those registered for
the given field name (or ``"*"``).

Raises:
KeyError: If *field* is given but has no registered callbacks.
'''
if field is None:
self.__field_callbacks.clear()
else:
field = field.upper() if field != "*" else "*"
try:
del self.__field_callbacks[field]
except KeyError:
raise KeyError(
f"No callbacks registered for field {field!r}")

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 All @@ -182,9 +282,39 @@ def process_severity(self, record, severity, alarm):
record.NSTA = alarm

def trigger(self):
'''Call this to trigger processing for records with I/O Intr scan.'''
'''Trigger immediate record processing.

Uses scanIoRequest for I/O Intr records (fast path).
Falls back to scanOnce for records on any other SCAN setting.

Only one path fires per call: scanIoRequest returns non-zero
when the record is on the I/O Intr scan list, so scanOnce is
skipped. When the record has been moved to a different scan
list scanIoRequest returns zero and scanOnce takes over.

Known limitation — periodic SCAN and double processing:

When SCAN is set to a periodic rate (e.g. "1 second"), the
EPICS scan thread calls dbProcess on its own schedule, and
set() also calls scanOnce → dbProcess. The record is
processed by both paths. dbScanLock serialises access and
recGblCheckDeadband prevents duplicate monitors when the
value has not changed, so this is harmless in practice but
does result in extra processing cycles.

To avoid this, use ``iocInit(auto_reset_scan=True)``.
The C hook resets SCAN back to I/O Intr after forwarding
the requested rate to the Python callback, so the record
stays on the I/O Intr scan list and scanIoRequest remains
the only processing path. Passive writes are exempt from
the reset (they are a deliberate "stop updating" command).
'''
queued = False
if self.__ioscanpvt:
imports.scanIoRequest(self.__ioscanpvt)
queued = imports.scanIoRequest(self.__ioscanpvt)
if not queued and hasattr(self, '_record') \
and self._record is not None:
imports.scan_once(self._record.record.value)



Expand Down
129 changes: 127 additions & 2 deletions softioc/extension.c
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
#define db_accessHFORdb_accessC // Needed to get correct DBF_ values
#include <dbAccess.h>
#include <dbFldTypes.h>
#include <dbScan.h>
#include <menuScan.h>
#include <callback.h>
#include <dbStaticLib.h>
#include <asTrapWrite.h>
Expand Down Expand Up @@ -269,15 +271,114 @@ void EpicsPvPutHook(struct asTrapWriteMessage *pmessage, int after)
}


/* Guard against double-registration of EpicsPvPutHook: asTrapWrite
* maintains a linked list of listeners, and registering the same function
* pointer twice would cause double printf output per write. */
static int epics_pv_put_hook_installed = 0;

static PyObject *install_pv_logging(PyObject *self, PyObject *args)
{
const char *acf_file;
int log_puts = 1; /* default: register the printf hook (backward compat) */

if (!PyArg_ParseTuple(args, "s", &acf_file))
if (!PyArg_ParseTuple(args, "s|p", &acf_file, &log_puts))
return NULL;

asSetFilename(acf_file);
asTrapWriteRegisterListener(EpicsPvPutHook);
if (log_puts && !epics_pv_put_hook_installed) {
asTrapWriteRegisterListener(EpicsPvPutHook);
epics_pv_put_hook_installed = 1;
}
Py_RETURN_NONE;
}


/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
/* 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;

/* When non-zero, FieldWriteHook resets SCAN back to I/O Intr after
* forwarding the notification to Python — unless Passive was requested.
* This eliminates periodic-scan contention: SCAN acts as a latched
* command that Python reads and then the record returns to I/O Intr. */
static int auto_reset_scan = 0;

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);

/* Auto-reset SCAN to I/O Intr after forwarding the notification,
* unless the client set Passive (meaning "stop updating"). */
if (auto_reset_scan) {
const char *dot = strrchr(channel_name, '.');
if (dot && strcmp(dot + 1, "SCAN") == 0
&& strcmp(value_str, "Passive") != 0) {
epicsEnum16 io_intr = menuScanI_O_Intr;
dbScanLock(pchan->addr.precord);
dbPut(&pchan->addr, DBR_ENUM, &io_intr, 1);
dbScanUnlock(pchan->addr.precord);
}
}
}

/* Guard against double-registration of FieldWriteHook — same rationale
* as epics_pv_put_hook_installed above. */
static int field_write_hook_installed = 0;

static PyObject *register_field_write_listener(PyObject *self, PyObject *args)
{
PyObject *callback;
int reset_scan = 0;
if (!PyArg_ParseTuple(args, "O|p", &callback, &reset_scan))
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;
auto_reset_scan = reset_scan;
if (!field_write_hook_installed) {
asTrapWriteRegisterListener(FieldWriteHook);
field_write_hook_installed = 1;
}
Py_RETURN_NONE;
}

Expand Down Expand Up @@ -321,6 +422,26 @@ static PyObject *signal_processing_complete(PyObject *self, PyObject *args)
}


/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
/* CLS extension: scanOnce wrapper.
*
* Queues immediate record processing via the EPICS Base scanOnce() API,
* which works regardless of the record's current SCAN setting.
*/

static PyObject *scan_once(PyObject *self, PyObject *args)
{
Py_ssize_t record_ptr;
if (!PyArg_ParseTuple(args, "n", &record_ptr))
return NULL;
dbCommon *precord = (dbCommon *)record_ptr;
Py_BEGIN_ALLOW_THREADS
scanOnce(precord);
Py_END_ALLOW_THREADS
Py_RETURN_NONE;
}


/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
/* Initialisation. */

Expand All @@ -339,6 +460,10 @@ 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)"},
{"scan_once", scan_once, METH_VARARGS,
"Queue immediate record processing via scanOnce() (CLS extension)"},
{NULL, NULL, 0, NULL} /* Sentinel */
};

Expand Down
Loading