Skip to content

feat: add on_field_change() for reacting to CA/PVA field writes#205

Open
BoredDadDev wants to merge 1 commit intoDiamondLightSource:masterfrom
BoredDadDev:feature/field-change-callbacks
Open

feat: add on_field_change() for reacting to CA/PVA field writes#205
BoredDadDev wants to merge 1 commit intoDiamondLightSource:masterfrom
BoredDadDev:feature/field-change-callbacks

Conversation

@BoredDadDev
Copy link
Copy Markdown

Summary

This PR adds the ability to register Python callbacks that fire whenever an
external client (Channel Access or PV Access) writes to any field of a
soft-IOC record. This enables reactive logic for metadata fields like
SCAN, DISA, DESC, EGU, alarm limits, and any other writeable field
-- without modifying the EPICS core libraries.

Motivation

The upstream pythonSoftIOC provides on_update callbacks that fire when
the VAL field is written, but there is no mechanism to react to writes on
non-VAL fields (SCAN, DISA, DESC, HIHI, etc.). At CLS we need to:

  • Detect when an operator changes a record's scan rate and adjust polling.
  • Audit metadata changes for compliance.
  • React to alarm-limit modifications in real time.

What changed

New files

File Purpose
softioc/field_monitor.py Python-level dispatcher: routes C hook calls to per-record callbacks
tests/sim_field_callbacks_ioc.py Subprocess IOC for integration tests (follows upstream sim_*_ioc.py pattern)
tests/test_field_callbacks.py 5 CA + PVA integration tests covering control fields, alarm fields (DBF_DOUBLE), string fields (DBF_STRING), and PVA non-VAL puts

Modified files

File Change
softioc/extension.c +64 lines: FieldWriteHook C function + register_field_write_listener Python wrapper -- new asTrapWrite listener, coexists with the existing EpicsPvPutHook print-logger
softioc/device_core.py +52 lines: on_field_change(), field_callbacks property, _get_field_callbacks() on DeviceSupportCore
softioc/imports.py +6 lines: register_field_write_listener() ctypes wrapper
softioc/softioc.py +12 lines: load access-security file before iocInit; call install_field_monitor() after
tests/conftest.py +9 lines: field_callbacks_ioc pytest fixture
setup.cfg Minor version metadata update

How it works

CA/PVA client write
        |
        v
  asTrapWrite (EPICS core, access.acf TRAPWRITE rule)
        |
        +--> EpicsPvPutHook()    [ORIGINAL -- printf logging, UNCHANGED]
        |
        +--> FieldWriteHook()    [NEW -- acquires GIL, calls Python]
                |
                v
        _dispatch_field_write()   [field_monitor.py]
                |
                +-- LookupRecord(rec_name)
                +-- record._get_field_callbacks(field)
                |       +-- exact match callbacks
                |       +-- wildcard "*" callbacks
                +-- invoke each callback(rec_name, field, value_str)

Preserving original behavior

  1. Print logging (pvlog): The original EpicsPvPutHook remains completely
    untouched. Both hooks coexist because asTrapWriteRegisterListener
    supports multiple listeners. We ensure pvlog is imported before
    iocInit (which it was already in all upstream test IOCs), so the
    access.acf file is loaded and the print-logging hook is registered.

  2. No existing API changes: on_update still works as before. The new
    on_field_change is purely additive.

  3. No record behavior changes: Records process identically. The hook
    fires after the write completes, with the GIL acquired safely.

API

# After iocInit():
record.on_field_change("SCAN", callback)   # specific field
record.on_field_change("*", callback)      # wildcard -- all fields

# Read-only view of all registered callbacks:
record.field_callbacks  # -> {"SCAN": [...], "*": [...], ...}

# callback signature:
# callback(record_name: str, field_name: str, value_string: str)

De-registration (remove_field_callback, clear_field_callbacks) and
multi-field subscription (passing a list of fields) are included in PR2
along with the auto_reset_scan and log_puts extensions.

What fires callbacks

Source Fires?
CA client (caput) Yes
PVA client (pvput, p4p) Yes
Internal record.set() No
IOC shell dbpf No

Demonstration

A runnable demo (docs/demo_field_coverage.py, in the CLS workspace repo)
exercises 47 writeable fields of an aOut record via both CA and PVA,
capturing each on_field_change callback and printing a latency table.

Measured results on localhost (after channel warm-up):

Protocol Fields Callbacks fired Latency (min / avg / max)
CA 47 47 / 47 (100 %) 0.03 ms / 0.19 ms / 0.42 ms
PVA 47 47 / 47 (100 %) 0.03 ms / 0.22 ms / 1.84 ms

Key observations:

  • VAL and all alarm fields (HIHI, HIGH, LOW, LOLO, HHSV, ...)
    use the identical code path as every other field -- no special casing.
  • RTYP is readable via caget but not writeable, so no callback fires
    for it (expected).
  • The ~10-75 ms seen on the first PVA write is TCP connection setup;
    once channels are warm the latency matches CA.

Test results

Feature branch:  311 passed, 0 failed, 16 skipped  (1m57s)
Master baseline: 306 passed, 0 failed, 16 skipped  (1m49s)
Delta:           +5 new tests (test_field_callbacks_ca, test_field_callbacks_pva,
                 test_field_callbacks_alarm_field, test_field_callbacks_string_field,
                 test_field_callbacks_pva_non_val)
Regressions:     0

The 16 skips are @requires_cothread tests on non-cothread platforms -- same
on both branches.

Design review

SOLID compliance

  • Single Responsibility: field_monitor.py dispatches; device_core.py
    stores callbacks; extension.c bridges C/Python. Each module does one thing.
  • Open/Closed: Adding new field monitoring requires only calling
    on_field_change() -- no source modifications needed. The "*" wildcard
    makes the system extensible to fields we haven't thought of yet.
  • Dependency Inversion: The C layer depends on an abstract "callable"
    interface, not on concrete Python classes.

Security review

  • Buffer safety: C value buffer uses MAX_STRING_SIZE + 1 (41 bytes, the
    EPICS standard) with memset zeroing. No overflow possible because
    dbGetField with DBR_STRING respects MAX_STRING_SIZE.
  • GIL safety: PyGILState_Ensure/Release bracket all Python calls from
    the CA/PVA thread. No Python objects accessed outside the GIL.
  • Reference counting: Py_XDECREF on result; Py_INCREF/XDECREF on
    callback replacement. No leaks.
  • No user input in format strings: All printf/print use explicit
    format specifiers, never user-controlled format strings.
  • Exception isolation: Each callback is wrapped in try/except; a failing
    callback cannot crash the IOC or prevent other callbacks from running.
  • No new network surfaces: The hook intercepts existing EPICS writes --
    it does not open sockets, listen on ports, or accept new connections.

Code metrics checklist

  • Functions < 35 lines, cyclomatic complexity < 8
  • Clear, intention-revealing names (on_field_change, _dispatch_field_write)
  • Single responsibility per function/module
  • Comments explain "why" (not "what")
  • No dead code
  • Comprehensive docstrings with Args/Returns/Note sections
  • Tests pass (311/311)

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant