frappy/secop/lib/statemachine.py
Markus Zolliker 9636dc9cea state on dilsc as of 2022-10-03
vector field, but no new state machine yet
2022-11-21 14:51:02 +01:00

353 lines
12 KiB
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 <markus.zolliker@psi.ch>
#
# *****************************************************************************
"""a simple, but powerful state machine
Mechanism
---------
The code for the state machine is NOT to be implemented as a subclass
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(<delay>) to keep the state and call the
- or `Finish` for finishing
Initialisation Code
-------------------
For code to be called only after a state transition, use stateobj.init.
def state_x(stateobj):
if stateobj.init:
... code to be execute only after entering state x ...
... further code ...
Error Handler
-------------
handler=<handler object> as argument in StateMachine.__init__ or .start
defines a handler object to be called whenever the machine is stopped,
restarted. finished or an error is raised in a state function.
<handler>.on_error may return either None for finishing or a further state
function for continuing. The other handler methods always return None,
as there is no useful follow up state.
Threaded Use
------------
On start, a thread is started, which is waiting for a trigger event when the
machine is not active. In case the thread creation is disabled. :meth:`cycle`
must be called periodically for running the state machine.
"""
import time
import threading
import queue
from logging import getLogger
from secop.lib import mkthread, UniqueObject
Finish = UniqueObject('Finish')
Stop = UniqueObject('Stop')
Restart = UniqueObject('Restart')
class Retry:
def __init__(self, delay=None):
self.delay = delay
class StateHandler:
"""default handlers
may be used as base class or mixin for implementing custom handlers
"""
def on_error(self, statemachine, exc):
"""called on error
:param statemachine: the state machine object
:param exc: the exception
:return: None or a state function to be executed for handling the error state
"""
statemachine.log.warning('%r raised in state %r', exc, statemachine.status_string)
def on_transition(self, statemachine, newstate):
"""called when state is changed
:param statemachine: the statemachine
:param newstate: the new state function
this method will not be called when the state is changed to None,
e.g. on finish, restart, stop or when None is returned from the error handler
"""
def on_restart(self, statemachine):
"""called on restart
:param statemachine: the state machine object
"""
def on_stop(self, statemachine):
"""called when stopped
:param statemachine: the state machine object
"""
def on_finish(self, statemachine):
"""called on finish
:param statemachine: the state machine object
"""
default_handler = StateHandler()
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
restarted = False
_last_time = 0
def __init__(self, state=None, logger=None, threaded=True, **kwds):
"""initialize state machine
:param state: if given, this is the first state
: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 Retry(None)
self.now = time.time() # avoid calling time.time several times per state
self.handler = default_handler
self.log = logger or getLogger('dummy')
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
if state:
self.start(state)
def _update_attributes(self, kwds):
"""update allowed attributes"""
cls = type(self)
for key, value in kwds.items():
if hasattr(cls, key):
raise AttributeError('can not set %s.%s' % (cls.__name__, key))
setattr(self, key, value)
@property
def is_active(self):
return bool(self.state)
@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
self.log.debug('state: %s', self.status_string)
if state:
self.handler.on_transition(self, state)
self.init = True
self.now = time.time()
self.transition_time = self.now
def cycle(self):
"""do one cycle in the thread loop
:return: a delay or None when idle
"""
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.log.debug('stopped in state %r', self.status_string)
self.handler.on_stop(self)
self.stopped = False
ret = None
elif self.restarted:
self.log.debug('restarted in state %r', self.status_string)
self.handler.on_restart(self)
self.restarted = False
ret = None
except Exception as e:
try:
ret = self.handler.on_error(self, e)
self.log.debug('called on_error with exc=%r%s', e,
' ret=%r' % ret if ret else '')
except Exception as ee:
self.log.exception('%r raised in on_error(state, %r)', ee, e)
if ret is Finish:
self.log.debug('finish in state %r', self.status_string)
self.handler.on_finish(self)
self._new_state(None)
self._idle_event.set()
return None
if callable(ret):
self._new_state(ret)
continue
if isinstance(ret, Retry):
if ret.delay == 0:
continue
if ret.delay is None:
return self.default_delay
return ret.delay
self.handler.on_error(self, RuntimeError(
'return value must be callable, Retry(...) or Finish, not %r' % ret))
break
else:
self.handler.on_error(self, RuntimeError(
'too many states chained - probably infinite loop'))
self._new_state(None)
return None
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._idle_event.clear()
self.restarted = False
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):
"""start with a new state
and interrupt the current state
the cleanup function will be called with state.restarted = True
:param state: the first state
:param kwds: items to put as attributes on the state machine
"""
self.log.debug('start %r', kwds)
if self.state:
self.restarted = True
with self._lock: # wait for running cycle finished
if self.restarted: # on_restart is not yet called
self.handler.on_restart(self)
self.restarted = False
self._start(state, **kwds)
else:
self._start(state, **kwds)
def stop(self):
"""stop machine, go to idle state
the cleanup function will be called with state.stopped = True
"""
self.log.debug('stop')
self.stopped = True
with self._lock:
if self.stopped: # on_stop is not yet called
self.handler.on_stop(self)
self.stopped = False
self._new_state(None)
def wait(self, timeout=None):
"""wait for state machine being idle"""
self._idle_event.wait(timeout)
def delta(self, mindelta=0):
"""helper method for time dependent control
: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
Usage:
def state_x(self, state):
delta = state.delta(5)
if delta is None:
return # less than 5 seconds have passed, we wait for the next cycle
# delta is >= 5, and the zero time for delta is set
# now we can use delta for control calculations
remark: in the first step after start, state.delta(0) returns nearly 0
"""
delta = self.now - self._last_time
if delta < mindelta:
return None
self._last_time = self.now
return delta