A library for expressing a self-running state machine. States and Transitions in the machine are more than simple nodes and edges, though.
Each State in the machine may have a precondition
and/or postcondition
. These are arbitrary code expressed via a lambda or Proc instance, and control whether or not the State may be entered or exited.
Each State also may have a prelude
and/or postlude
. These are arbitrary code chunks (also a lambda or Proc instance) that are run when entering and/or exiting a State.
Transitions may also have a precondition
that controls whether or not the transition may occur, and may have an action
that runs as the transition occurs.
Transitions are primarily triggered by an 'event' fired into the machine. Instead of a global namespace of events, each event may be fired along with a 'scope'. (Scopes typically specify the source of the event.) Transitions can be specified to occur when an event is fired only in a specific scope, or when the event is fired in any scope.
Transitions may also be specified without any triggering event. Such transitions occur as soon as they are legal. (In the most restrictive case, such a transition is only possible if the postcondition of the active state is met, the precondition of the transition is met, and the precondition of the target state is met.)
Because Transitions may execute code arbitrary code when executed, and thus affect some internal state of the machine or application, a Transitions may be specified to go to the same state as they originated in. In this case, the prelude
of the State is not run upon transitioning.
Every precondition
/postcondition
/prelude
/postlude
/action
block is executed using instance_eval
in the scope of the plan instance itself. This gives those code blocks access to the current_state
, plan_time
(number of seconds the plan has been running), state_time
(number of seconds the current state has been active), and env
variables.
The env
accessor returns an auto-vivifying Hash of hashes. The intended use is to provide a scope as the first key to the Hash, and the name of a global variable in that scope as the second key. This provides a convenient dumping ground for code to create and manage side inforamtion that helps simplify states in the machine.
planmachine9.rb (download)class PlanMachine9
class State
# The PlanMachine9 instance that this state is associated with
attr_accessor :machine
# A nice name to associate with this state (optional; mainly for debugging purposes)
attr_accessor :name
# Proc or lambda to determine if this state may be entered (optional)
attr_accessor :precondition
# Proc or lambda to determine if this state may be exited (optional)
attr_accessor :postcondition
# Proc or lambda to run right after the state is entered (optional)
attr_accessor :prelude
# Proc or lambda to run right before the state is entered (optional)
attr_accessor :postlude
# Arrays of all Transition instances leading into and out of this state.
attr_reader :transitions_in, :transitions_out
# If the instance has a +precondition+ lambda/proc set,
def enterable?
!@precondition || self.machine.instance_eval( &@precondition )
end
# If the state has a +postcondition+ lambda/proc set.
def exitable?
!@postcondition || self.machine.instance_eval( &@postcondition )
end
def initialize( name=nil, &prelude )
@name = name
@prelude = prelude
@transitions_in = []
@transitions_out = []
end
def self.from_rexml( node )
state = self.new( node.attributes['id'] )
%w| precondition prelude postcondition postlude|.each{ |name|
if pnode = REXML::XPath.first( node, "#{name}[@type='text/ruby']" )
state.send( "#{name}=", eval( "lambda{ #{pnode.text} }" ) )
end
}
state
end
def run_prelude
@prelude && self.machine.instance_eval( &@prelude )
end
def run_postlude
@postlude && self.machine.instance_eval( &@postlude )
end
def to_s
"<State '#{name}'>"
end
end
class Transition
# The PlanMachine9 instance that this transition is associated with
attr_accessor :machine
# Proc or lambda to determine if this transition may be run (optional)
attr_accessor :precondition
# Proc or lambda to run during the transition, when exiting the state (optional)
attr_accessor :action
# State instances at either end of the transition
attr_accessor :from, :to
# The event (and its scope) that triggers the transition. Both may be nil.
attr_accessor :event, :scope
# If the instance has a +precondition+ lambda/proc set,
def available?
( !@precondition || self.machine.instance_eval( &@precondition ) ) &&
( @from==@to || @to.enterable? )
end
def initialize( from=nil, to=nil, event=nil, scope=nil, &action )
self.from = from
self.to = to
self.event = event
self.scope = scope
self.action = action
end
def from=( state )
return if @from == state
@from.transitions_out.delete( self ) if @from
@from = state
@from.transitions_out << self if @from
end
def to=( state )
return if @to == state
@to.transitions_in.delete( self ) if @to
@to = state
@to.transitions_in << self if @to
end
def self.from_rexml( node )
transition = self.new
%w| precondition action|.each{ |name|
if pnode = REXML::XPath.first( node, "#{name}[@type='text/ruby']" )
transition.send( "#{name}=", eval( "lambda{ #{pnode.text} }" ) )
end
}
transition.event = node.attributes['event']
transition.scope = node.attributes['scope']
transition
end
def execute
@action && self.machine.instance_eval( &@action )
end
def to_s
"<Transition from='#{from.name}' to='#{to.name}' event='#{event}' scope='#{scope}'>"
end
end
end
class PlanMachine9
# A hash of hashes, made available to code in states and transitions for storing values
attr_reader :env
# The State the machine should #start at each time.
attr_accessor :initial_state
# The State the mahine is currently in.
:current_state
def self.from_xml( xmlstring )
plan = self.new
states_by_name = {}
require 'rexml/document'
doc = REXML::Document.new( xmlstring )
# Load in all the states before connecting them with transitions
doc.each_element( '//state' ){ |xml_node|
s = State.from_rexml( xml_node )
states_by_name[ s.name ] = s
plan.initial_state ||= s
}
# Find all the transitions and create them
doc.each_element( '//transition' ){ |xml_node|
t = Transition.from_rexml( xml_node )
t.from = states_by_name[ xml_node.parent.attributes['id'] ]
t.to = states_by_name[ xml_node.attributes['to'] ]
}
plan.prepare_plan
plan
end
def initialize( initial_state=nil )
@initial_state = initial_state
@env = Hash.new{ |h,scope| h[scope] = {} }
end
def start( update_interval = 1.0/60 )
# Start the timers
@plan_start = @state_start = Time.now
# Move to the first state
@current_state = @initial_state
@current_state.run_prelude
# Kick off a timer
@update_interval = update_interval
@update_timer = Thread.new{
while true
self.update
sleep @update_interval
end
}
end
def pause
#TODO: pause threads
@pause_started = Time.now
end
def resume
pause_duration = Time.now - @pause_started
@plan_start += pause_duration
@state_start += pause_duration
#TODO: resume threads
end
def stop
@update_timer.kill
end
def plan_time
Time.now - @plan_start
end
def state_time
Time.now - @state_start
end
def update
return nil unless @current_state.exitable?
eventless_transitions = @transitions_by_state[ @current_state ][ nil ]
if avail = eventless_transitions.find{ |t| t.available? }
perform_transition( avail )
end
end
def handle_event( event, scope=nil )
return nil unless @current_state.exitable?
scoped_transitions = @transitions_by_state[ @current_state ][ event ]
# Start off with all transitions specific to the specified scope
possible_transitions = scoped_transitions[ scope ]
# ...add in global transitions, in case no scope-specific transitions match
possible_transitions += scoped_transitions[ nil ] if scope
if avail = possible_transitions.find{ |t| t.available? }
perform_transition( avail )
end
end
def perform_transition( transition )
@current_state.run_postlude
transition.execute
next_state = transition.to
if next_state != @current_state
@current_state = next_state
@current_state.run_prelude
@state_start = Time.now
end
@current_state
end
def to_s
"<PlanMachine9 @ #{@current_state}>"
end
# Must be called once after all States and Transitions are accessible
# from the initial_state for the machine.
def prepare_plan
@states_prepared = {}
@transitions_by_state = Hash.new{ |h1,state|
h1[ state ] = Hash.new{ |h2,event|
h2[ event ] = if event
Hash.new{ |h3,scope|
h3[ scope ] = []
}
else
[]
end
}
}
prepare_plan_for_state( @initial_state ) if @initial_state
end
private
def prepare_plan_for_state( state )
@states_prepared[ state ] = true
state.machine = self
state.transitions_in.uniq!
state.transitions_out.uniq!
transitions_by_event = @transitions_by_state[ state ]
state.transitions_out.each{ |transition|
if evt = transition.event
transitions_by_event[ evt ][ transition.scope ] << transition
else
transitions_by_event[ nil ] << transition
end
transition.machine = self
unless @states_prepared[ to = transition.to ]
prepare_plan_for_state( to )
end
}
end
end
elevator_plan.rb (download)def puts( *args )
super
$stdout.flush
end
require 'planmachine9.rb'
class Elevator < PlanMachine9
def sound_alarm
puts "Sound the alarm! Something's stuck in the doors!"
end
def clear_alarm
handle_event( 'all_clear' )
end
def up
handle_event( 'up', 'outer_buttons' )
end
def down
handle_event( 'down', 'outer_buttons' )
end
def close
handle_event( 'close_doors', 'inner_buttons' )
end
def open
handle_event( 'open_doors', 'inner_buttons' )
end
def bump_doors
handle_event( 'obstacle_detected', 'elevator' )
end
def update
puts "@ #{@current_state}"
super
end
def handle_event( event, scope=nil )
puts "-->#{event.inspect} (scope: #{scope.inspect})<--"
super
end
def perform_transition( transition )
puts "...executing #{transition}"
super
end
end
elevator = Elevator.from_xml( IO.read( 'elevator.pxml9' ) )
elevator.start( 0.3 )
sleep 1
elevator.up
sleep 0.2
elevator.up
sleep 3
elevator.close
sleep 0.6
elevator.bump_doors
sleep 14
elevator.bump_doors
sleep 4
elevator.close
sleep 0.6
elevator.bump_doors
sleep 4
elevator.close
sleep 0.6
elevator.open
sleep 4
elevator.close
sleep 0.6
elevator.bump_doors
sleep 4
elevator.close
sleep 0.6
elevator.bump_doors
sleep 1
elevator.clear_alarm
sleep 4
elevator.pxml9 (download)<planmachine9>
<state id="doors_closed">
<prelude type="text/ruby">
env[ :elevator ][ :interruptions ] = 0
</prelude>
<transition to="doors_opening" event="up" scope="outer_buttons" />
<transition to="doors_opening" event="down" scope="outer_buttons" />
</state>
<state id="doors_opening">
<transition to="doors_open">
<precondition type="text/ruby">
state_time >= 2
</precondition>
</transition>
</state>
<state id="doors_closing">
<transition to="doors_closed">
<precondition type="text/ruby">
state_time >= 2
</precondition>
</transition>
<transition to="doors_opening" event="obstacle_detected">
<precondition type="text/ruby">
env[ :elevator ][ :interruptions ] < 4
</precondition>
<action type="text/ruby">
env[ :elevator ][ :interruptions ] += 1
</action>
</transition>
<transition to="doors_opening" event="open_doors" scope="inner_buttons">
<precondition type="text/ruby">
env[ :elevator ][ :interruptions ] < 4
</precondition>
<action type="text/ruby">
env[ :elevator ][ :interruptions ] += 1
</action>
</transition>
<transition to="alarmed">
<precondition type="text/ruby">
env[ :elevator ][ :interruptions ] >= 4
</precondition>
</transition>
</state>
<state id="doors_open">
<transition to="doors_closing">
<precondition type="text/ruby">
state_time >= 10
</precondition>
</transition>
<transition to="doors_closing" event="close_doors" scope="inner_buttons" />
</state>
<state id="alarmed">
<prelude type="text/ruby">
sound_alarm
</prelude>
<transition to="doors_closing" event="all_clear" />
<postlude type="text/ruby">
env[ :elevator ][ :interruptions ] = 0
puts "All Clear"
</postlude>
</state>
</planmachine9>
(output)@ <State 'doors_closed'>
@ <State 'doors_closed'>
@ <State 'doors_closed'>
@ <State 'doors_closed'>
-->"up" (scope: "outer_buttons")<--
...executing <Transition from='doors_closed' to='doors_opening' event='up' scope='outer_buttons'>
-->"up" (scope: "outer_buttons")<--
@ <State 'doors_opening'>
@ <State 'doors_opening'>
@ <State 'doors_opening'>
@ <State 'doors_opening'>
@ <State 'doors_opening'>
@ <State 'doors_opening'>
@ <State 'doors_opening'>
...executing <Transition from='doors_opening' to='doors_open' event='' scope=''>
@ <State 'doors_open'>
@ <State 'doors_open'>
@ <State 'doors_open'>
-->"close_doors" (scope: "inner_buttons")<--
...executing <Transition from='doors_open' to='doors_closing' event='close_doors' scope='inner_buttons'>
@ <State 'doors_closing'>
@ <State 'doors_closing'>
-->"obstacle_detected" (scope: "elevator")<--
...executing <Transition from='doors_closing' to='doors_opening' event='obstacle_detected' scope=''>
@ <State 'doors_opening'>
@ <State 'doors_opening'>
@ <State 'doors_opening'>
@ <State 'doors_opening'>
@ <State 'doors_opening'>
@ <State 'doors_opening'>
@ <State 'doors_opening'>
...executing <Transition from='doors_opening' to='doors_open' event='' scope=''>
@ <State 'doors_open'>
@ <State 'doors_open'>
@ <State 'doors_open'>
@ <State 'doors_open'>
@ <State 'doors_open'>
@ <State 'doors_open'>
@ <State 'doors_open'>
@ <State 'doors_open'>
@ <State 'doors_open'>
@ <State 'doors_open'>
@ <State 'doors_open'>
@ <State 'doors_open'>
@ <State 'doors_open'>
@ <State 'doors_open'>
@ <State 'doors_open'>
@ <State 'doors_open'>
@ <State 'doors_open'>
@ <State 'doors_open'>
@ <State 'doors_open'>
@ <State 'doors_open'>
@ <State 'doors_open'>
@ <State 'doors_open'>
@ <State 'doors_open'>
@ <State 'doors_open'>
@ <State 'doors_open'>
@ <State 'doors_open'>
@ <State 'doors_open'>
@ <State 'doors_open'>
@ <State 'doors_open'>
@ <State 'doors_open'>
@ <State 'doors_open'>
@ <State 'doors_open'>
...executing <Transition from='doors_open' to='doors_closing' event='' scope=''>
@ <State 'doors_closing'>
@ <State 'doors_closing'>
@ <State 'doors_closing'>
@ <State 'doors_closing'>
@ <State 'doors_closing'>
@ <State 'doors_closing'>
-->"obstacle_detected" (scope: "elevator")<--
...executing <Transition from='doors_closing' to='doors_opening' event='obstacle_detected' scope=''>
@ <State 'doors_opening'>
@ <State 'doors_opening'>
@ <State 'doors_opening'>
@ <State 'doors_opening'>
@ <State 'doors_opening'>
@ <State 'doors_opening'>
@ <State 'doors_opening'>
...executing <Transition from='doors_opening' to='doors_open' event='' scope=''>
@ <State 'doors_open'>
@ <State 'doors_open'>
@ <State 'doors_open'>
@ <State 'doors_open'>
@ <State 'doors_open'>
-->"close_doors" (scope: "inner_buttons")<--
@ <State 'doors_open'>
...executing <Transition from='doors_open' to='doors_closing' event='close_doors' scope='inner_buttons'>
@ <State 'doors_closing'>
-->"obstacle_detected" (scope: "elevator")<--
...executing <Transition from='doors_closing' to='doors_opening' event='obstacle_detected' scope=''>
@ <State 'doors_opening'>
@ <State 'doors_opening'>
@ <State 'doors_opening'>
@ <State 'doors_opening'>
@ <State 'doors_opening'>
@ <State 'doors_opening'>
@ <State 'doors_opening'>
@ <State 'doors_opening'>
...executing <Transition from='doors_opening' to='doors_open' event='' scope=''>
@ <State 'doors_open'>
@ <State 'doors_open'>
@ <State 'doors_open'>
@ <State 'doors_open'>
@ <State 'doors_open'>
-->"close_doors" (scope: "inner_buttons")<--
...executing <Transition from='doors_open' to='doors_closing' event='close_doors' scope='inner_buttons'>
@ <State 'doors_closing'>
@ <State 'doors_closing'>
-->"open_doors" (scope: "inner_buttons")<--
...executing <Transition from='doors_closing' to='doors_opening' event='open_doors' scope='inner_buttons'>
@ <State 'doors_opening'>
@ <State 'doors_opening'>
@ <State 'doors_opening'>
@ <State 'doors_opening'>
@ <State 'doors_opening'>
@ <State 'doors_opening'>
@ <State 'doors_opening'>
@ <State 'doors_opening'>
...executing <Transition from='doors_opening' to='doors_open' event='' scope=''>
@ <State 'doors_open'>
@ <State 'doors_open'>
@ <State 'doors_open'>
@ <State 'doors_open'>
@ <State 'doors_open'>
-->"close_doors" (scope: "inner_buttons")<--
...executing <Transition from='doors_open' to='doors_closing' event='close_doors' scope='inner_buttons'>
@ <State 'doors_closing'>
...executing <Transition from='doors_closing' to='alarmed' event='' scope=''>
Sound the alarm! Something's stuck in the doors!
@ <State 'alarmed'>
-->"obstacle_detected" (scope: "elevator")<--
@ <State 'alarmed'>
@ <State 'alarmed'>
@ <State 'alarmed'>
@ <State 'alarmed'>
@ <State 'alarmed'>
@ <State 'alarmed'>
@ <State 'alarmed'>
@ <State 'alarmed'>
@ <State 'alarmed'>
@ <State 'alarmed'>
@ <State 'alarmed'>
@ <State 'alarmed'>
@ <State 'alarmed'>
-->"close_doors" (scope: "inner_buttons")<--
@ <State 'alarmed'>
@ <State 'alarmed'>
-->"obstacle_detected" (scope: "elevator")<--
@ <State 'alarmed'>
@ <State 'alarmed'>
@ <State 'alarmed'>
-->"all_clear" (scope: nil)<--
...executing <Transition from='alarmed' to='doors_closing' event='all_clear' scope=''>
All Clear
@ <State 'doors_closing'>
@ <State 'doors_closing'>
@ <State 'doors_closing'>
@ <State 'doors_closing'>
@ <State 'doors_closing'>
@ <State 'doors_closing'>
@ <State 'doors_closing'>
...executing <Transition from='doors_closing' to='doors_closed' event='' scope=''>
@ <State 'doors_closed'>
@ <State 'doors_closed'>
@ <State 'doors_closed'>
@ <State 'doors_closed'>
@ <State 'doors_closed'>
@ <State 'doors_closed'>