diff --git a/setup.cfg b/setup.cfg index 92154b4f..c4b141e6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -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 diff --git a/softioc/device_core.py b/softioc/device_core.py index cfc5e8c3..67cf0b32 100644 --- a/softioc/device_core.py +++ b/softioc/device_core.py @@ -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 diff --git a/softioc/extension.c b/softioc/extension.c index 62f579ba..0127f8ed 100644 --- a/softioc/extension.c +++ b/softioc/extension.c @@ -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. */ @@ -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 */ }; diff --git a/softioc/field_monitor.py b/softioc/field_monitor.py new file mode 100644 index 00000000..c68076be --- /dev/null +++ b/softioc/field_monitor.py @@ -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) diff --git a/softioc/imports.py b/softioc/imports.py index c27dd177..7c1c70e8 100644 --- a/softioc/imports.py +++ b/softioc/imports.py @@ -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() diff --git a/softioc/softioc.py b/softioc/softioc.py index 8a439a2f..cb1a48d5 100644 --- a/softioc/softioc.py +++ b/softioc/softioc.py @@ -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.''' diff --git a/tests/conftest.py b/tests/conftest.py index 04e13b04..0217e6cb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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("") diff --git a/tests/sim_field_callbacks_ioc.py b/tests/sim_field_callbacks_ioc.py new file mode 100644 index 00000000..62460887 --- /dev/null +++ b/tests/sim_field_callbacks_ioc.py @@ -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 diff --git a/tests/test_field_callbacks.py b/tests/test_field_callbacks.py new file mode 100644 index 00000000..b68ed519 --- /dev/null +++ b/tests/test_field_callbacks.py @@ -0,0 +1,209 @@ +"""Tests for the CLS ``on_field_change`` extension. + +The IOC runs in a subprocess (``sim_field_callbacks_ioc.py``) so that it has +its own EPICS database and Channel Access / PV Access servers — exactly the +same architecture used by the other pythonSoftIOC tests. + +What is tested +-------------- +* CA write to a non-VAL control field (SCAN, DISA) fires the + registered callback. +* CA write to VAL fires the VAL callback. +* CA write to an alarm field (HIHI) fires its callback. + Alarm fields are ``DBF_DOUBLE``; this confirms no special + casing vs other types. +* CA write to a string metadata field (DESC) fires its callback. + DESC is ``DBF_STRING``; this confirms the + ``dbGetField(DBR_STRING)`` path works for fields whose native + type is already a string. +* PVA write to VAL fires the VAL callback. +* PVA write to a non-VAL field (SCAN) fires the callback. + This is the only protocol difference that needs explicit verification — + pvxs uses a different field-addressing model for subfields. +* Multiple writes to the same field accumulate correctly in the counter. +* A callback on one field does not increment another field's counter. +* A wildcard ``"*"`` callback fires for every field write. +""" + +import asyncio + +import pytest + +from multiprocessing.connection import Listener + +from conftest import ( + ADDRESS, + select_and_recv, + aioca_cleanup, + TIMEOUT, +) + + +# --------------------------------------------------------------------------- +# CA tests +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_field_callbacks_ca(field_callbacks_ioc): + """CA puts to SCAN, DISA and VAL each fire the correct callback.""" + from aioca import caget, caput + + pre = field_callbacks_ioc.pv_prefix + + with Listener(ADDRESS) as listener, listener.accept() as conn: + select_and_recv(conn, "R") + + try: + # -- SCAN field -- + await caput(pre + ":AO.SCAN", "1 second", wait=True) + await asyncio.sleep(0.5) + assert await caget(pre + ":SCAN-CB-CNT") == 1 + + # -- DISA field -- + await caput(pre + ":AO.DISA", 1, wait=True) + await asyncio.sleep(0.5) + assert await caget(pre + ":DISA-CB-CNT") == 1 + + # -- VAL field via CA -- + await caput(pre + ":AO", 42.0, wait=True) + await asyncio.sleep(0.5) + assert await caget(pre + ":VAL-CB-CNT") == 1 + + # -- Multiple SCAN writes accumulate -- + await caput(pre + ":AO.SCAN", "2 second", wait=True) + await caput(pre + ":AO.SCAN", ".5 second", wait=True) + await asyncio.sleep(0.5) + assert await caget(pre + ":SCAN-CB-CNT") == 3 + + # -- Isolation: SCAN writes do not affect DISA counter -- + assert await caget(pre + ":DISA-CB-CNT") == 1 + + # -- Wildcard: every write (3 SCAN + 1 DISA + 1 VAL = 5) -- + assert await caget(pre + ":ANY-CB-CNT") == 5 + finally: + aioca_cleanup() + conn.send("D") + select_and_recv(conn, "D") + + +# --------------------------------------------------------------------------- +# PVA test +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_field_callbacks_pva(field_callbacks_ioc): + """A PVA put to VAL fires the VAL callback and the wildcard callback.""" + from aioca import caget + from p4p.client.asyncio import Context + + pre = field_callbacks_ioc.pv_prefix + + with Listener(ADDRESS) as listener, listener.accept() as conn: + select_and_recv(conn, "R") + + try: + with Context("pva") as ctx: + await asyncio.wait_for( + ctx.put(pre + ":AO", 99.0), timeout=TIMEOUT + ) + + await asyncio.sleep(0.5) + assert await caget(pre + ":VAL-CB-CNT") == 1 + assert await caget(pre + ":ANY-CB-CNT") == 1 # wildcard + finally: + aioca_cleanup() + conn.send("D") + select_and_recv(conn, "D") + + +# --------------------------------------------------------------------------- +# Field-type coverage tests +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_field_callbacks_alarm_field(field_callbacks_ioc): + """Alarm field (HIHI, DBF_DOUBLE) uses the same callback path as VAL. + + This is the key "no special casing" assertion: alarm limits go through + asTrapWrite identically to any other writeable field. + """ + from aioca import caget, caput + + pre = field_callbacks_ioc.pv_prefix + + with Listener(ADDRESS) as listener, listener.accept() as conn: + select_and_recv(conn, "R") + + try: + await caput(pre + ":AO.HIHI", 95.0, wait=True) + await asyncio.sleep(0.5) + assert await caget(pre + ":HIHI-CB-CNT") == 1 + assert await caget(pre + ":ANY-CB-CNT") == 1 # wildcard also fires + # Other counters must remain zero — no cross-field leakage. + assert await caget(pre + ":VAL-CB-CNT") == 0 + assert await caget(pre + ":SCAN-CB-CNT") == 0 + finally: + aioca_cleanup() + conn.send("D") + select_and_recv(conn, "D") + + +@pytest.mark.asyncio +async def test_field_callbacks_string_field(field_callbacks_ioc): + """String metadata field (DESC, DBF_STRING) is captured correctly. + + The C hook reads every value through ``dbGetField(DBR_STRING)``. For + fields whose native type is already a string (like DESC) the value must + round-trip without corruption. + """ + from aioca import caget, caput + + pre = field_callbacks_ioc.pv_prefix + + with Listener(ADDRESS) as listener, listener.accept() as conn: + select_and_recv(conn, "R") + + try: + await caput(pre + ":AO.DESC", "test label", wait=True) + await asyncio.sleep(0.5) + assert await caget(pre + ":DESC-CB-CNT") == 1 + assert await caget(pre + ":ANY-CB-CNT") == 1 + # Other counters must remain zero. + assert await caget(pre + ":VAL-CB-CNT") == 0 + assert await caget(pre + ":HIHI-CB-CNT") == 0 + finally: + aioca_cleanup() + conn.send("D") + select_and_recv(conn, "D") + + +@pytest.mark.asyncio +async def test_field_callbacks_pva_non_val(field_callbacks_ioc): + """A PVA put to a non-VAL field (SCAN) fires the callback. + + pvxs addresses subfields differently from VAL writes. This test + confirms that the asTrapWrite hook fires regardless of which field + a PVA client targets. + """ + from aioca import caget + from p4p.client.asyncio import Context + + pre = field_callbacks_ioc.pv_prefix + + with Listener(ADDRESS) as listener, listener.accept() as conn: + select_and_recv(conn, "R") + + try: + with Context("pva") as ctx: + await asyncio.wait_for( + ctx.put(pre + ":AO.SCAN", "1 second"), timeout=TIMEOUT + ) + + await asyncio.sleep(0.5) + assert await caget(pre + ":SCAN-CB-CNT") == 1 + assert await caget(pre + ":ANY-CB-CNT") == 1 # wildcard + assert await caget(pre + ":VAL-CB-CNT") == 0 # not triggered + finally: + aioca_cleanup() + conn.send("D") + select_and_recv(conn, "D")