Skip to content

Commit 9c56552

Browse files
Add get_field and set_field methods to records
These allow access to all EPICS attributes on a record. Also add documentation and tests
1 parent dad702c commit 9c56552

File tree

6 files changed

+290
-1
lines changed

6 files changed

+290
-1
lines changed

CHANGELOG.rst

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Versioning <https://semver.org/spec/v2.0.0.html>`_.
1010
Unreleased_
1111
-----------
1212

13-
Nothing yet
13+
- 'Add get_field and set_field methods to records <../../pull/140>'_
1414

1515
4.4.0_ - 2023-07-06
1616
-------------------

docs/reference/api.rst

+42
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,27 @@ class which provides the methods documented below.
578578
Note that channel access puts to a Python soft IOC input record are
579579
completely ineffective, and this includes waveform records.
580580

581+
.. method:: get_field(field)
582+
583+
This returns the named field from the record. An exception will be raised
584+
if the field cannot be found.
585+
586+
Note that this function can only be used after the IOC has been initialized.
587+
If you need to retrieve a field's value before that, access it directly via
588+
an attribute e.g. ``my_record.EGU``. (This will not work after the IOC is
589+
initialized)
590+
591+
.. method:: set_field(field, value)
592+
593+
This sets the given field to the given value. The value will
594+
always be converted to a Python String, which is then interpreted by
595+
EPICS as a DBF_STRING type. Note that values can be no longer than 39 bytes.
596+
597+
Note that this function can only be used after the IOC has been initialized.
598+
If you need to set a field's value before that, set it directly as an attribute
599+
on the record e.g. ``my_record.EGU``. (This will not work after the IOC is
600+
initialized)
601+
581602
Working with OUT records
582603
~~~~~~~~~~~~~~~~~~~~~~~~
583604

@@ -599,4 +620,25 @@ Working with OUT records
599620

600621
Returns the value associated with the record.
601622

623+
.. method:: get_field(field)
624+
625+
This returns the named field from the record. An exception will be raised
626+
if the field cannot be found.
627+
628+
Note that this function can only be used after the IOC has been initialized.
629+
If you need to retrieve a field's value before that, access it directly via
630+
an attribute e.g. ``my_record.EGU``. (This will not work after the IOC is
631+
initialized)
632+
633+
.. method:: set_field(field, value)
634+
635+
This sets the given field to the given value. The value will
636+
always be converted to a Python String, which is then interpreted by
637+
EPICS as a DBF_STRING type. Note that values can be no longer than 39 bytes.
638+
639+
Note that this function can only be used after the IOC has been initialized.
640+
If you need to set a field's value before that, set it directly as an attribute
641+
on the record e.g. ``my_record.EGU``. (This will not work after the IOC is
642+
initialized)
643+
602644
.. _epics_device: https://github.com/Araneidae/epics_device

softioc/device.py

+21
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
signal_processing_complete,
1313
recGblResetAlarms,
1414
db_put_field,
15+
db_get_field,
1516
)
1617
from .device_core import DeviceSupportCore, RecordLookup
1718

@@ -83,6 +84,26 @@ def _read_value(self, record):
8384
def _write_value(self, record, value):
8485
record.write_val(value)
8586

87+
def get_field(self, field):
88+
''' Returns the given field value as a string.'''
89+
assert hasattr(self, "_record"), \
90+
'get_field may only be called after iocInit'
91+
92+
data = (c_char * 40)()
93+
name = self._name + '.' + field
94+
db_get_field(name, fields.DBF_STRING, addressof(data), 1)
95+
return _string_at(data, 40)
96+
97+
def set_field(self, field, value):
98+
'''Sets the given field to the given value. Value will be transported as
99+
a DBF_STRING.'''
100+
assert hasattr(self, "_record"), \
101+
'set_field may only be called after iocInit'
102+
103+
data = (c_char * 40)()
104+
data.value = str(value).encode() + b'\0'
105+
name = self._name + '.' + field
106+
db_put_field(name, fields.DBF_STRING, addressof(data), 1)
86107

87108
class ProcessDeviceSupportIn(ProcessDeviceSupportCore):
88109
_link_ = 'INP'

softioc/extension.c

+21
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,25 @@ static PyObject *db_put_field(PyObject *self, PyObject *args)
113113
Py_RETURN_NONE;
114114
}
115115

116+
static PyObject *db_get_field(PyObject *self, PyObject *args)
117+
{
118+
const char *name;
119+
short dbrType;
120+
void *pbuffer;
121+
long length;
122+
if (!PyArg_ParseTuple(args, "shnl", &name, &dbrType, &pbuffer, &length))
123+
return NULL;
124+
125+
long options = 0;
126+
struct dbAddr dbAddr;
127+
if (dbNameToAddr(name, &dbAddr))
128+
return PyErr_Format(
129+
PyExc_RuntimeError, "dbNameToAddr failed for %s", name);
130+
if (dbGetField(&dbAddr, dbrType, pbuffer, &options, &length, NULL))
131+
return PyErr_Format(
132+
PyExc_RuntimeError, "dbGetField failed for %s", name);
133+
Py_RETURN_NONE;
134+
}
116135

