diff --git a/frappy/lib/statemachine.py b/frappy/lib/statemachine.py index a40dac4..5985447 100644 --- a/frappy/lib/statemachine.py +++ b/frappy/lib/statemachine.py @@ -29,113 +29,92 @@ of StateMachine, but usually as functions or methods of an other object. The created state object may hold variables needed for the state. A state function may return either: - a function for the next state to transition to -- Retry() to keep the state and call the -- or `None` for finishing +- Retry to keep the state and call the state function again +- or Finish for finishing Initialisation Code ------------------- -For code to be called only after a state transition, use stateobj.init. +For code to be called only after a state transition, use statemachine.init. -def state_x(stateobj): - if stateobj.init: +def state_x(statemachine): + if statemachine.init: ... code to be execute only after entering state x ... ... further code ... -Cleanup Function ----------------- +Restart +------- -cleanup= as argument in StateMachine.__init__ or .start -defines a cleanup function to be called whenever the machine is stopped or -an error is raised in a state function. A cleanup function may return -either None for finishing or a further state function for continuing. -In case of stop or restart, this return value is ignored. +To restart the statemachine, call statemachine.start. The current task is interrupted, +the cleanup sequence is called, and after this the machine is restarted with the +arguments of the start method. -State Specific Cleanup Code ---------------------------- +Stop +---- -To execute state specific cleanup, the cleanup may examine the current state -(stateobj.state) in order to decide what to be done. - -If a need arises, a future extension to this library may support specific -cleanup functions by means of a decorator adding the specific cleanup function -as an attribute to the state function. +To stop the statemachine, call statemachine.stop. The current task is interrupted, +the cleanup sequence is called, and the machine finishes. -Threaded Use ------------- +Cleaning Up +----------- -On start, a thread is started, which is waiting for a trigger event when the -machine is not active. For test purposes or special needs, the thread creation -may be disabled. :meth:`cycle` must be called periodically in this case. +A cleanup function might be added as arguments to StateMachine.start. +On error, stop or restart, the cleanup sequence will be executed. +The cleanup itself is not be interrupted: +- if a further exeception is raised, the machine is interrupted immediately +- if start or stop is called again, a previous start or stop is ignored. + The currently running cleanup sequence is finished, and not started again. """ import time import threading -import queue from logging import getLogger -from frappy.lib import mkthread, UniqueObject +from frappy.lib import UniqueObject + +Retry = UniqueObject('Retry') +Finish = UniqueObject('Finish') -Stop = UniqueObject('Stop') -Restart = UniqueObject('Restart') +class Start: + def __init__(self, newstate, kwds): + self.newstate = newstate + self.kwds = kwds # statemachine attributes -class Retry: - def __init__(self, delay=None): - self.delay = delay +class Stop: + pass class StateMachine: """a simple, but powerful state machine""" # class attributes are not allowed to be overriden by kwds of __init__ or :meth:`start` - start_time = None # the time of last start - transition_time = None # the last time when the state changed - state = None # the current state - now = None - init = True - stopped = False - last_error = None # last exception raised or Stop or Restart - _last_time = 0 + statefunc = None # the current statefunc + now = None # the current time (avoid mutiple calls within a state) + init = True # True only in the first call of a state after a transition + next_task = None # None or an instance of Start or Stop + cleanup_reason = None # None or an instance of Exception, Start or Stop + _last_time = 0 # for delta method - def __init__(self, state=None, logger=None, threaded=True, **kwds): + def __init__(self, statefunc=None, logger=None, **kwds): """initialize state machine - :param state: if given, this is the first state + :param statefunc: if given, this is the first statefunc :param logger: an optional logger - :param threaded: whether a thread should be started (default: True) :param kwds: any attributes for the state object """ - self.default_delay = 0.25 # default delay when returning None - self.now = time.time() # avoid calling time.time several times per state - self.cleanup = self.default_cleanup # default cleanup: finish on error + self.cleanup = None + self.transition = None + self.maxloops = 10 # the maximum number of statefunc functions called in sequence without Retry + self.now = time.time() # avoids calling time.time several times per statefunc self.log = logger or getLogger('dummy') + self._lock = threading.Lock() self._update_attributes(kwds) - self._lock = threading.RLock() - self._threaded = threaded - if threaded: - self._thread_queue = queue.Queue() - self._idle_event = threading.Event() - self._thread = None - self._restart = None - if state: - self.start(state) - - @staticmethod - def default_cleanup(state): - """default cleanup - - :param self: the state object - :return: None (for custom cleanup functions this might be a new state) - """ - if state.stopped: # stop or restart - verb = 'stopped' if state.stopped is Stop else 'restarted' - state.log.debug('%s in state %r', verb, state.status_string) - else: - state.log.warning('%r raised in state %r', state.last_error, state.status_string) + if statefunc: + self.start(statefunc) def _update_attributes(self, kwds): """update allowed attributes""" @@ -145,154 +124,101 @@ class StateMachine: raise AttributeError('can not set %s.%s' % (cls.__name__, key)) setattr(self, key, value) + def _cleanup(self, reason): + if isinstance(reason, Exception): + self.log.warning('%s: raised %r', self.statefunc.__name__, reason) + elif isinstance(reason, Stop): + self.log.debug('stopped in %s', self.statefunc.__name__) + else: # must be Start + self.log.debug('restart %s during %s', reason.newstate.__name__, self.statefunc.__name__) + if self.cleanup_reason is None: + self.cleanup_reason = reason + if not self.cleanup: + return None # no cleanup needed or cleanup already handled + with self._lock: + cleanup, self.cleanup = self.cleanup, None + ret = None + try: + ret = cleanup(self) # pylint: disable=not-callable # None or function + if not (ret is None or callable(ret)): + self.log.error('%s: return value must be callable or None, not %r', + self.statefunc.__name__, ret) + ret = None + except Exception as e: + self.log.exception('%r raised in cleanup', e) + return ret + @property def is_active(self): - return bool(self.state) + return bool(self.statefunc) - @property - def status_string(self): - if self.state is None: - return '' - doc = self.state.__doc__ - return doc.split('\n', 1)[0] if doc else self.state.__name__ - - @property - def state_time(self): - """the time spent already in this state""" - return self.now - self.transition_time - - @property - def run_time(self): - """time since last (re-)start""" - return self.now - self.start_time - - def _new_state(self, state): - self.state = state + def _new_state(self, statefunc): + if self.transition: + self.transition(self, statefunc) # pylint: disable=not-callable # None or function self.init = True - self.now = time.time() - self.transition_time = self.now - self.log.debug('state: %s', self.status_string) + self.statefunc = statefunc + self._last_time = self.now def cycle(self): - """do one cycle in the thread loop + """do one cycle - :return: a delay or None when idle + call state functions until Retry is returned """ - with self._lock: - if self.state is None: - return None - for _ in range(999): - self.now = time.time() - try: - ret = self.state(self) - self.init = False - if self.stopped: - self.last_error = self.stopped - self.cleanup(self) - self.stopped = False - ret = None - except Exception as e: - self.last_error = e - ret = self.cleanup(self) - self.log.debug('called %r %sexc=%r', self.cleanup, - 'ret=%r ' % ret if ret else '', e) - if ret is None: - self.log.debug('state: None after cleanup') - self.state = None - self._idle_event.set() - return None - if callable(ret): + for _ in range(2): + if self.statefunc: + for _ in range(self.maxloops): + self.now = time.time() + if self.next_task and not self.cleanup_reason: + # interrupt only when not cleaning up + ret = self._cleanup(self.next_task) + else: + try: + ret = self.statefunc(self) + self.init = False + if ret is Retry: + return + if ret is Finish: + break + if not callable(ret): + ret = self._cleanup(RuntimeError( + '%s: return value must be callable, Retry or Finish, not %r' + % (self.statefunc.__name__, ret))) + except Exception as e: + ret = self._cleanup(e) + if ret is None: + break self._new_state(ret) - continue - if isinstance(ret, Retry): - if ret.delay == 0: + else: + ret = self._cleanup(RuntimeError( + '%s: too many states chained - probably infinite loop' % self.statefunc.__name__)) + if ret: + self._new_state(ret) continue - if ret.delay is None: - return self.default_delay - return ret.delay - self.last_error = RuntimeError('return value must be callable, Retry(...) or finish') - break - else: - self.last_error = RuntimeError('too many states chained - probably infinite loop') - self.cleanup(self) - self.state = None - return None + if self.cleanup_reason is None: + self.log.debug('finish in state %r', self.statefunc.__name__) + self._new_state(None) + if self.next_task: + with self._lock: + action, self.next_task = self.next_task, None + self.cleanup_reason = None + if isinstance(action, Start): + self._new_state(action.newstate) + self._update_attributes(action.kwds) - def trigger(self, delay=0): - if self._threaded: - self._thread_queue.put(delay) - - def _run(self, delay): - """thread loop - - :param delay: delay before first state is called - """ - while True: - try: - ret = self._thread_queue.get(timeout=delay) - if ret is not None: - delay = ret - continue - except queue.Empty: - pass - delay = self.cycle() - - def _start(self, state, **kwds): - self._restart = None - self._idle_event.clear() - self.last_error = None - self.stopped = False - self._update_attributes(kwds) - self._new_state(state) - self.start_time = self.now - self._last_time = self.now - first_delay = self.cycle() # important: call once (e.g. set status to busy) - if self._threaded: - if self._thread is None or not self._thread.is_alive(): - # restart thread if dead (may happen when cleanup failed) - if first_delay is not None: - self._thread = mkthread(self._run, first_delay) - else: - self.trigger(first_delay) - - def start(self, state, **kwds): + def start(self, statefunc, **kwds): """start with a new state - and interrupt the current state - the cleanup function will be called with state.stopped=Restart - - :param state: the first state + :param statefunc: the first state :param kwds: items to put as attributes on the state machine """ - self.log.debug('start %r', kwds) - if self.state: - self.stopped = Restart - with self._lock: # wait for running cycle finished - if self.stopped: # cleanup is not yet done - self.last_error = self.stopped - self.cleanup(self) # ignore return state on restart - self.stopped = False - self._start(state, **kwds) - else: - self._start(state, **kwds) + kwds.setdefault('cleanup', None) # cleanup must be given on each restart + with self._lock: + self.next_task = Start(statefunc, kwds) def stop(self): - """stop machine, go to idle state - - the cleanup function will be called with state.stopped=Stop - """ - self.log.debug('stop') - self.stopped = Stop + """stop machine, go to idle state""" with self._lock: - if self.stopped: # cleanup is not yet done - self.last_error = self.stopped - self.cleanup(self) # ignore return state on restart - self.stopped = False - self.state = None - - def wait(self, timeout=None): - """wait for state machine being idle""" - self._idle_event.wait(timeout) + self.next_task = Stop() def delta(self, mindelta=0): """helper method for time dependent control @@ -300,7 +226,7 @@ class StateMachine: :param mindelta: minimum time since last call :return: time delta or None when less than min delta time has passed - to be called from within an state + to be called from within an state function Usage: diff --git a/frappy/modules.py b/frappy/modules.py index 15538f6..4981a10 100644 --- a/frappy/modules.py +++ b/frappy/modules.py @@ -227,8 +227,13 @@ class PollInfo: self.fast_flag = False self.trigger_event = trigger_event - def trigger(self): - """trigger a recalculation of poll due times""" + def trigger(self, immediate=False): + """trigger a recalculation of poll due times + + :param immediate: when True, doPoll should be called as soon as possible + """ + if immediate: + self.last_main = 0 self.trigger_event.set() def update_interval(self, pollinterval): diff --git a/frappy/states.py b/frappy/states.py new file mode 100644 index 0000000..7534199 --- /dev/null +++ b/frappy/states.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# ***************************************************************************** +# +# This program is free software; you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation; either version 2 of the License, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program; if not, write to the Free Software Foundation, Inc., +# 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Module authors: +# Markus Zolliker +# +# ***************************************************************************** +"""state machine mixin + +handles status depending on statemachine state +""" + + +from frappy.core import BUSY, IDLE, ERROR, Parameter, Command +from frappy.errors import ProgrammingError +from frappy.lib.statemachine import StateMachine, Finish, Start, Stop, \ + Retry # pylint: disable=unused-import + + +class StatusCode: + """decorator for state methods + + :param code: the code assigned to the state function + :param text: the text assigned to the state function + if not given, the text is taken from the state functions name + """ + def __init__(self, code, text=None): + self.code = code + self.text = text + + def __set_name__(self, owner, name): + if not issubclass(owner, HasStates): + raise ProgrammingError('when using decorator "status_code", %s must inherit HasStates' % owner.__name__) + self.cls = owner + self.name = name + if 'statusMap' not in owner.__dict__: + # we need a copy on each inheritance level + owner.statusMap = owner.statusMap.copy() + owner.statusMap[name] = self.code, name.replace('_', ' ') if self.text is None else self.text + setattr(owner, name, self.func) # replace with original method + + def __call__(self, func): + self.func = func + return self + + +class HasStates: + """mixin for modules needing a statemachine""" + status = Parameter() # make sure this is a parameter + _state_machine = None + statusMap = {} # a dict populated with status values for methods used as state functions + + def init_state_machine(self, **kwds): + """initialize the state machine + + might be overridden in order to add additional attributes initialized + + :param kwds: additional attributes + """ + self._state_machine = StateMachine( + logger=self.log, + idle_status=(IDLE, ''), + transition=self.state_transition, + reset_fast_poll=False, + status_text='', + **kwds) + + def initModule(self): + super().initModule() + self.init_state_machine() + + def state_transition(self, sm, newstate): + """handle status updates""" + status = self.get_status(newstate) + if status is not None: + # if a status_code is given, remember the text of this state + sm.status_text = status[1] + if isinstance(sm.next_task, Stop): + if newstate: + status = self.status[0], 'stopping (%s)' % sm.status_text + elif isinstance(sm.next_task, Start): + next_status = self.get_status(sm.next_task.newstate, BUSY) + if newstate: + # restart case + status = next_status[0], 'restarting (%s)' % sm.status_text + else: + # start case + status = next_status + if status is None: + return # no status_code given -> no change + if status != self.status: + self.status = status + + def get_status(self, statefunc, default_code=None): + """get the status assigned to a statefunc + + :param statefunc: the state function to get the status from. if None, the idle_status attribute + of the state machine is returned + :param default_code: if None, None is returned in case no status_code is attached to statefunc + otherwise the returned status is composed by default_code and the modified name of the statefuncs + :return: a status or None + """ + if statefunc is None: + status = self._state_machine.idle_status or (ERROR, 'Finish was returned without final status') + else: + name = statefunc.__name__ + status = self.statusMap.get(name) + if status is None and default_code is not None: + status = default_code, name.replace('_', ' ') + print('get_status', statefunc, status, default_code) + return status + + def doPoll(self): + super().doPoll() + sm = self._state_machine + sm.cycle() + if sm.statefunc is None and sm.reset_fast_poll: + sm.reset_fast_poll = False + self.setFastPoll(False) + + def start_machine(self, statefunc, fast_poll=True, cleanup=None, **kwds): + """start or restart the state machine + + :param statefunc: the initial state to be called + :param fast_poll: flag to indicate that polling has to switched to fast + :param cleanup: a cleanup function + :param kwds: attributes to be added to the state machine on start + + If the state machine is already running, the following happens: + 1) the currently executing state function, if any, is finished + 2) in case the cleanup attribute on the state machine object is not None, + it is called and subsequently the state functions returned are executed, + until a state function returns None or Finish. However, in case a cleanup + sequence is already running, this is finished instead. + 3) only then, the new cleanup function and all the attributes given + in kwds are set on the state machine + 4) the state machine continues at the given statefunc + """ + sm = self._state_machine + status = self.get_status(statefunc, BUSY) + if sm.statefunc: + status = status[0], 'restarting' + self.status = status + sm.status_text = status[1] + sm.start(statefunc, cleanup=cleanup, **kwds) + if fast_poll: + sm.reset_fast_poll = True + self.setFastPoll(True) + self.pollInfo.trigger(True) # trigger poller + + def stop_machine(self, stopped_status=(IDLE, 'stopped')): + """stop the currently running machine + + :param stopped_status: status to be set after stopping + + If the state machine is not running, nothing happens. + Else the state machine is stoppen, the predefined cleanup + sequence is executed and then the status is set to the value + given in the sopped_status argument. + An already running cleanup sequence is not executed again. + """ + sm = self._state_machine + if sm.is_active: + sm.idle_status = stopped_status + sm.stop() + self.status = self.status[0], 'stopping' + self.pollInfo.trigger(True) # trigger poller + + @Command + def stop(self): + self.stop_machine() + + def final_status(self, code=IDLE, text=''): + """final status + + Usage: + + return self.final_status('IDLE', 'machine idle') + """ + sm = self._state_machine + sm.idle_status = code, text + return Finish diff --git a/frappy_psi/convergence.py b/frappy_psi/convergence.py index a0a4693..f7820ea 100644 --- a/frappy_psi/convergence.py +++ b/frappy_psi/convergence.py @@ -97,7 +97,7 @@ class HasConvergence: state.timeout_base = state.now return self.state_inside if not self.timeout: - return Retry() + return Retry if state.init: state.timeout_base = state.now state.dif_crit = dif # criterium for resetting timeout base @@ -108,7 +108,7 @@ class HasConvergence: elif state.now > state.timeout_base + self.timeout: self.status = WARN, 'convergence timeout' return self.state_instable - return Retry() + return Retry def state_inside(self, state): """inside tolerance, still busy""" @@ -121,7 +121,7 @@ class HasConvergence: return self.state_stable if state.init: self.status = BUSY, 'inside tolerance' - return Retry() + return Retry def state_outside(self, state): """temporarely outside tolerance, busy""" @@ -135,13 +135,13 @@ class HasConvergence: self.status = BUSY, 'outside tolerance' # do not reset the settling time on occasional outliers, count backwards instead state.spent_inside = max(0.0, state.spent_inside - state.delta()) - return Retry() + return Retry def state_stable(self, state): """stable, after settling_time spent within tolerance, idle""" dif, tol = self.get_dif_tol() if dif <= tol: - return Retry() + return Retry self.status = WARN, 'instable' state.spent_inside = max(self.settling_time, state.spent_inside) return self.state_instable @@ -156,7 +156,7 @@ class HasConvergence: return self.state_stable else: state.spent_inside = max(0, state.spent_inside - state.delta()) - return Retry() + return Retry def state_interrupt(self, state): """stopping""" diff --git a/frappy_psi/motorvalve.py b/frappy_psi/motorvalve.py index d721d95..3d0008e 100644 --- a/frappy_psi/motorvalve.py +++ b/frappy_psi/motorvalve.py @@ -126,7 +126,7 @@ class MotorValve(PersistentMixin, Drivable): self.motor.stop() self.status = ERROR, 'opening valve failed (home switch not released)' return None - return Retry() + return Retry motvalue = self.motor.read_value() if abs(motvalue - self.turns * 360) < 5: self.read_value() # value = opened, status = IDLE @@ -147,7 +147,7 @@ class MotorValve(PersistentMixin, Drivable): if self.motor.isBusy(): if self.motor.home: return self.find_closed - return Retry() + return Retry motvalue = self.motor.read_value() if abs(motvalue) > 5: if state.count > 0: @@ -171,7 +171,7 @@ class MotorValve(PersistentMixin, Drivable): if not self.motor.home: self.motor.stop() return None - return Retry() + return Retry motvalue = self.motor.read_value() if motvalue < -360: self.read_value() # status -> error @@ -180,7 +180,7 @@ class MotorValve(PersistentMixin, Drivable): # moved by more than 5 deg state.prev = self.motor.value self.motor.write_target(-360) - return Retry() + return Retry if motvalue > 5: self.status = ERROR, 'closing valve failed (zero not reached)' return None @@ -216,7 +216,7 @@ class MotorValve(PersistentMixin, Drivable): self.status = ERROR, 'ref run failed, can not find home switch' return None if not self.motor.home: - return Retry() + return Retry self.motor.write_speed(self.lowspeed) state.prev = self.motor.read_value() self.motor.write_target(state.prev - 360) @@ -226,13 +226,13 @@ class MotorValve(PersistentMixin, Drivable): def ref_released(self, state): if self.motor.isBusy(): if self.motor.home: - return Retry() + return Retry elif self.motor.read_home(): if state.count > 0: state.count -= 1 self.log.info('home switch not released, try again') self.motor.write_target(self.motor.target) - return Retry() + return Retry self.status = ERROR, 'ref run failed, can not release home switch' return None return self.ref_home @@ -242,7 +242,7 @@ class MotorValve(PersistentMixin, Drivable): if not self.motor.home: self.motor.stop() return None - return Retry() + return Retry self.motor.set_zero(max(-50, (self.motor.read_value() - state.prev) * 0.5)) self.read_value() # check home button is valid if abs(self.motor.target - self.motor.value) < 5: diff --git a/test/test_statemachine.py b/test/test_statemachine.py index cd6fb25..e3651f0 100644 --- a/test/test_statemachine.py +++ b/test/test_statemachine.py @@ -21,18 +21,30 @@ # ***************************************************************************** -from frappy.lib.statemachine import StateMachine, Stop, Retry +from frappy.core import Drivable, Parameter +from frappy.datatypes import StatusType, Enum +from frappy.states import StateMachine, Stop, Retry, Finish, Start, HasStates, StatusCode + + +class LoggerStub: + def info(self, fmt, *args): + print(fmt % args) + + def debug(self, fmt, *args): + pass + + warning = exception = error = info + handlers = [] def rise(state): state.step += 1 - print('rise', state.step) if state.init: state.status = 'rise' state.level += 1 if state.level > 3: return turn - return Retry() + return Retry def turn(state): @@ -42,7 +54,7 @@ def turn(state): state.direction += 1 if state.direction > 3: return fall - return Retry() + return Retry def fall(state): @@ -52,32 +64,35 @@ def fall(state): state.level -= 1 if state.level < 0: raise ValueError('crash') - return Retry(0) # retry until crash! + return fall # retry until crash! -def error_handler(state): - state.last_error_name = type(state.last_error).__name__ +def finish(state): + return None -class LoggerStub: - def debug(self, fmt, *args): - print(fmt % args) - info = warning = exception = error = debug - handlers = [] +class Result: + cleanup_reason = None + def __init__(self): + self.states = [] -class DummyThread: - def is_alive(self): - return True + def on_error(self, sm): + self.cleanup_reason = sm.cleanup_reason + + def on_transition(self, sm, newstate): + self.states.append(newstate) def test_fun(): - s = StateMachine(step=0, status='', threaded=False, logger=LoggerStub()) + obj = Result() + s = StateMachine(step=0, status='', transition=obj.on_transition, logger=LoggerStub()) assert s.step == 0 assert s.status == '' s.cycle() # do nothing assert s.step == 0 - s.start(rise, level=0, direction=0) + s.start(rise, cleanup=obj.on_error, level=0, direction=0) + s.cycle() for i in range(1, 4): assert s.status == 'rise' assert s.step == i @@ -91,56 +106,221 @@ def test_fun(): assert s.direction == i - 4 s.cycle() s.cycle() # -> crash - assert isinstance(s.last_error, ValueError) - assert str(s.last_error) == 'crash' - assert s.state is None + assert isinstance(obj.cleanup_reason, ValueError) + assert str(obj.cleanup_reason) == 'crash' + assert obj.states == [rise, turn, fall, fall, fall, fall, fall, None] + assert s.statefunc is None def test_max_chain(): - s = StateMachine(step=0, status='', threaded=False, logger=LoggerStub()) - s.start(fall, level=999+1, direction=0) - assert isinstance(s.last_error, RuntimeError) - assert s.state is None + obj = Result() + s = StateMachine(step=0, status='', transition=obj.on_transition, logger=LoggerStub()) + s.start(fall, cleanup=obj.on_error, level=999+1, direction=0) + s.cycle() + assert isinstance(obj.cleanup_reason, RuntimeError) + assert s.statefunc is None def test_stop(): - s = StateMachine(step=0, status='', threaded=False, logger=LoggerStub()) - s.start(rise, level=0, direction=0) - for _ in range(1, 3): + obj = Result() + s = StateMachine(step=0, status='', transition=obj.on_transition, logger=LoggerStub()) + s.start(rise, cleanup=obj.on_error, level=0, direction=0) + for _ in range(3): s.cycle() s.stop() s.cycle() - assert s.last_error is Stop - assert s.state is None + assert isinstance(obj.cleanup_reason, Stop) + assert obj.states == [rise, None] + assert s.statefunc is None -def test_std_error_handling(): - s = StateMachine(step=0, status='', threaded=False, logger=LoggerStub()) - s.start(rise, level=0, direction=0) +def test_error_handling(): + obj = Result() + s = StateMachine(step=0, status='', transition=obj.on_transition, logger=LoggerStub()) + s.start(rise, cleanup=obj.on_error, level=0, direction=0) s.cycle() - s.level = None # -> TypeError on next step - s.cycle() - assert s.state is None # default error handler: stop machine - assert isinstance(s.last_error, TypeError) - assert not hasattr(s, 'last_error_name') - - -def test_default_error_handling(): - s = StateMachine(step=0, status='', cleanup=error_handler, threaded=False, logger=LoggerStub()) - s.start(rise, level=0, direction=0) s.cycle() s.level = None s.cycle() - assert s.state is None - assert s.last_error_name == 'TypeError' - assert isinstance(s.last_error, TypeError) + assert isinstance(obj.cleanup_reason, TypeError) + assert obj.states == [rise, None] + assert s.statefunc is None -def test_cleanup_on_restart(): - s = StateMachine(step=0, status='', threaded=False, logger=LoggerStub()) - s.start(rise, level=0, direction=0) +def test_on_restart(): + obj = Result() + s = StateMachine(step=0, status='', transition=obj.on_transition, logger=LoggerStub()) + s.start(rise, cleanup=obj.on_error, level=0, direction=0) + s.cycle() s.cycle() s.start(turn) s.cycle() - assert s.state is turn - assert s.last_error is None + assert isinstance(obj.cleanup_reason, Start) + obj.cleanup_reason = None + s.cycle() + assert s.statefunc is turn + assert obj.cleanup_reason is None + assert obj.states == [rise, None, turn] + + +def test_finish(): + obj = Result() + s = StateMachine(step=0, status='', transition=obj.on_transition, logger=LoggerStub()) + s.start(finish, cleanup=obj.on_error, level=0, direction=0) + s.cycle() + s.cycle() + assert obj.states == [finish, None] + assert s.statefunc is None + + +Status = Enum( + Drivable.Status, + PREPARED=150, + PREPARING=340, + RAMPING=370, + STABILIZING=380, + FINALIZING=390, +) + + +class DispatcherStub: + # the first update from the poller comes a very short time after the + # initial value from the timestamp. However, in the test below + # the second update happens after the updates dict is cleared + # -> we have to inhibit the 'omit unchanged update' feature + omit_unchanged_within = 0 + + def __init__(self, updates): + self.updates = updates + + def announce_update(self, modulename, pname, pobj): + assert modulename == 'obj' + if pobj.readerror: + self.updates.append((pname, pobj.readerror)) + else: + self.updates.append((pname, pobj.value)) + + +class ServerStub: + def __init__(self, updates): + self.dispatcher = DispatcherStub(updates) + + +class Mod(HasStates, Drivable): + status = Parameter(datatype=StatusType(Status)) + _my_time = 0 + + def artificial_time(self): + return self._my_time + + def on_cleanup(self, sm): + return self.cleanup_one + + def state_transition(self, sm, newstate): + self.statelist.append(getattr(newstate, '__name__', None)) + super().state_transition(sm, newstate) + + def state_one(self, sm): + if sm.init: + return Retry + return self.state_two + + @StatusCode('PREPARING', 'state 2') + def state_two(self, sm): + return self.state_three + + @StatusCode('FINALIZING') + def state_three(self, sm): + if sm.init: + return Retry + return self.final_status('IDLE', 'finished') + + @StatusCode('BUSY') + def cleanup_one(self, sm): + if sm.init: + return Retry + print('one 2') + return self.cleanup_two + + def cleanup_two(self, sm): + if sm.init: + return Retry + return Finish + + def doPoll(self): + super().doPoll() + self._my_time += 1 + + +def create_module(): + updates = [] + obj = Mod('obj', LoggerStub(), {'.description': ''}, ServerStub(updates)) + obj.initModule() + obj.statelist = [] + try: + obj._Module__pollThread(obj.polledModules, None) + except TypeError: + pass # None is not callable + updates.clear() + return obj, updates + + +def test_updates(): + obj, updates = create_module() + obj.start_machine(obj.state_one) + for _ in range(10): + obj.doPoll() + assert updates == [ + ('status', (Status.BUSY, 'state one')), # default: BUSY, function name without '_' + ('status', (Status.PREPARING, 'state 2')), # explicitly given + ('status', (Status.FINALIZING, 'state three')), # only code given + ('status', (Status.IDLE, 'finished')), + ] + + +def test_stop_without_cleanup(): + obj, updates = create_module() + obj.start_machine(obj.state_one) + obj.doPoll() + obj.stop_machine() + for _ in range(10): + obj.doPoll() + assert updates == [ + ('status', (Status.BUSY, 'state one')), + ('status', (Status.BUSY, 'stopping')), + ('status', (Status.IDLE, 'stopped')), + ] + assert obj.statelist == ['state_one', None] + + +def test_stop_with_cleanup(): + obj, updates = create_module() + obj.start_machine(obj.state_one, cleanup=obj.on_cleanup) + obj.doPoll() + obj.stop_machine() + for _ in range(10): + obj.doPoll() + assert updates == [ + ('status', (Status.BUSY, 'state one')), + ('status', (Status.BUSY, 'stopping')), + ('status', (Status.BUSY, 'stopping (cleanup one)')), + ('status', (Status.IDLE, 'stopped')), + ] + assert obj.statelist == ['state_one', 'cleanup_one', 'cleanup_two', None] + + +def test_all_restart(): + obj, updates = create_module() + obj.start_machine(obj.state_one, cleanup=obj.on_cleanup, statelist=[]) + obj.doPoll() + obj.start_machine(obj.state_three) + for _ in range(10): + obj.doPoll() + assert updates == [ + ('status', (Status.BUSY, 'state one')), + ('status', (Status.FINALIZING, 'restarting')), + ('status', (Status.FINALIZING, 'restarting (cleanup one)')), + ('status', (Status.FINALIZING, 'state three')), + ('status', (Status.IDLE, 'finished')), + ] + assert obj.statelist == ['state_one', 'cleanup_one', 'cleanup_two', None, 'state_three', None]