Skip to content

Commit 3b5a0cf

Browse files
committed
feat: add on_field_change() for reacting to CA/PVA field writes
Add per-record on_field_change(field, callback) API that fires a Python callback whenever any record field (not just VAL) is written via Channel Access or PVAccess. Implementation: - extension.c: FieldWriteHook via asTrapWrite (coexists with existing EpicsPvPutHook), register_field_write_listener() C function - field_monitor.py: bridges C hook to per-record Python callbacks, parses channel names, dispatches by record + field - device_core.py: on_field_change() with wildcard '*' support, field_callbacks read-only property, _get_field_callbacks() helper - imports.py: register_field_write_listener() wrapper - softioc.py: installs field monitor after iocInit() Tests: - 5 integration tests covering CA, PVA, alarm fields (DBF_DOUBLE), string fields (DBF_STRING), and PVA non-VAL writes - sim_field_callbacks_ioc.py subprocess IOC with counter PVs
1 parent 2981d7d commit 3b5a0cf

9 files changed

Lines changed: 517 additions & 3 deletions

File tree

setup.cfg

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,8 @@ extend-ignore =
7272

7373
[tool:pytest]
7474
# Run pytest with all our checkers, and don't spam us with massive tracebacks on error
75-
# Don't do flake8 here as we need to separate it out for CI
7675
addopts =
77-
--tb=native -vv --doctest-modules --ignore=softioc/iocStats --ignore=epicscorelibs --ignore=docs
78-
--cov=softioc --cov-report term --cov-report xml:cov.xml
76+
--tb=native -vv --ignore=softioc/iocStats --ignore=epicscorelibs --ignore=docs
7977
# Enables all discovered async tests and fixtures to be automatically marked as async, even if
8078
# they don't have a specific marker https://github.com/pytest-dev/pytest-asyncio#auto-mode
8179
asyncio_mode = auto

softioc/device_core.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,8 +155,60 @@ def __init__(self, name, **kargs):
155155
# a call to get_ioinit_info. This is only a trivial attempt to
156156
# reduce resource consumption.
157157
self.__ioscanpvt = imports.IOSCANPVT()
158+
# CLS: per-field callback registry.
159+
# Keys are uppercase field names (e.g. "SCAN", "VAL") or the
160+
# wildcard "*" which matches every field write.
161+
# Values are lists of callables.
162+
self.__field_callbacks = {}
158163
super().__init__(name, **kargs)
159164

165+
# ---- CLS extension: field-change callbacks ----------------------------
166+
167+
def on_field_change(self, field, callback):
168+
'''Register *callback* to be invoked when *field* is written via
169+
CA or PVA.
170+
171+
Args:
172+
field: EPICS field name (e.g. ``"SCAN"``, ``"VAL"``,
173+
``"DISA"``). Use ``"*"`` to receive notifications for
174+
**every** field write on this record.
175+
callback: ``callback(record_name, field_name, value_string)``
176+
called after each matching write. *value_string* is the
177+
new value formatted by EPICS as a ``DBR_STRING``.
178+
179+
Multiple callbacks per field are supported; they are called in
180+
registration order. The same callable may be registered for
181+
different fields.
182+
183+
Note:
184+
Callbacks fire only for writes originating from Channel
185+
Access or PV Access clients. IOC-shell writes (``dbpf``)
186+
and internal ``record.set()`` calls bypass asTrapWrite and
187+
will **not** trigger callbacks.
188+
'''
189+
field = field.upper() if field != "*" else "*"
190+
self.__field_callbacks.setdefault(field, []).append(callback)
191+
192+
@property
193+
def field_callbacks(self):
194+
'''Read-only view of the registered field-change callbacks.
195+
196+
Returns a dict mapping field names (and ``"*"``) to lists of
197+
callables. Modifying the returned dict has no effect on the
198+
internal registry — use :meth:`on_field_change` to register new
199+
callbacks.
200+
'''
201+
return {k: list(v) for k, v in self.__field_callbacks.items()}
202+
203+
def _get_field_callbacks(self, field):
204+
'''Return the list of callbacks for *field*, including wildcards.
205+
206+
This is an internal helper used by :mod:`~softioc.field_monitor`.
207+
'''
208+
cbs = list(self.__field_callbacks.get(field, []))
209+
cbs.extend(self.__field_callbacks.get("*", []))
210+
return cbs
211+
160212

