1
0
mirror of https://github.com/JoelBender/bacpypes synced 2025-09-28 22:15:23 +08:00
bacpypes/tests/state_machine.py
2016-05-07 15:53:58 -04:00

885 lines
28 KiB
Python
Executable File

#!/usr/bin/python
"""
Testing State Machine
---------------------
"""
import os
try:
# Python 3
from queue import Queue
except ImportError:
# Python 2
from Queue import Queue
from bacpypes.debugging import bacpypes_debugging, ModuleLogger
from bacpypes.comm import Client, Server
from bacpypes.task import FunctionTask as _FunctionTask
# some debugging
_debug = 0
_log = ModuleLogger(globals())
class Transition:
"""
Transition
~~~~~~~~~~
Instances of this class are transitions betweeen states of a state
machine.
"""
def __init__(self, next_state):
self.next_state = next_state
class SendTransition(Transition):
def __init__(self, pdu, next_state):
Transition.__init__(self, next_state)
self.pdu = pdu
class ReceiveTransition(Transition):
def __init__(self, pdu, next_state):
Transition.__init__(self, next_state)
self.pdu = pdu
class TimeoutTransition(Transition):
def __init__(self, timeout, next_state):
Transition.__init__(self, next_state)
self.timeout = timeout
@bacpypes_debugging
class State:
"""
State
~~~~~
Instances of this class, or a derived class, are the states of a state
machine.
"""
def __init__(self, state_machine, doc_string=""):
"""Create a new state, bound to a specific state machine. This is
typically called by the state machine.
"""
if _debug:
State._debug(
"__init__ %r doc_string=%r", state_machine, doc_string
)
self.state_machine = state_machine
self.doc_string = doc_string
self.is_success_state = False
self.is_fail_state = False
# empty lists of send and receive transitions
self.send_transitions = []
self.receive_transitions = []
# timeout transition
self.timeout_transition = None
def reset(self):
"""Override this method in a derived class if the state maintains
counters or other information. Called when the associated state
machine is reset.
"""
if _debug: State._debug("reset")
def doc(self, doc_string):
"""Change the documentation string (label) for the state. The state
is returned for method chaining.
"""
if _debug: State._debug("doc %r", doc_string)
# save the doc string
self.doc_string = doc_string
# chainable
return self
def success(self, doc_string=None):
"""Mark a state as a successful final state. The state is returned
for method chaining.
:param doc_string: an optional label for the state
"""
if _debug: State._debug("success %r", doc_string)
# error checking
if self.is_success_state:
raise RuntimeError("already a success state")
if self.is_fail_state:
raise RuntimeError("already a fail state")
# this is now a success state
self.is_success_state = True
# save the new doc string
if doc_string is not None:
self.doc_string = doc_string
elif not self.doc_string:
self.doc_string = "success"
# chainable
return self
def fail(self, doc_string=None):
"""Mark a state as a failure final state. The state is returned
for method chaining.
:param doc_string: an optional label for the state
"""
if _debug: State._debug("fail %r", doc_string)
# error checking
if self.is_success_state:
raise RuntimeError("already a success state")
if self.is_fail_state:
raise RuntimeError("already a fail state")
# this is now a fail state
self.is_fail_state = True
# save the new doc string
if doc_string is not None:
self.doc_string = doc_string
elif not self.doc_string:
self.doc_string = "fail"
# chainable
return self
def enter_state(self):
"""Called when the state machine is entering the state."""
if _debug: State._debug("enter_state(%s)", self.doc_string)
# if there is a timeout, schedule it
if self.timeout_transition:
if _debug: State._debug(" - waiting: %r", self.timeout_transition.timeout)
# schedule the timeout
self.state_machine.state_timeout_task.install_task(delta=self.timeout_transition.timeout)
else:
if _debug: State._debug(" - no timeout")
def exit_state(self):
"""Called when the state machine is exiting the state."""
if _debug: State._debug("exit_state(%s)", self.doc_string)
# if there was a timeout, suspend it
if self.timeout_transition:
if _debug: State._debug(" - canceling timeout")
self.state_machine.state_timeout_task.suspend_task()
def send(self, pdu, next_state=None):
"""Create a SendTransition from this state to another, possibly new,
state. The next state is returned for method chaining.
:param pdu: PDU to send
:param next_state: state to transition to after sending
"""
if _debug: State._debug("send(%s) %r next_state=%r", self.doc_string, pdu, next_state)
# maybe build a new state
if not next_state:
next_state = self.state_machine.new_state()
if _debug: State._debug(" - new next_state: %r", next_state)
elif next_state not in self.state_machine.states:
raise ValueError("off the rails")
# add this to the list of transitions
self.send_transitions.append(SendTransition(pdu, next_state))
# return the next state
return next_state
def before_send(self, pdu):
"""Called before each PDU about to be sent."""
self.state_machine.before_send(pdu)
def after_send(self, pdu):
"""Called after each PDU sent."""
self.state_machine.after_send(pdu)
def receive(self, pdu, next_state=None):
"""Create a ReceiveTransition from this state to another, possibly new,
state. The next state is returned for method chaining.
:param pdu: PDU to match
:param next_state: destination state after a successful match
"""
if _debug: State._debug("receive(%s) %r next_state=%r", self.doc_string, pdu, next_state)
# maybe build a new state
if not next_state:
next_state = self.state_machine.new_state()
if _debug: State._debug(" - new next_state: %r", next_state)
elif next_state not in self.state_machine.states:
raise ValueError("off the rails")
# add this to the list of transitions
self.receive_transitions.append(ReceiveTransition(pdu, next_state))
# return the next state
return next_state
def before_receive(self, pdu):
"""Called with each PDU received before matching."""
self.state_machine.before_receive(pdu)
def after_receive(self, pdu):
"""Called with PDU received after match."""
self.state_machine.after_receive(pdu)
def unexpected_receive(self, pdu):
"""Called with PDU that did not match. Unless this is trapped by the
state, the default behaviour is to fail."""
if _debug: State._debug("unexpected_receive %r", pdu)
# pass along to the state machine
self.state_machine.unexpected_receive(pdu)
def timeout(self, delay, next_state=None):
"""Create a TimeoutTransition from this state to another, possibly new,
state. There can only be one timeout transition per state. The next
state is returned for method chaining.
:param delay: the amount of time to wait for a matching PDU
:param next_state: destination state after timeout
"""
if _debug: State._debug("timeout(%s) %r next_state=%r", self.doc_string, delay, next_state)
# check to see if a timeout has already been specified
if self.timeout_transition:
raise RuntimeError("state already has a timeout")
# maybe build a new state
if not next_state:
next_state = self.state_machine.new_state()
if _debug: State._debug(" - new next_state: %r", next_state)
elif next_state not in self.state_machine.states:
raise ValueError("off the rails")
# set the transition
self.timeout_transition = TimeoutTransition(delay, next_state)
# return the next state
return next_state
def __repr__(self):
return "<%s(%s) at %s>" % (
self.__class__.__name__,
self.doc_string,
hex(id(self)),
)
@bacpypes_debugging
class StateMachine:
"""
StateMachine
~~~~~~~~~~~~
A state machine consisting of states. Every state machine has a start
state where the state machine begins when it is started. It also has
an *unexpected receive* fail state where the state machine goes if
there is an unexpected (unmatched) PDU received.
"""
def __init__(
self,
timeout=None,
start_state=None,
unexpected_receive_state=None,
machine_group=None,
state_subclass=State,
):
if _debug: StateMachine._debug("__init__")
# no states to starting out, not running
self.states = []
self.running = False
# might be part of a group
self.machine_group = machine_group
# reset to initial condition
self.reset()
# save the state subclass for new states
if not issubclass(state_subclass, State):
raise TypeError("%r is not derived from State" % (state_subclass,))
self.state_subclass = state_subclass
# create the start state
if start_state:
if start_state.state_machine:
raise RuntimeError("start state already bound to a machine")
self.states.append(start_state)
start_state.state_machine = self
else:
start_state = self.new_state("start")
self.start_state = start_state
# create the unexpected receive state
if unexpected_receive_state:
if unexpected_receive_state.state_machine:
raise RuntimeError("unexpected receive state already bound to a machine")
self.states.append(unexpected_receive_state)
unexpected_receive_state.state_machine = self
else:
unexpected_receive_state = self.new_state("unexpected receive").fail()
self.unexpected_receive_state = unexpected_receive_state
# received messages get queued during state transitions
self.state_transitioning = 0
self.transition_queue = Queue()
# create a state timeout task, to be installed as necessary
self.state_timeout_task = _FunctionTask(self.state_timeout)
# create a state machine timeout task
self.timeout = timeout
if timeout:
self.timeout_state = self.new_state("state machine timeout").fail()
self.timeout_task = _FunctionTask(self.state_machine_timeout)
else:
self.timeout_state = None
self.timeout_task = None
def new_state(self, doc="", state_subclass=None):
if _debug: StateMachine._debug("new_state %r %r", doc, state_subclass)
# check for proper subclass
if state_subclass and not issubclass(state_subclass, State):
raise TypeError("%r is not derived from State" % (state_subclass,))
# make the state object from the class that was provided or default
state = (state_subclass or self.state_subclass)(self, doc)
if _debug: StateMachine._debug(" - state: %r", state)
# save a reference to make sure we don't go off the rails
self.states.append(state)
# return the new state
return state
def reset(self):
if _debug: StateMachine._debug("reset")
# make sure we're not running
if self.running:
raise RuntimeError("state machine running")
# no current state, empty transaction log
self.current_state = None
self.transaction_log = []
# we are not starting up
self._startup_flag = False
# give all the states a chance to reset
for state in self.states:
state.reset()
def run(self):
if _debug: StateMachine._debug("run")
if self.running:
raise RuntimeError("state machine running")
if self.current_state:
raise RuntimeError("not running but has a current state")
# if there is a timeout task, schedule the fail
if self.timeout_task:
if _debug: StateMachine._debug(" - schedule runtime limit")
self.timeout_task.install_task(delta=self.timeout)
# we are starting up
self._startup_flag = True
# go to the start state
self.goto_state(self.start_state)
# if it is part of a group, let the group know
if self.machine_group:
self.machine_group.running(self)
# if it stopped already, let the group know
if not self.running:
self.machine_group.stopped(self)
# startup complete
self._startup_flag = False
def halt(self):
"""Called when the state machine should no longer be running."""
if _debug: StateMachine._debug("halt")
# make sure we're running
if not self.running:
raise RuntimeError("state machine not running")
# cancel the timeout
if self.timeout:
if _debug: StateMachine._debug(" - cancel runtime limit")
self.timeout_task.suspend_task()
# no longer running
self.running = False
def success(self):
"""Called when the state machine has successfully completed."""
if _debug: StateMachine._debug("success")
def fail(self):
"""Called when the state machine has failed."""
if _debug: StateMachine._debug("fail")
def goto_state(self, state):
if _debug: StateMachine._debug("goto_state %r", state)
# where do you think you're going?
if state not in self.states:
raise RuntimeError("off the rails")
# transitioning
self.state_transitioning += 1
# exit the old state
if self.current_state:
self.current_state.exit_state()
elif state is self.start_state:
# starting up
self.running = True
else:
raise RuntimeError("start at the start state")
# here we are
current_state = self.current_state = state
# check for success state
if current_state.is_success_state:
if _debug: StateMachine._debug(" - success state")
self.state_transitioning -= 1
self.halt()
self.success()
# if it is part of a group, let the group know
if self.machine_group and not self._startup_flag:
self.machine_group.stopped(self)
return
# check for fail state
if current_state.is_fail_state:
if _debug: StateMachine._debug(" - fail state")
self.state_transitioning -= 1
self.halt()
self.fail()
# if it is part of a group, let the group know
if self.machine_group and not self._startup_flag:
self.machine_group.stopped(self)
return
# let the state do something
current_state.enter_state()
if _debug: StateMachine._debug(" - state entered")
# assume we can stay
next_state = None
# send everything that needs to be sent
for transition in current_state.send_transitions:
if _debug: StateMachine._debug(" - sending: %r", transition)
current_state.before_send(transition.pdu)
self.send(transition.pdu)
current_state.after_send(transition.pdu)
# check for a transition
next_state = transition.next_state
if _debug: StateMachine._debug(" - next_state: %r", next_state)
if next_state is not current_state:
break
if not next_state:
if _debug: StateMachine._debug(" - nowhere to go")
elif next_state is self.current_state:
if _debug: StateMachine._debug(" - going nowhere")
else:
if _debug: StateMachine._debug(" - going")
self.goto_state(next_state)
# no longer transitioning
self.state_transitioning -= 1
# could be recursive call
if not self.state_transitioning:
while self.running and not self.transition_queue.empty():
pdu = self.transition_queue.get()
if _debug: StateMachine._debug(" - pdu: %r", pdu)
# try again
self.receive(pdu)
def before_send(self, pdu):
"""Called before each PDU about to be sent."""
# add a reference to the pdu in the transaction log
self.transaction_log.append(("<<<", pdu),)
def after_send(self, pdu):
"""Called after each PDU sent."""
pass
def receive(self, pdu):
if _debug: StateMachine._debug("receive %r", pdu)
if not self.running:
if _debug: StateMachine._debug(" - not running")
return
# check to see if we are transitioning
if self.state_transitioning:
if _debug: StateMachine._debug(" - transitioning")
self.transition_queue.put(pdu)
return
if not self.current_state:
raise RuntimeError("no current state")
current_state = self.current_state
# let the state know this was received
current_state.before_receive(pdu)
match_found = False
# look for a matching receive transition
for transition in current_state.receive_transitions:
if self.match_pdu(pdu, transition.pdu):
if _debug: StateMachine._debug(" - match found")
match_found = True
# let the state know this was matched
current_state.after_receive(pdu)
# check for a transition
next_state = transition.next_state
if _debug: StateMachine._debug(" - next_state: %r", next_state)
if next_state is not current_state:
break
else:
if _debug: StateMachine._debug(" - going nowhere")
if not match_found:
if _debug: StateMachine._debug(" - unexpected")
current_state.unexpected_receive(pdu)
elif next_state is not current_state:
if _debug: StateMachine._debug(" - going")
self.goto_state(next_state)
def before_receive(self, pdu):
"""Called with each PDU received before matching."""
# add a reference to the pdu in the transaction log
self.transaction_log.append((">>>", pdu),)
def after_receive(self, pdu):
"""Called with PDU received after match."""
pass
def unexpected_receive(self, pdu):
"""Called with PDU that did not match. Unless this is trapped by the
state, the default behaviour is to fail."""
if _debug: StateMachine._debug("unexpected_receive %r", pdu)
# go to the unexpected receive state (failing)
self.goto_state(self.unexpected_receive_state)
def state_timeout(self):
if _debug: StateMachine._debug("state_timeout")
if not self.running:
raise RuntimeError("state machine not running")
if not self.current_state.timeout_transition:
raise RuntimeError("state timeout, but no timeout transition")
# go to the state specified
self.goto_state(self.current_state.timeout_transition.next_state)
def state_machine_timeout(self):
if _debug: StateMachine._debug("state_machine_timeout")
if not self.running:
raise RuntimeError("state machine not running")
# go to the state specified
self.goto_state(self.timeout_state)
def send(self, pdu):
raise NotImplementedError("send not implemented")
def match_pdu(self, pdu, transition_pdu):
if _debug: StateMachine._debug("match_pdu %r %r", pdu, transition_pdu)
return pdu == transition_pdu
def __repr__(self):
if not self.running:
state_text = "idle "
else:
state_text = "in "
state_text += repr(self.current_state)
return "<%s %s at %s>" % (
self.__class__.__name__,
state_text,
hex(id(self)),
)
@bacpypes_debugging
class StateMachineGroup:
"""
StateMachineGroup
~~~~~~~~~~~~~~~~~
A state machine group is a collection of state machines that are all
started and stopped together. There are methods available to derived
classes that are called when all of the machines in the group have
completed, either all successfully or at least one has failed.
.. note:: When creating a group of state machines, add the ones that
are expecting to receive one or more PDU's first before the ones
that send PDU's. They will be started first, and be ready for the
PDU that might be sent.
"""
def __init__(self):
"""Create a state machine group."""
if _debug: StateMachineGroup._debug("__init__")
# empty list of machines
self.state_machines = []
# flag for starting up
self._startup_flag = False
def append(self, state_machine):
"""Add a state machine to the end of the list of state machines."""
if _debug: StateMachineGroup._debug("append %r", state_machine)
# check the state machine
if not isinstance(state_machine, StateMachine):
raise TypeError("not a state machine")
if state_machine.machine_group:
raise RuntimeError("state machine already a part of a group")
# tell the state machine it is a member of this group
state_machine.machine_group = self
# add it to the list
self.state_machines.append(state_machine)
def remove(self, state_machine):
"""Remove a state machine from the list of state machines."""
if _debug: StateMachineGroup._debug("remove %r", state_machine)
# check the state machine
if not isinstance(state_machine, StateMachine):
raise TypeError("not a state machine")
if state_machine.machine_group is not self:
raise RuntimeError("state machine not a member of this group")
# tell the state machine it is no longer a member of this group
state_machine.machine_group = None
# pass along to the list
self.state_machines.remove(state_machine)
def reset(self):
"""Resets all the machines in the group."""
if _debug: StateMachineGroup._debug("reset")
# pass along to each machine
for state_machine in self.state_machines:
if _debug: StateMachineGroup._debug(" - resetting: %r", state_machine)
state_machine.reset()
def run(self):
"""Runs all the machines in the group."""
if _debug: StateMachineGroup._debug("run")
# turn on the startup flag
self._startup_flag = True
# pass along to each machine
for state_machine in self.state_machines:
if _debug: StateMachineGroup._debug(" - running: %r", state_machine)
state_machine.run()
# turn off the startup flag
self._startup_flag = False
if _debug: StateMachineGroup._debug(" - all started")
# check for success/fail, all of the machines may already be done
all_success, some_failed = self.check_for_success()
if all_success:
self.success()
elif some_failed:
self.fail()
def running(self, state_machine):
"""Called by a state machine in the group when it has completed its
transition into its starting state."""
if _debug: StateMachineGroup._debug("running %r", state_machine)
def stopped(self, state_machine):
"""Called by a state machine after it has halted and its success()
or fail() method has been called."""
if _debug: StateMachineGroup._debug("stopped %r", state_machine)
# if we are not starting up, check for success/fail
if not self._startup_flag:
all_success, some_failed = self.check_for_success()
if all_success:
self.success()
elif some_failed:
self.fail()
def check_for_success(self):
"""Called after all of the machines have started, and each time a
machine has stopped, to see if the entire group should be considered
a success or fail."""
if _debug: StateMachineGroup._debug("check_for_success")
# accumulators
all_success = True
some_failed = False
# check each machine
for state_machine in self.state_machines:
if state_machine.running:
if _debug: StateMachineGroup._debug(" - running: %r", state_machine)
all_success = some_failed = None
break
# if there is no current state it was reset
if not state_machine.current_state:
if _debug: StateMachineGroup._debug(" - no current state: %r", state_machine)
continue
all_success = all_success and state_machine.current_state.is_success_state
some_failed = some_failed or state_machine.current_state.is_fail_state
if _debug:
StateMachineGroup._debug(" all_success: %r", all_success)
StateMachineGroup._debug(" some_failed: %r", some_failed)
# return the results of the check
return (all_success, some_failed)
def halt(self):
"""Halts all of the running machines in the group."""
if _debug: StateMachineGroup._debug("halt")
# pass along to each machine
for state_machine in self.state_machines:
if state_machine.running:
state_machine.halt()
def success(self):
"""Called when all of the machines in the group have halted and they
are all in a 'success' final state."""
if _debug: StateMachineGroup._debug("success")
def fail(self):
"""Called when all of the machines in the group have halted and at
at least one of them is in a 'fail' final state."""
if _debug: StateMachineGroup._debug("fail")
@bacpypes_debugging
class ClientStateMachine(Client, StateMachine):
"""
ClientStateMachine
~~~~~~~~~~~~~~~~~~
An instance of this class sits at the top of a stack. PDU's that the
state machine sends are sent down the stack and PDU's coming up the
stack are fed as received PDU's.
"""
def __init__(self):
if _debug: ClientStateMachine._debug("__init__")
Client.__init__(self)
StateMachine.__init__(self)
def send(self, pdu):
if _debug: ClientStateMachine._debug("send %r", pdu)
self.request(pdu)
def confirmation(self, pdu):
if _debug: ClientStateMachine._debug("confirmation %r", pdu)
self.receive(pdu)
@bacpypes_debugging
class ServerStateMachine(Server, StateMachine):
"""
ServerStateMachine
~~~~~~~~~~~~~~~~~~
An instance of this class sits at the bottom of a stack. PDU's that the
state machine sends are sent up the stack and PDU's coming down the
stack are fed as received PDU's.
"""
def __init__(self):
if _debug: ServerStateMachine._debug("__init__")
Server.__init__(self)
StateMachine.__init__(self)
def send(self, pdu):
if _debug: ServerStateMachine._debug("send %r", pdu)
self.response(pdu)
def indication(self, pdu):
if _debug: ServerStateMachine._debug("indication %r", pdu)
self.receive(pdu)