feat: Universal set() via scanOnce(), callback refinements, and auto_reset_scan#206
Open
BoredDadDev wants to merge 2 commits intoDiamondLightSource:masterfrom
Open
feat: Universal set() via scanOnce(), callback refinements, and auto_reset_scan#206BoredDadDev wants to merge 2 commits intoDiamondLightSource:masterfrom
set() via scanOnce(), callback refinements, and auto_reset_scan#206BoredDadDev wants to merge 2 commits intoDiamondLightSource:masterfrom
Conversation
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
Universal set() — PR 2: - trigger() falls back to scanOnce() when SCAN != 'I/O Intr', so set() publishes immediately regardless of what the SCAN field shows. scanIoRequest return value is now checked; scanOnce fires only when scanIoRequest did not queue the record (never both on the same cycle). - iocInit() accepts auto_reset_scan=True: after forwarding a SCAN write to Python, the C hook resets SCAN to 'I/O Intr' so the record stays on the I/O Intr scan list — eliminating periodic-scan contention. Callback refinements — building on PR 1: - on_field_change() now accepts a list of field names (multi-field subscription with one callback). - log_puts=False added to iocInit() to suppress the built-in CA put logger without disabling field-change callbacks. - remove_field_callback(field, cb) de-registers one callback. - clear_field_callbacks(field=None) removes all callbacks for a field or all callbacks on the record. - record.field_callbacks read-only dict view of registered callbacks. - C double-registration guard added: FieldWriteHook can only be installed once even if iocInit() is called multiple times. Tests: 25 dedicated tests (test_universal_set.py 16, test_callback_refinements.py 9). Full suite at this point: 363 passed, 16 skipped, 0 failures.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR makes
set()work regardless of a record's SCAN setting, addsde-registration and multi-field subscription to the field-change callback
API introduced in PR1, and adds an
auto_reset_scanoption toiocInit()to handle the periodic-scan contention that arises when clients change SCAN.
Builds on #205 (
feature/field-change-callbacks).Motivation
PR1 made it possible for Python to react when a client changes SCAN. But
that immediately exposed a new problem: once the client moves the record off
I/O Intr,set()breaks silently --scanIoRequest()returns zero andthe value sits unpublished until something else calls
dbProcess().Making SCAN field changes meaningful requires that
set()continues topublish regardless of the SCAN setting.
Additionally, once callbacks could fire for SCAN writes we needed to be
able to de-register them (audit/testing), and the natural next step was
to support subscribing to a list of fields in one call.
What changed
New files
tests/sim_universal_set_ioc.pyset()universality teststests/test_universal_set.pytests/sim_callback_refinements_ioc.pytests/test_callback_refinements.pytests/sim_field_behavior_ioc.pytests/test_field_behavior.pyModified files
softioc/device_core.pyremove_field_callback(),clear_field_callbacks(); extendon_field_change()to accept a list/tuple of fields; updatetrigger()withscanOncefallbacksoftioc/extension.cscan_once()C wrapper forscanOnce();auto_reset_scanflag + SCAN-reset logic inFieldWriteHook; double-registration guard for both hookssoftioc/field_monitor.pyinstall_field_monitor()to accept and forwardauto_reset_scansoftioc/imports.pyscan_once()ctypes wrapper; refactorinstall_pv_logging()to acceptlog_putsflag; updateregister_field_write_listener()to forwardauto_reset_scan; setscanIoRequest.restype = c_uintso Python sees the return valuesoftioc/softioc.pyiocInit()signature to addlog_puts=Trueandauto_reset_scan=False; pass flags through toinstall_pv_loggingandinstall_field_monitortests/conftest.pyHow it works
1. Universal
set()--scanOncefallback intrigger()trigger()now usesscanIoRequest()'s return value.scanIoRequest()returns a non-zero bitmask if it queued records, or zero if the I/O Intr
scan list was empty (meaning the record is on a different scan list). When
it returns zero,
trigger()falls back toscanOnce():Only one path fires per call.
scanIoRequestwas already in use; the onlychange is capturing its return value (requires setting
restype = c_uintonthe ctypes wrapper).
The
scan_onceC wrapper is minimal:2.
auto_reset_scan-- SCAN as a latched commandWhen
iocInit(auto_reset_scan=True),FieldWriteHookresets SCAN back to"I/O Intr"immediately after forwarding the write to the Python callback --unless the client wrote
"Passive"(which is treated as a deliberate"stop updating" intent and is exempt).
The reset uses
dbPutwithDBR_ENUMinsidedbScanLock/dbScanUnlock.EPICS Base's
SPC_SCANspecial processing fires, correctly moving the recordback to the I/O Intr scan list. An internal
dbPutdoes not triggerasTrapWrite, so there is no callback loop.In this mode
scanIoRequestremains the only processing path -- noperiodic-scan contention, no extra processing cycles.
3. Callback API refinements
on_field_changenow accepts alistortupleas thefieldargument andregisters the callback for each field individually.
4.
log_putsflagLoads the access-security file (required for
asTrapWritelisteners) withoutregistering the
EpicsPvPutHookprintf logger. Useful when callback trafficis high and per-put console output is unwanted. Default
True-- existingbehavior is unchanged.
Behavior after this PR
set()before PR2set()after PR2scanOnce()scanOnce()scanOnce()scanOnce()Preserving original behavior
scanIoRequestpath unchanged: The existing I/O Intr fast path isunmodified. The only change is reading the return value.
scanOnceonly fireswhen
scanIoRequestreturned zero -- the two paths are mutually exclusive.log_puts=Trueby default: The printf caput logger remains on unlessexplicitly disabled.
auto_reset_scan=Falseby default: SCAN behavior is unchanged unlessopted in.
on_field_changecalls unaffected: Single-string and wildcardregistrations work identically; list support is purely additive.
Known limitation -- periodic SCAN and double processing
When
auto_reset_scan=False(the default) and a client sets SCAN to aperiodic rate, the EPICS scan thread calls
dbProcess()on its own schedulein addition to
set()->scanOnce()->dbProcess().dbScanLockserializes access and
recGblCheckDeadbandsuppresses duplicate CA monitors,so clients don't see phantom updates -- but the extra processing cycles are
real. Use
auto_reset_scan=Trueto eliminate this.Test results
The 16 skips are
@requires_cothreadtests on non-cothread platforms -- sameon both branches.
Design review
SOLID compliance
trigger()handles record processing; the SCANreset lives entirely in the C
FieldWriteHook; the API additions(
remove_field_callback,clear_field_callbacks) are all onDeviceSupportCore.auto_reset_scanis opt-in; existing IOCs need zero changes.scan_onceis a drop-in complement forscanIoRequest-- both queuedbProcess(), just via different paths.Security review
scanOnceis only called whenscanIoRequestreturned zero -- the two code paths are mutually exclusive.
dbPutinsideFieldWriteHookdoes notre-trigger
asTrapWrite, soauto_reset_scancannot produce a callback loop.dbScanLock/dbScanUnlockaround
dbPut, matching the locking pattern used by EPICS Base itself forSCAN field changes.
scan_oncereceives the record pointer viaPy_ssize_t(the same pattern as the existing
trigger()implementation). No rawinteger-to-pointer cast outside of ctypes-managed memory.
remove_field_callbackandclear_field_callbacksonly run from Python (GIL held); the C hook onlyreads the callback store after acquiring the GIL -- no concurrent modification.
Code metrics checklist
scan_once,auto_reset_scan,remove_field_callback)