diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 199a4011..f7fece06 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -10,6 +10,15 @@ Versioning `_. Unreleased_ ----------- +Added: + +- `Enable setting alarm status of Out records <../../pull/157>`_ +- `Adding the non_interactive_ioc function <../../pull/156>`_ + +Removed: + +- `Remove python3.6 support <../../pull/138>`_ + Changed: - `AsyncioDispatcher cleanup tasks atexit <../../pull/138>`_ diff --git a/docs/reference/api.rst b/docs/reference/api.rst index 2fb01518..86f30e4b 100644 --- a/docs/reference/api.rst +++ b/docs/reference/api.rst @@ -120,6 +120,12 @@ Test Facilities`_ documentation for more details of each function. .. autofunction:: generalTimeReport .. autofunction:: eltc +.. autofunction:: non_interactive_ioc + +When used with a service manager, use python's -u option or the environment +variable PYTHONUNBUFFERED=TRUE. This ensures that python output, i.e. stdout +and stderr streams, is sent directly to the terminal. + .. attribute:: exit Displaying this value will invoke ``epicsExit()`` causing the IOC to @@ -454,9 +460,10 @@ starting the IOC. Alarm Value Definitions: `softioc.alarm` ---------------------------------------- - The following values can be passed to IN record :meth:`~softioc.device.ProcessDeviceSupportIn.set` and -:meth:`~softioc.device.ProcessDeviceSupportIn.set_alarm` methods. +:meth:`~softioc.device.ProcessDeviceSupportIn.set_alarm` methods, and to OUT record +:meth:`~softioc.device.ProcessDeviceSupportOut.set` and +:meth:`~softioc.device.ProcessDeviceSupportOut.set_alarm`. .. attribute:: NO_ALARM = 0 @@ -549,7 +556,7 @@ class which provides the methods documented below. This class is used to implement Python device support for the record types ``ai``, ``bi``, ``longin``, ``mbbi`` and IN ``waveform`` records. - .. method:: set(value, severity=NO_ALARM, alarm=UDF_ALARM, timestamp=None) + .. method:: set(value, severity=NO_ALARM, alarm=NO_ALARM, timestamp=None) Updates the stored value and severity status and triggers an update. If ``SCAN`` has been set to ``'I/O Intr'`` (which is the default if the @@ -608,14 +615,22 @@ Working with OUT records ``ao``, ``bo``, ``longout``, ``mbbo`` and OUT ``waveform`` records. All OUT records support the following methods. - .. method:: set(value, process=True) + .. method:: set(value, process=True, severity=NO_ALARM, alarm=NO_ALARM) - Updates the value associated with the record. By default this will + Updates the stored value and severity status. By default this will trigger record processing, and so will cause any associated `on_update` and `validate` methods to be called. If ``process`` is `False` then neither of these methods will be called, but the value will still be updated. + .. method:: set_alarm(severity, alarm) + + This is exactly equivalent to calling:: + + rec.set(rec.get(), severity=severity, alarm=alarm) + + and triggers an alarm status change without changing the value. + .. method:: get() Returns the value associated with the record. diff --git a/softioc/alarm.py b/softioc/alarm.py index e1366c3f..2e1895f9 100644 --- a/softioc/alarm.py +++ b/softioc/alarm.py @@ -5,6 +5,7 @@ INVALID_ALARM = 3 # Some alarm code definitions taken from EPICS alarm.h +# NO_ALARM = 0 READ_ALARM = 1 WRITE_ALARM = 2 HIHI_ALARM = 3 diff --git a/softioc/asyncio_dispatcher.py b/softioc/asyncio_dispatcher.py index eb186771..eeea73dc 100644 --- a/softioc/asyncio_dispatcher.py +++ b/softioc/asyncio_dispatcher.py @@ -3,6 +3,7 @@ import logging import threading import atexit +import signal class AsyncioDispatcher: def __init__(self, loop=None, debug=False): @@ -47,6 +48,17 @@ def close(self): self.__atexit = None self.__shutdown() + + def wait_for_quit(self): + stop_event = threading.Event() + + def signal_handler(signum, frame): + stop_event.set() + + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + stop_event.wait() async def __inloop(self, started): self.loop = asyncio.get_running_loop() diff --git a/softioc/cothread_dispatcher.py b/softioc/cothread_dispatcher.py index 87e1a125..3f9d23b8 100644 --- a/softioc/cothread_dispatcher.py +++ b/softioc/cothread_dispatcher.py @@ -19,6 +19,8 @@ def __init__(self, dispatcher = None): else: self.__dispatcher = dispatcher + self.wait_for_quit = cothread.WaitForQuit + def __call__( self, func, diff --git a/softioc/device.py b/softioc/device.py index 583eae35..4f4989f1 100644 --- a/softioc/device.py +++ b/softioc/device.py @@ -137,7 +137,7 @@ def _process(self, record): return self._epics_rc_ def set(self, value, - severity=alarm.NO_ALARM, alarm=alarm.UDF_ALARM, timestamp=None): + severity=alarm.NO_ALARM, alarm=alarm.NO_ALARM, timestamp=None): '''Updates the stored value and triggers an update. The alarm severity and timestamp can also be specified if appropriate.''' value = self._value_to_epics(value) @@ -176,9 +176,17 @@ def __init__(self, name, **kargs): self.__enable_write = True if 'initial_value' in kargs: - self._value = self._value_to_epics(kargs.pop('initial_value')) + value = self._value_to_epics(kargs.pop('initial_value')) + initial_severity = alarm.NO_ALARM + initial_status = alarm.NO_ALARM else: - self._value = None + value = self._default_value() + # To maintain backwards compatibility, if there is no initial value + # we mark the record as invalid + initial_severity = alarm.INVALID_ALARM + initial_status = alarm.UDF_ALARM + + self._value = (value, initial_severity, initial_status) self._blocking = kargs.pop('blocking', blocking) if self._blocking: @@ -190,18 +198,17 @@ def init_record(self, record): '''Special record initialisation for out records only: implements special record initialisation if an initial value has been specified, allowing out records to have a sensible initial value.''' - if self._value is None: - # Cannot set in __init__ (like we do for In records), as we want - # the record alarm status to be set if no value was provided - # Probably related to PythonSoftIOC issue #53 - self._value = self._default_value() - else: - self._write_value(record, self._value) - if 'MLST' in self._fields_: - record.MLST = self._value - record.TIME = time.time() - record.UDF = 0 - recGblResetAlarms(record) + + self._write_value(record, self._value[0]) + if 'MLST' in self._fields_: + record.MLST = self._value[0] + + record.TIME = time.time() + + record.UDF = 0 + record.NSEV = self._value[1] + record.NSTA = self._value[2] + recGblResetAlarms(record) return self._epics_rc_ def __completion(self, record): @@ -216,9 +223,13 @@ def _process(self, record): if record.PACT: return EPICS_OK + # Ignore memoized value, retrieve it from the VAL field instead value = self._read_value(record) + _, severity, alarm = self._value + self.process_severity(record, severity, alarm) + if not self.__always_update and \ - self._compare_values(value, self._value): + self._compare_values(value, self._value[0]): # If the value isn't making a change then don't do anything. return EPICS_OK @@ -227,11 +238,11 @@ def _process(self, record): not self.__validate(self, python_value): # Asynchronous validation rejects value, so restore the last good # value. - self._write_value(record, self._value) + self._write_value(record, self._value[0]) return EPICS_ERROR else: # Value is good. Hang onto it, let users know the value has changed - self._value = value + self._value = (value, severity, alarm) record.UDF = 0 if self.__on_update and self.__enable_write: record.PACT = self._blocking @@ -248,15 +259,26 @@ def _value_to_dbr(self, value): return self._dbf_type_, 1, addressof(value), value - def set(self, value, process=True): + def set_alarm(self, severity, alarm): + '''Updates the alarm status without changing the stored value. An + update is triggered, and a timestamp can optionally be specified.''' + self._value = (self._value[0], severity, alarm) + self.set( + self.get(), + severity=severity, + alarm=alarm) + + + def set(self, value, process=True, + severity=alarm.NO_ALARM, alarm=alarm.NO_ALARM): '''Special routine to set the value directly.''' value = self._value_to_epics(value) try: _record = self._record except AttributeError: - # Record not initialised yet. Record the value for when + # Record not initialised yet. Record data for when # initialisation occurs - self._value = value + self._value = (value, severity, alarm) else: # The array parameter is used to keep the raw pointer alive dbf_code, length, data, array = self._value_to_dbr(value) @@ -265,12 +287,7 @@ def set(self, value, process=True): self.__enable_write = True def get(self): - if self._value is None: - # Before startup complete if no value set return default value - value = self._default_value() - else: - value = self._value - return self._epics_to_value(value) + return self._epics_to_value(self._value[0]) def _Device(Base, record_type, ctype, dbf_type, epics_rc, mlst = False): diff --git a/softioc/softioc.py b/softioc/softioc.py index 884b72da..117d09bd 100644 --- a/softioc/softioc.py +++ b/softioc/softioc.py @@ -352,3 +352,12 @@ def interactive_ioc(context = {}, call_exit = True): if call_exit: safeEpicsExit(0) + +def non_interactive_ioc(): + '''Function to run the IOC in non-interactive mode. This mode is useful for + running the IOC as a background process without user interaction. + This function expects a stop signal. When it receives one, the IOC stops. + ''' + device.dispatcher.wait_for_quit() + safeEpicsExit(0) + diff --git a/tests/test_records.py b/tests/test_records.py index 1786b01e..3056e88d 100644 --- a/tests/test_records.py +++ b/tests/test_records.py @@ -1,9 +1,8 @@ import asyncio -import subprocess -import sys import numpy import os import pytest +from enum import Enum from conftest import ( aioca_cleanup, @@ -16,8 +15,8 @@ get_multiprocessing_context ) -from softioc import asyncio_dispatcher, builder, softioc -from softioc import alarm +from softioc import alarm, asyncio_dispatcher, builder, softioc +from softioc.builder import ClearRecords from softioc.device import SetBlocking from softioc.device_core import LookupRecord, LookupRecordList @@ -33,12 +32,14 @@ builder.mbbIn, builder.stringIn, builder.WaveformIn, + builder.longStringIn, ] def test_records(tmp_path): # Ensure we definitely unload all records that may be hanging over from # previous tests, then create exactly one instance of expected records. from sim_records import create_records + ClearRecords() create_records() path = str(tmp_path / "records.db") @@ -1215,3 +1216,112 @@ async def test_recursive_set(self): if process.exitcode is None: process.terminate() pytest.fail("Process did not finish cleanly, terminating") + +class TestAlarms: + """Tests related to record alarm status""" + + # Record creation function and associated PV name + records = [ + (builder.aIn, "AI_AlarmPV"), + (builder.boolIn, "BI_AlarmPV"), + (builder.longIn, "LI_AlarmPV"), + (builder.mbbIn, "MBBI_AlarmPV"), + (builder.stringIn, "SI_AlarmPV"), + (builder.WaveformIn, "WI_AlarmPV"), + (builder.longStringIn, "LSI_AlarmPV"), + (builder.aOut, "AO_AlarmPV"), + (builder.boolOut, "BO_AlarmPV"), + (builder.longOut, "LO_AlarmPV"), + (builder.stringOut, "SO_AlarmPV"), + (builder.mbbOut, "MBBO_AlarmPV"), + (builder.WaveformOut, "WO_AlarmPV"), + (builder.longStringOut, "LSO_AlarmPV"), + ] + + severity = alarm.INVALID_ALARM + status = alarm.DISABLE_ALARM + + class SetEnum(Enum): + """Enum to specify when set_alarm should be called""" + PRE_INIT = 0 + POST_INIT = 1 + + def alarm_test_func(self, device_name, conn, set_enum: SetEnum): + builder.SetDeviceName(device_name) + + pvs = [] + for record_func, name in self.records: + kwargs = {} + if record_func in [builder.WaveformOut, builder.WaveformIn]: + kwargs["length"] = WAVEFORM_LENGTH + + pvs.append(record_func(name, **kwargs)) + + if set_enum == self.SetEnum.PRE_INIT: + log("CHILD: Setting alarm before init") + for pv in pvs: + pv.set_alarm(self.severity, self.status) + + builder.LoadDatabase() + softioc.iocInit() + + if set_enum == self.SetEnum.POST_INIT: + log("CHILD: Setting alarm after init") + for pv in pvs: + pv.set_alarm(self.severity, self.status) + + conn.send("R") # "Ready" + log("CHILD: Sent R over Connection to Parent") + + # Keep process alive while main thread works. + while (True): + if conn.poll(TIMEOUT): + val = conn.recv() + if val == "D": # "Done" + break + + + @requires_cothread + @pytest.mark.parametrize("set_enum", [SetEnum.PRE_INIT, SetEnum.POST_INIT]) + def test_set_alarm_severity_status(self, set_enum): + """Test that set_alarm function allows setting severity and status""" + ctx = get_multiprocessing_context() + parent_conn, child_conn = ctx.Pipe() + + device_name = create_random_prefix() + + process = ctx.Process( + target=self.alarm_test_func, + args=(device_name, child_conn, set_enum), + ) + + process.start() + + from cothread.catools import caget, _channel_cache, FORMAT_CTRL + + try: + # Wait for message that IOC has started + select_and_recv(parent_conn, "R") + + # Suppress potential spurious warnings + _channel_cache.purge() + + for _, name in self.records: + + ret_val = caget( + device_name + ":" + name, + timeout=TIMEOUT, + format=FORMAT_CTRL + ) + + assert ret_val.severity == self.severity, \ + f"Severity mismatch for record {name}" + assert ret_val.status == self.status, \ + f"Status mismatch for record {name}" + + + finally: + # Suppress potential spurious warnings + _channel_cache.purge() + parent_conn.send("D") # "Done" + process.join(timeout=TIMEOUT)