117136
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
118137
/* IOC PV put logging */
@@ -266,6 +285,8 @@ static struct PyMethodDef softioc_methods[] = {
266285
"Get offset, size and type for each record field"},
267286
{"db_put_field", db_put_field, METH_VARARGS,
268287
"Put a database field to a value"},
288+
{"db_get_field", db_get_field, METH_VARARGS,
289+
"Get a database field's value"},
269290
{"install_pv_logging", install_pv_logging, METH_VARARGS,
270291
"Install caput logging to stdout"},
271292
{"signal_processing_complete", signal_processing_complete, METH_VARARGS,

softioc/imports.py

+4
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ def db_put_field(name, dbr_type, pbuffer, length):
2323
'''Put field where pbuffer is void* pointer. Returns RC'''
2424
return _extension.db_put_field(name, dbr_type, pbuffer, length)
2525

26+
def db_get_field(name, dbr_type, pbuffer, length):
27+
'''Get field where pbuffer is void* pointer. Returns Py_RETURN_NONE'''
28+
return _extension.db_get_field(name, dbr_type, pbuffer, length)
29+
2630
def install_pv_logging(acf_file):
2731
'''Install pv logging'''
2832
_extension.install_pv_logging(acf_file)

tests/test_records.py

+201
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
builder.WaveformIn,
3535
]
3636

