diff --git a/example/limits.py b/example/limits.py new file mode 100644 index 00000000..87c76349 --- /dev/null +++ b/example/limits.py @@ -0,0 +1,134 @@ +""" +Demonstrate a Handler which uses open() and post() to apply control.limits +to a pair of PVs. +Run the program and use + - `pvget demo:limit_open` to verify that although the initial value was set + to 14 the control.limitHigh means that it is set to 10. + - `pvmonitor demo:limit_post` to see the behaviour of a PV with control limits + set to restrict values to >=3 and <=7 as a post() sweeps it between 0 and 10. + - `pvput demo:limit_open control.limitHigh=5` to see that changing the control + limits is correctly supported. +""" + +import time + +from p4p import Value +from p4p.nt.scalar import NTScalar +from p4p.server import Server, ServerOperation +from p4p.server.raw import Handler +from p4p.server.thread import SharedPV + + +class LimitsHandler(Handler): + """ + A handler which applies the control.limits to a PV. + """ + + def _eval_limits(self, value: Value): + """Check the value against the control.limits and adjust if necessary.""" + + # Have "control.limitHigh" and "control.limitLow" been set? If they have + # then the changed flag will be set and we can use the value. If they + # haven't then the changed flag will not be set, they will have a default + # value of 0, and we shouldn't use them to test the value. + + limit_high = value.get("control.limitHigh") + if ( + limit_high is not None + and value.changed("control.limitHigh") + and value["value"] > limit_high + ): + # print(f"Value {value['value']} is above the high limit of {limit_high}, setting to {limit_high}") + value["value"] = limit_high + + limit_low = value.get("control.limitLow") + if ( + limit_low is not None + and value.changed("control.limitLow") + and value["value"] < limit_low + ): + # print(f"Value {value['value']} is below the low limit of {limit_low}, setting to {limit_low}") + value["value"] = limit_low + + def open(self, value: Value): + """Check any initial value against the limits.""" + self._eval_limits(value) + + def post(self, pv: SharedPV, value: Value): + """Check changes to the value against the limits.""" + + # The value and control limits we need to evaluate are shared between + # the pv and the value. We need to check which fields are most current + # (value takes precedence) and set them in the value variable + # approppriately. + pv_value: Value = pv.current().raw + self._merge_field(value, pv_value, "value") + self._merge_field(value, pv_value, "control.limitHigh") + self._merge_field(value, pv_value, "control.limitLow") + + self._eval_limits(value) + + def _merge_field(self, value: Value, pv_value: Value, field: str): + if not value.changed(field) and pv_value.changed(field): + value[field] = pv_value[field] + + def put(self, pv: SharedPV, op: ServerOperation): + """Allow puts by simply forwarding to the post and invoking the associated handler.""" + + pv.post(op.value()) + op.done() + + +def main(): + """ + Create a pvaServer with two PVs with limits. + """ + + # Construct a PV with a high limit of 10, but an initial value of 14. + # The handler will be responsible for enforcing the limit and ensuring + # that the value is set to 10 instead of 14. + open_pv = SharedPV( + nt=NTScalar("i", control=True), + handler=LimitsHandler(), + initial={ + "value": 14, + "control.limitHigh": 10, + }, + ) + + # Construct a PV starting within its limit but which we will use posts + # to step within a range of 0 to 10 every second. Observe the behaviour + # with a pvmonitor. + value = 5 + post_pv = SharedPV( + nt=NTScalar("i", control=True), + handler=LimitsHandler(), + initial={ + "value": value, + "control.limitLow": 3, + "control.limitHigh": 7, + }, + ) + + pvs = {"demo:limit_open": open_pv, "demo:limit_post": post_pv} + + server = None + try: + server = Server(providers=[pvs]) + while True: + time.sleep(1) + value = value + 1 + if value > 10: + value = 0 + # post_pv.post(value) + except KeyboardInterrupt: + pass + finally: + for pv in pvs.values(): + pv.close() + if server: + server.stop() + + +if __name__ == "__main__": + main() diff --git a/src/p4p/server/raw.py b/src/p4p/server/raw.py index 2629f780..034ed0bf 100644 --- a/src/p4p/server/raw.py +++ b/src/p4p/server/raw.py @@ -39,6 +39,13 @@ class Handler(object): Use of this as a base class is optional. """ + def open(self, value): + """ + Called each time an Open operation is performed on this Channel + + :param value: A Value, or appropriate object (see nt= and wrap= of the constructor). + """ + pass def put(self, pv, op): """ @@ -50,6 +57,17 @@ def put(self, pv, op): """ op.done(error='Not supported') + def post(self, pv, value): + """ + Called each time a client issues a post + operation on this Channel. + + :param SharedPV pv: The :py:class:`SharedPV` which this Handler is associated with. + :param value: A Value, or appropriate object (see nt= and wrap= of the constructor). + :param dict options: A dictionary of configuration options. + """ + pass + def rpc(self, pv, op): """ Called each time a client issues a Remote Procedure Call @@ -77,6 +95,14 @@ def onLastDisconnect(self, pv): pass + def close(self, pv): + """ + Called when the Channel is closed. + + :param SharedPV pv: The :py:class:`SharedPV` which this Handler is associated with. + """ + pass + class SharedPV(_SharedPV): """Shared state Process Variable. Callback based implementation. @@ -158,6 +184,16 @@ def open(self, value, nt=None, wrap=None, unwrap=None, **kws): V = self._wrap(value, **kws) except: # py3 will chain automatically, py2 won't raise ValueError("Unable to wrap %r with %r and %r"%(value, self._wrap, kws)) + + # Guard goes here because we can have handlers that don't inherit from + # the Handler base class + try: + open_fn = self._handler.open + except AttributeError: + pass + else: + open_fn(V) + _SharedPV.open(self, V) def post(self, value, **kws): @@ -174,8 +210,37 @@ def post(self, value, **kws): V = self._wrap(value, **kws) except: # py3 will chain automatically, py2 won't raise ValueError("Unable to wrap %r with %r and %r"%(value, self._wrap, kws)) + + # Guard goes here because we can have handlers that don't inherit from + # the Handler base class + try: + post_fn = self._handler.post + except AttributeError: + pass + else: + post_fn(self, V) + _SharedPV.post(self, V) + def close(self, destroy=False): + """Close PV, disconnecting any clients. + + :param bool destroy: Indicate "permanent" closure. Current clients will not see subsequent open(). + + close() with destory=True or sync=True will not prevent clients from re-connecting. + New clients may prevent sync=True from succeeding. + Prevent reconnection by __first__ stopping the Server, removing with :py:meth:`StaticProvider.remove()`, + or preventing a :py:class:`DynamicProvider` from making new channels to this SharedPV. + """ + try: + close_fn = self._handler.close + except AttributeError: + pass + else: + close_fn(self) + + _SharedPV.close(self) + def current(self): V = _SharedPV.current(self) try: @@ -208,6 +273,13 @@ def __init__(self, pv, real): self._pv = pv # this creates a reference cycle, which should be collectable since SharedPV supports GC self._real = real + def open(self, value): + _log.debug('OPEN %s %s', self._pv, value) + try: + self._pv._exec(None, self._real.open, value) + except AttributeError: + pass + def onFirstConnect(self): self._pv._exec(None, self._pv._onFirstConnect, None) try: # user handler may omit onFirstConnect() @@ -239,6 +311,20 @@ def rpc(self, op): except AttributeError: op.done(error="RPC not supported") + def post(self, value): + _log.debug('POST %s %s', self._pv, value) + try: + self._pv._exec(None, self._real.post, self._pv, value) + except AttributeError: + pass + + def close(self): + _log.debug('CLOSE %s', self._pv) + try: + self._pv._exec(None, self._real.close, self._pv) + except AttributeError: + pass + @property def onFirstConnect(self): def decorate(fn): @@ -253,6 +339,20 @@ def decorate(fn): return fn return decorate + @property + def on_open(self): + def decorate(fn): + self._handler.open = fn + return fn + return decorate + + @property + def on_post(self): + def decorate(fn): + self._handler.post = fn + return fn + return decorate + @property def put(self): def decorate(fn): @@ -267,6 +367,13 @@ def decorate(fn): return fn return decorate + @property + def on_close(self): + def decorate(fn): + self._handler.close = fn + return fn + return decorate + def __repr__(self): if self.isOpen(): return '%s(value=%s)' % (self.__class__.__name__, repr(self.current())) diff --git a/src/p4p/test/test_sharedpv.py b/src/p4p/test/test_sharedpv.py index a810ba32..dbb05258 100644 --- a/src/p4p/test/test_sharedpv.py +++ b/src/p4p/test/test_sharedpv.py @@ -17,7 +17,7 @@ from ..wrapper import Value, Type from ..client.thread import Context, Disconnected, TimeoutError, RemoteError from ..server import Server, StaticProvider -from ..server.thread import SharedPV, _defaultWorkQueue +from ..server.thread import Handler, SharedPV, _defaultWorkQueue from ..util import WorkQueue from ..nt import NTScalar, NTURI from .utils import RefTestCase @@ -420,3 +420,55 @@ def testPVClose(self): _log.debug('CLOSE') self.assertFalse(self.H.conn) + +class TestHandlerOpenPostClose(RefTestCase): + """ + Test Handler open(), post() and close() functions called correctly by SharedPV. Note that: + - TestRPC, TestFirstLast already test onFirstConnect() and onLastDisconnect(). + - TestGPM, TestPVRequestMask already test put(). + - TestRPC, TestRPC2 already test rpc(). + """ + + class TestHandler(Handler): + def __init__(self): + self.last_op = "init" + + def open(self, value): + self.last_op = "open" + value["value"] = 17 + + def post(self, pv, value): + self.last_op = "post" + value["value"] = value["value"] * 2 + + def close(self, pv): + self.last_op = "close" + + def setUp(self): + super(TestHandlerOpenPostClose, self).setUp() + self.handler = self.TestHandler() + self.pv = SharedPV(handler=self.handler, nt=NTScalar('d')) + + def test_open(self): + # Setup sets the initial value to 5, but the Handler open() overrides + self.pv.open(5) + self.assertEqual(self.handler.last_op, "open") + self.assertEqual(self.pv.current(), 17.0) + + def test_post(self): + self.pv.open(5) + self.pv.post(13.0) + self.assertEqual(self.handler.last_op, "post") + self.assertEqual(self.pv.current(), 26.0) + + def test_close(self): + self.pv.open(5) + self.pv.close(sync=True) + self.assertEqual(self.handler.last_op, "close") + + def tearDown(self): + self.pv.close(sync=True) + self.traceme(self.pv) + del self.pv + + super(TestHandlerOpenPostClose, self).tearDown()