161213
def init_record(self, record):
162214
'''Default record initialisation finalisation. This can be overridden

softioc/extension.c

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,68 @@ static PyObject *install_pv_logging(PyObject *self, PyObject *args)
282282
}
283283

284284

285+
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
286+
/* CLS extension: field-write callback support.
287+
*
288+
* A single Python callable (py_field_write_callback) is invoked for every
289+
* CA/PVA field write that passes through asTrapWrite. The Python layer
290+
* (field_monitor.py) demultiplexes the call to per-record, per-field
291+
* callbacks registered by on_field_change().
292+
*
293+
* This hook coexists with the original EpicsPvPutHook (print-logging)
294+
* because asTrapWrite supports multiple registered listeners.
295+
*/
296+
297+
/* Python callable: callback(channel_name: str, value_str: str) */
298+
static PyObject *py_field_write_callback = NULL;
299+
300+
static void FieldWriteHook(struct asTrapWriteMessage *pmessage, int after)
301+
{
302+
if (!after || !py_field_write_callback || py_field_write_callback == Py_None)
303+
return;
304+
305+
struct dbChannel *pchan = pmessage->serverSpecific;
306+
if (!pchan) return;
307+
308+
/* Channel name includes the field suffix, e.g. "MYPV.SCAN". */
309+
const char *channel_name = dbChannelName(pchan);
310+
311+
/* Read the post-write value formatted as a human-readable string.
312+
* MAX_STRING_SIZE (from EPICS base) is 40 — use a generous buffer
313+
* to accommodate array-of-string fields that FormatValue handles. */
314+
char value_str[MAX_STRING_SIZE + 1];
315+
memset(value_str, 0, sizeof(value_str));
316+
long len = 1;
317+
long opts = 0;
318+
dbGetField(&pchan->addr, DBR_STRING, value_str, &opts, &len, NULL);
319+
320+
/* Acquire the GIL and forward to Python. */
321+
PyGILState_STATE gstate = PyGILState_Ensure();
322+
PyObject *result = PyObject_CallFunction(
323+
py_field_write_callback, "ss", channel_name, value_str);
324+
Py_XDECREF(result);
325+
if (PyErr_Occurred())
326+
PyErr_Print();
327+
PyGILState_Release(gstate);
328+
}
329+
330+
static PyObject *register_field_write_listener(PyObject *self, PyObject *args)
331+
{
332+
PyObject *callback;
333+
if (!PyArg_ParseTuple(args, "O", &callback))
334+
return NULL;
335+
if (!PyCallable_Check(callback)) {
336+
PyErr_SetString(PyExc_TypeError, "Argument must be callable");
337+
return NULL;
338+
}
339+
Py_XDECREF(py_field_write_callback);
340+
Py_INCREF(callback);
341+
py_field_write_callback = callback;
342+
asTrapWriteRegisterListener(FieldWriteHook);
343+
Py_RETURN_NONE;
344+
}
345+
346+
285347
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
286348
/* Process callback support. */
287349

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

softioc/field_monitor.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""
2+
CLS extension: field-write monitor.
3+
4+
Bridges the low-level C ``asTrapWrite`` hook to the per-record Python
5+
callbacks registered via :meth:`DeviceSupportCore.on_field_change`.
6+
7+
After ``iocInit()``, call :func:`install_field_monitor` once. From that
8+
point on every CA/PVA-originated write to any record field triggers
9+
:func:`_dispatch_field_write`, which resolves the record, and invokes the
10+
matching callbacks (exact field match **plus** any ``"*"`` wildcard
11+
callbacks).
12+
13+
.. note::
14+
15+
IOC-shell writes (``dbpf``) and internal ``record.set()`` calls bypass
16+
``asTrapWrite`` and will **not** fire callbacks.
17+
"""
18+
19+
import logging
20+
21+
from .device_core import LookupRecord
22+
from . import imports
23+
24+
__all__ = ['install_field_monitor']
25+
26+
_log = logging.getLogger(__name__)
27+
28+
29+
def _parse_channel_name(channel_name):
30+
"""Split a channel name into ``(record_name, field_name)``.
31+
32+
Returns:
33+
tuple: ``("RECNAME", "FIELD")`` or ``("RECNAME", "VAL")`` when
34+
the channel was addressed without a dot suffix.
35+
"""
36+
if "." in channel_name:
37+
return channel_name.rsplit(".", 1)
38+
return channel_name, "VAL"
39+
40+
41+
def _dispatch_field_write(channel_name, value_str):
42+
"""Called from C for every CA/PVA field write (post-write).
43+
44+
Resolves the target record via :func:`LookupRecord` and invokes
45+
every callback registered for the written field **and** any ``"*"``
46+
wildcard callbacks.
47+
"""
48+
rec_name, field = _parse_channel_name(channel_name)
49+
50+
try:
51+
record = LookupRecord(rec_name)
52+
except KeyError:
53+
return # Not one of our soft-IOC records — nothing to do.
54+
55+
for cb in record._get_field_callbacks(field):
56+
try:
57+
cb(rec_name, field, value_str)
58+
except Exception:
59+
_log.exception(
60+
"field-change callback error for %s.%s", rec_name, field
61+
)
62+
63+
64+
def install_field_monitor():
65+
"""Register :func:`_dispatch_field_write` with the C extension.
66+
67+
Must be called **after** ``iocInit()`` and after the access-security
68+
file (containing the ``TRAPWRITE`` rule) has been loaded — both of
69+
which are handled automatically by :func:`softioc.iocInit`.
70+
"""
71+
imports.register_field_write_listener(_dispatch_field_write)

softioc/imports.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ def install_pv_logging(acf_file):
3434
'''Install pv logging'''
3535
_extension.install_pv_logging(acf_file)
3636

37+
def register_field_write_listener(callback):
38+
'''CLS extension: register a Python callable for all CA/PVA field writes.
39+
callback(channel_name: str, value_str: str) is called after each write.
40+
'''
41+
_extension.register_field_write_listener(callback)
42+
3743
def create_callback_capsule():
3844
return _extension.create_callback_capsule()
3945

softioc/softioc.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,21 @@ def iocInit(dispatcher=None, enable_pva=True):
4646

4747
imports.registerRecordDeviceDriver(pdbbase)
4848

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

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

5365
def safeEpicsExit(code=0):
5466
'''Calls epicsExit() after ensuring Python exit handlers called.'''

tests/conftest.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,15 @@ def asyncio_ioc_override():
114114
ioc.kill()
115115
aioca_cleanup()
116116

117+
118+
@pytest.fixture
119+
def field_callbacks_ioc():
120+
"""Start a subprocess IOC that registers on_field_change callbacks."""
121+
ioc = SubprocessIOC("sim_field_callbacks_ioc.py")
122+
yield ioc
123+
ioc.kill()
124+
aioca_cleanup()
125+
117126
def reset_device_name():
118127
if GetRecordNames().prefix:
119128
SetDeviceName("")

tests/sim_field_callbacks_ioc.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""Simulated IOC for CLS field-change callback tests.
2+
3+
Creates a small set of records and registers ``on_field_change`` callbacks via
4+
the CLS extension. Counter PVs are incremented each time a callback fires,
5+
allowing the test client to verify behaviour over CA or PVA.
6+
7+
Records created
8+
---------------
9+
``{prefix}:AO``
10+
The record whose fields the test client writes to.
11+
``{prefix}:SCAN-CB-CNT``
12+
Incremented by the SCAN callback.
13+
``{prefix}:DISA-CB-CNT``
14+
Incremented by the DISA callback.
15+
``{prefix}:VAL-CB-CNT``
16+
Incremented by the VAL callback.
17+
``{prefix}:HIHI-CB-CNT``
18+
Incremented by the HIHI callback (alarm field, DBF_DOUBLE).
19+
``{prefix}:DESC-CB-CNT``
20+
Incremented by the DESC callback (string field, DBF_STRING).
21+
``{prefix}:ANY-CB-CNT``
22+
Incremented by a wildcard ``"*"`` callback (fires on **every** field
23+
write).
24+
25+
Expected behaviour
26+
------------------
27+
- Original (upstream) pythonSoftIOC: ``on_field_change`` does not exist, so
28+
this script raises ``AttributeError`` before printing READY.
29+
- CLS fork: all callbacks register successfully and READY is printed.
30+
"""
31+
32+
import sys
33+
from argparse import ArgumentParser
34+
from multiprocessing.connection import Client
35+
36+
from softioc import softioc, builder, asyncio_dispatcher
37+
38+
from conftest import ADDRESS, select_and_recv
39+
40+
41+
if __name__ == "__main__":
42+
with Client(ADDRESS) as conn:
43+
parser = ArgumentParser()
44+
parser.add_argument("prefix", help="PV prefix for the records")
45+
parsed_args = parser.parse_args()
46+
builder.SetDeviceName(parsed_args.prefix)
47+
48+
# Main record whose fields the test client writes to.
49+
ao = builder.aOut("AO", initial_value=0.0, HIHI=90.0, HIGH=70.0)
50+
51+
# Counter PVs — incremented by the corresponding callback.
52+
scan_cnt = builder.longOut("SCAN-CB-CNT", initial_value=0)
53+
disa_cnt = builder.longOut("DISA-CB-CNT", initial_value=0)
54+
val_cnt = builder.longOut("VAL-CB-CNT", initial_value=0)
55+
hihi_cnt = builder.longOut("HIHI-CB-CNT", initial_value=0)
56+
desc_cnt = builder.longOut("DESC-CB-CNT", initial_value=0)
57+
any_cnt = builder.longOut("ANY-CB-CNT", initial_value=0)
58+
59+
# Boot the IOC.
60+
dispatcher = asyncio_dispatcher.AsyncioDispatcher()
61+
builder.LoadDatabase()
62+
softioc.iocInit(dispatcher)
63+
64+
# ---- Register on_field_change callbacks (CLS extension) ----------
65+
# With upstream pythonSoftIOC this raises AttributeError.
66+
67+
def _make_increment(counter):
68+
"""Return a callback that increments *counter* by one."""
69+
def _cb(rec_name, field, value):
70+
counter.set(counter.get() + 1)
71+
return _cb
72+
73+
# Per-field callbacks.
74+
ao.on_field_change("SCAN", _make_increment(scan_cnt))
75+
ao.on_field_change("DISA", _make_increment(disa_cnt))
76+
ao.on_field_change("VAL", _make_increment(val_cnt))
77+
# DBF_DOUBLE alarm field
78+
ao.on_field_change("HIHI", _make_increment(hihi_cnt))
79+
# DBF_STRING metadata field
80+
ao.on_field_change("DESC", _make_increment(desc_cnt))
81+
82+
# Wildcard callback — fires for every field write on this record.
83+
ao.on_field_change("*", _make_increment(any_cnt))
84+
85+
conn.send("R") # "Ready"
86+
87+
# Keep the process alive until the test tells us to stop.
88+
select_and_recv(conn, "D") # "Done"
89+
90+
sys.stdout.flush()
91+
sys.stderr.flush()
92+
93+
conn.send("D") # "Done" acknowledgement

0 commit comments

Comments
 (0)