37+
# TODO: This should never pass, but somehow does in CI...
3738
def test_records(tmp_path):
3839
# Ensure we definitely unload all records that may be hanging over from
3940
# previous tests, then create exactly one instance of expected records.
@@ -904,3 +905,203 @@ async def query_record(index):
904905
log(f"PARENT: Join completed with exitcode {process.exitcode}")
905906
if process.exitcode is None:
906907
pytest.fail("Process did not terminate")
908+
909+
class TestGetSetField:
910+
"""Tests related to get_field and set_field on records"""
911+
912+
test_result_rec = "TestResult"
913+
914+
def test_set_field_before_init_fails(self):
915+
"""Test that calling set_field before iocInit() raises an exception"""
916+
917+
ao = builder.aOut("testAOut")
918+
919+
with pytest.raises(AssertionError) as e:
920+
ao.set_field("EGU", "Deg")
921+
922+
assert "set_field may only be called after iocInit" in str(e.value)
923+
924+
def test_get_field_before_init_fails(self):
925+
"""Test that calling get_field before iocInit() raises an exception"""
926+
927+
ao = builder.aOut("testAOut")
928+
929+
with pytest.raises(AssertionError) as e:
930+
ao.get_field("EGU")
931+
932+
assert "get_field may only be called after iocInit" in str(e.value)
933+
934+
def get_set_test_func(self, device_name, conn):
935+
"""Run an IOC and do simple get_field/set_field calls"""
936+
937+
builder.SetDeviceName(device_name)
938+
939+
lo = builder.longOut("TestLongOut", EGU="unset", DRVH=12)
940+
941+
# Record to indicate success/failure
942+
bi = builder.boolIn(self.test_result_rec, ZNAM="FAILED", ONAM="SUCCESS")
943+
944+
dispatcher = asyncio_dispatcher.AsyncioDispatcher()
945+
builder.LoadDatabase()
946+
softioc.iocInit(dispatcher)
947+
948+
conn.send("R") # "Ready"
949+
950+
log("CHILD: Sent R over Connection to Parent")
951+
952+
# Set and then get the EGU field
953+
egu = "TEST"
954+
lo.set_field("EGU", egu)
955+
log("CHILD: set_field successful")
956+
readback_egu = lo.get_field("EGU")
957+
log(f"CHILD: get_field returned {readback_egu}")
958+
assert readback_egu == egu, \
959+
f"EGU field was not {egu}, was {readback_egu}"
960+
961+
log("CHILD: assert passed")
962+
963+
# Test completed, report to listening camonitor
964+
bi.set(True)
965+
966+
# Keep process alive while main thread works.
967+
while (True):
968+
if conn.poll(TIMEOUT):
969+
val = conn.recv()
970+
if val == "D": # "Done"
971+
break
972+
973+
log("CHILD: Received exit command, child exiting")
974+
975+
976+
@pytest.mark.asyncio
977+
async def test_get_set(self):
978+
"""Test a simple set_field/get_field is successful"""
979+
ctx = get_multiprocessing_context()
980+
parent_conn, child_conn = ctx.Pipe()
981+
982+
device_name = create_random_prefix()
983+
984+
process = ctx.Process(
985+
target=self.get_set_test_func,
986+
args=(device_name, child_conn),
987+
)
988+
989+
process.start()
990+
991+
log("PARENT: Child started, waiting for R command")
992+
993+
from aioca import camonitor
994+
995+
try:
996+
# Wait for message that IOC has started
997+
select_and_recv(parent_conn, "R")
998+
999+
log("PARENT: received R command")
1000+
1001+
queue = asyncio.Queue()
1002+
record = device_name + ":" + self.test_result_rec
1003+
monitor = camonitor(record, queue.put)
1004+
1005+
log(f"PARENT: monitoring {record}")
1006+
new_val = await asyncio.wait_for(queue.get(), TIMEOUT)
1007+
log(f"PARENT: new_val is {new_val}")
1008+
assert new_val == 1, \
1009+
f"Test failed, value was not 1(True), was {new_val}"
1010+
1011+
1012+
finally:
1013+
monitor.close()
1014+
# Clear the cache before stopping the IOC stops
1015+
# "channel disconnected" error messages
1016+
aioca_cleanup()
1017+
1018+
log("PARENT: Sending Done command to child")
1019+
parent_conn.send("D") # "Done"
1020+
process.join(timeout=TIMEOUT)
1021+
log(f"PARENT: Join completed with exitcode {process.exitcode}")
1022+
if process.exitcode is None:
1023+
pytest.fail("Process did not terminate")
1024+
1025+
def get_set_too_long_value(self, device_name, conn):
1026+
"""Run an IOC and deliberately call set_field with a too-long value"""
1027+
1028+
builder.SetDeviceName(device_name)
1029+
1030+
lo = builder.longOut("TestLongOut", EGU="unset", DRVH=12)
1031+
1032+
# Record to indicate success/failure
1033+
bi = builder.boolIn(self.test_result_rec, ZNAM="FAILED", ONAM="SUCCESS")
1034+
1035+
dispatcher = asyncio_dispatcher.AsyncioDispatcher()
1036+
builder.LoadDatabase()
1037+
softioc.iocInit(dispatcher)
1038+
1039+
conn.send("R") # "Ready"
1040+
1041+
log("CHILD: Sent R over Connection to Parent")
1042+
1043+
# Set a too-long value and confirm it reports an error
1044+
try:
1045+
lo.set_field("EGU", "ThisStringIsFarTooLongToFitIntoTheEguField")
1046+
except ValueError as e:
1047+
# Expected error, report success to listening camonitor
1048+
assert "byte string too long" in e.args[0]
1049+
bi.set(True)
1050+
1051+
# Keep process alive while main thread works.
1052+
while (True):
1053+
if conn.poll(TIMEOUT):
1054+
val = conn.recv()
1055+
if val == "D": # "Done"
1056+
break
1057+
1058+
log("CHILD: Received exit command, child exiting")
1059+
1060+
@pytest.mark.asyncio
1061+
async def test_set_too_long_value(self):
1062+
"""Test that set_field with a too-long value raises the expected
1063+
error"""
1064+
ctx = get_multiprocessing_context()
1065+
parent_conn, child_conn = ctx.Pipe()
1066+
1067+
device_name = create_random_prefix()
1068+
1069+
process = ctx.Process(
1070+
target=self.get_set_too_long_value,
1071+
args=(device_name, child_conn),
1072+
)
1073+
1074+
process.start()
1075+
1076+
log("PARENT: Child started, waiting for R command")
1077+
1078+
from aioca import camonitor
1079+
1080+
try:
1081+
# Wait for message that IOC has started
1082+
select_and_recv(parent_conn, "R")
1083+
1084+
log("PARENT: received R command")
1085+
1086+
queue = asyncio.Queue()
1087+
record = device_name + ":" + self.test_result_rec
1088+
monitor = camonitor(record, queue.put)
1089+
1090+
log(f"PARENT: monitoring {record}")
1091+
new_val = await asyncio.wait_for(queue.get(), TIMEOUT)
1092+
log(f"PARENT: new_val is {new_val}")
1093+
assert new_val == 1, \
1094+
f"Test failed, value was not 1(True), was {new_val}"
1095+
1096+
finally:
1097+
monitor.close()
1098+
# Clear the cache before stopping the IOC stops
1099+
# "channel disconnected" error messages
1100+
aioca_cleanup()
1101+
1102+
log("PARENT: Sending Done command to child")
1103+
parent_conn.send("D") # "Done"
1104+
process.join(timeout=TIMEOUT)
1105+
log(f"PARENT: Join completed with exitcode {process.exitcode}")
1106+
if process.exitcode is None:
1107+
pytest.fail("Process did not terminate")

0 commit comments

Comments
 (0)