357 lines
8.2 KiB
Go
357 lines
8.2 KiB
Go
/*
|
|
Package fsm defines an embeddable finite state machine.
|
|
|
|
In its initial version, it is a straight port of the 2012 PHP
|
|
OSInet\Finite_State_Machine\Machine available in https://code.osinet.fr/fgm/php__phplib.git
|
|
|
|
See http://wiki.audean.com/fsm/fsm for details.
|
|
|
|
Copyright (c) 2023 Ouest Systèmes Informatiques
|
|
|
|
License GPL-3.0
|
|
*/
|
|
package fsm
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
|
|
"golang.org/x/exp/maps"
|
|
"golang.org/x/exp/slices"
|
|
)
|
|
|
|
type (
|
|
Event any
|
|
EventMode int
|
|
State string
|
|
Transition map[Event][2]Result
|
|
)
|
|
|
|
const (
|
|
Version string = "12D22"
|
|
IdleEvent = "idle" // must not change name: method fIdle depends on it
|
|
|
|
InitState State = "init"
|
|
FinalState State = "final"
|
|
)
|
|
|
|
const (
|
|
EventInvalid EventMode = iota // Should not be seen
|
|
EventNormal // Processes events
|
|
EventQueue // Queue events for later use
|
|
EventSink // Throw away events
|
|
)
|
|
|
|
// Machine is a composable state machine.
|
|
//
|
|
// Applications must create an actual FSM composing such a Machine, in which they will:
|
|
// - define the fTransitions table, usually in their constructor
|
|
// - invoke the parent constructor to set up the FSM from the transitions table
|
|
// - create "f_foo" functions for each "foo" event, except "idle", which is builtin.
|
|
//
|
|
// Applications may:
|
|
// - disable event processing by setting $this->event to one of the fsm::EVENT_* constants
|
|
// - disable post-event actions by setting $this->allowsActions to false
|
|
// - disable the builtin idle event by setting $this->idleWork to false
|
|
// - query the current state by using $this->getState()
|
|
// - send an idle event by using $this->idle()
|
|
// - submit any event (including idle) by using $this->ApplyEvent($eventName)
|
|
type Machine struct {
|
|
AllowActions bool
|
|
EventMode EventMode
|
|
IdleWork bool
|
|
Queue []any
|
|
State State
|
|
Transitions map[State]Transition
|
|
}
|
|
|
|
// checkTransitions verifies than a transitions graph has been defined.
|
|
//
|
|
// Since applications are not expected to modify the machine once initialized,
|
|
// it is not meant to be used beyond New.
|
|
func (m Machine) checkTransitions() error {
|
|
if len(m.Transitions) == 0 {
|
|
return errors.New("no FSM processing is allowed without a transitions table")
|
|
}
|
|
if _, ok := m.Transitions[InitState]; ok {
|
|
return errors.New("transitions map does not have any initial state")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// New initializes the FSM to the first state in the transitions table
|
|
func New(transitions map[State]Transition) (*Machine, error) {
|
|
m := Machine{
|
|
AllowActions: true,
|
|
IdleWork: true,
|
|
EventMode: EventNormal,
|
|
Queue: []any{}, // Queue is the event queue for EVENT_QUEUE mode
|
|
State: InitState, // State holds the current state of the machine
|
|
Transitions: transitions, // Transitions holds the transitions table
|
|
}
|
|
if err := m.checkTransitions(); err != nil {
|
|
return nil, err
|
|
}
|
|
return &m, nil
|
|
}
|
|
|
|
// AcceptedEvents returns the list of events accepted in the current state.
|
|
func (m Machine) AcceptedEvents() []Event {
|
|
t, ok := m.Transitions[m.State]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
events := maps.Keys(t)
|
|
return events
|
|
}
|
|
|
|
// AcceptedOutcomes returns the list of outcomes accepted in the current state for the given event
|
|
func (m Machine) AcceptedOutcomes(e Event) ([2]Result, error) {
|
|
res, ok := m.Transitions[m.State][e]
|
|
if !ok {
|
|
return [2]Result{}, fmt.Errorf("event %q not accepted in state %q", e, m.State)
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
// IsEventAllowed verifies whether the given event is accepted in the current state
|
|
func (m Machine) IsEventAllowed(e Event) bool {
|
|
t := m.Transitions[m.State]
|
|
isIt := slices.Contains(maps.Keys(t), e)
|
|
return isIt
|
|
}
|
|
|
|
// IsOutcomeAllowed checks whether a given outcome available for a given event, considering the current state.
|
|
func (m Machine) IsOutcomeAllowed(e Event, outcome Result) bool {
|
|
if !m.IsEventAllowed(e) {
|
|
return false
|
|
}
|
|
res, err := m.AcceptedOutcomes(e)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
isIt := outcome == res[0] || outcome == res[1]
|
|
return isIt
|
|
}
|
|
|
|
// ApplyEvent applies both the given event and the resulting event chain if applicable.
|
|
func (m *Machine) ApplyEvent(e Event) Result {
|
|
var res Result
|
|
for {
|
|
var next Event
|
|
res = m.ApplySimpleEvent(e)
|
|
if m.AllowActions {
|
|
next = res.Action
|
|
} else {
|
|
next = nil
|
|
}
|
|
if next != nil {
|
|
break
|
|
}
|
|
}
|
|
|
|
return res
|
|
}
|
|
|
|
// ApplySimpleEvent is a helper for ApplyEvent that does not implement the post-transition action
|
|
//
|
|
// See ApplyEvent
|
|
func (m *Machine) ApplySimpleEvent(e Event) Result {
|
|
currentState := m.State
|
|
|
|
if e == IdleEvent && !m.IdleWork {
|
|
return Result{}
|
|
}
|
|
|
|
if !m.IsEventAllowed(e) {
|
|
log.Fatalf("Event %q not allowed in current state %q.\n", e, currentState)
|
|
}
|
|
return Result{}
|
|
}
|
|
|
|
$outcomes = $this- > getAcceptedOutcomes($eventName)
|
|
$methodName = "event$eventName"
|
|
if !method_exists($this, $methodName)) {
|
|
die (func_name().": missing method "
|
|
.get_class($this)."::$methodName. Aborting.\n")
|
|
}
|
|
$outcome = $this- >$methodName()
|
|
if !in_array($outcome, $outcomes)) {
|
|
throw new \Exception(func_name()
|
|
.": event guard. Transition on \"$eventName\" return invalid outcome: "
|
|
.print_r($outcome, true)
|
|
." for state $this->fState\n")
|
|
}
|
|
|
|
$transition = &$this- > fTransitions[$currentState][$eventName][$outcome]
|
|
$result = new Result (
|
|
$outcome,
|
|
$transition[0],
|
|
$transition[1]
|
|
)
|
|
if (isset($result->fsmState)) {
|
|
$this->fState = $result->fsmState
|
|
}
|
|
/* print_r($this->fTransitions[$currentState][$eventName]);
|
|
var_dump($result); */
|
|
if (!isset($outcome)) {
|
|
$outcome = 'NULL'
|
|
}
|
|
|
|
/*
|
|
echo func_name()
|
|
. ": $currentState: " . $eventName . '[' . $outcome . ']'
|
|
. " -> $this->fState / " . $result->fsmAction . PHP_EOL;
|
|
*/
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Default event.
|
|
*
|
|
* @return boolean
|
|
*/
|
|
public function fIdle() {
|
|
return TRUE
|
|
}
|
|
|
|
/**
|
|
* Apply an fsm::IDLE_EVENT event. Do not confuse with fIdle !
|
|
*
|
|
* @return Result
|
|
*/
|
|
public function idle() {
|
|
return $this->applyEvent(self::IDLE_EVENT)
|
|
}
|
|
|
|
/**
|
|
* return the current operating mode of the FSM
|
|
* @return int
|
|
*/
|
|
public function getEventMode() {
|
|
return $this->fEventMode
|
|
}
|
|
|
|
/**
|
|
* set the current operating mode for the FSM
|
|
*
|
|
* @param int $mode fsm::EVENT_* constants
|
|
* @return int $mode fsm::EVENT_* constants
|
|
*/
|
|
public function setEventMode($mode) {
|
|
switch ($mode) {
|
|
case self::EVENT_NORMAL:
|
|
if (count($this->fQueue) > 0) {
|
|
while (($event = array_shift($this->fQueue)) != = NULL) {
|
|
$this->applyEvent($event)
|
|
}
|
|
}
|
|
break
|
|
|
|
// Nothing special to do
|
|
case self::EVENT_QUEUE:
|
|
break
|
|
|
|
// Empty queue if needed
|
|
case self::EVENT_SINK:
|
|
if (count($this->fQueue) > 0) {
|
|
$this->fQueue = array()
|
|
}
|
|
break
|
|
default:
|
|
throw new Exception("Trying to set unknown FSM mode $mode")
|
|
}
|
|
|
|
$this->fEventMode = $mode
|
|
return $this->fEventMode
|
|
}
|
|
|
|
/**
|
|
* Load the fTransitions table from an external resource.
|
|
*
|
|
* @param string $url
|
|
* Optional: defaults to the name of the class, . ".xml"
|
|
*/
|
|
public function loadFsm($url = NULL) {
|
|
$osd = FALSE // on screen display (debug)
|
|
if ($osd) {
|
|
echo "Loading FSM from $url\n"
|
|
}
|
|
|
|
if (!isset($url)) {
|
|
$url = get_class($this).".xml"
|
|
}
|
|
|
|
$fsm = simplexml_load_file($url)
|
|
$fsmVersion = (string) $fsm['fsm_version']
|
|
if ($fsmVersion != = '1.3') {
|
|
die("Revision $fsmVersion of schema is not supported.\n")
|
|
}
|
|
|
|
$this->idleWork = ($fsm['idle_work'] == 1)
|
|
$this->allowActions = ($fsm['allow_actions'] == 1)
|
|
$this->fTransitions = array()
|
|
$t = &$this->fTransitions
|
|
|
|
// (string) casts in this loop are required: RHS is a SimpleXMLElement
|
|
foreach ($fsm->state as $state) {
|
|
$id = (string) $state['id']
|
|
if ($osd) {
|
|
echo "State $id :\n"
|
|
}
|
|
$t[$id] = array()
|
|
switch ($id) {
|
|
case self::INIT_STATE:
|
|
if ($osd) {
|
|
echo " Initial state\n"
|
|
}
|
|
break
|
|
case self::FINAL_STATE:
|
|
if ($osd) {
|
|
echo " Final state\n"
|
|
}
|
|
break
|
|
}
|
|
|
|
foreach ($state->event as $event) {
|
|
$name = (string) $event['name']
|
|
if ($osd) {
|
|
echo " Event $name"
|
|
}
|
|
|
|
if (!isset($event['type'])) {
|
|
$event['type'] = 'void'
|
|
}
|
|
|
|
$eventType = (string) $event['type']
|
|
if ($osd) {
|
|
echo ", type $eventType\n"
|
|
}
|
|
|
|
foreach ($event as $next) {
|
|
if ($event['type'] == 'void') {
|
|
$next['result'] = 'always'
|
|
$result = null
|
|
} else {
|
|
$result = (string) $next['result']
|
|
}
|
|
|
|
if (!isset($next['state'])) {
|
|
$next['state'] = (string) $state['id']
|
|
}
|
|
if ($osd) {
|
|
echo " Next(".$next['result'].') = '.$next['state']
|
|
if (isset($next['action'])) {
|
|
echo " + event ".$next['action']
|
|
}
|
|
echo PHP_EOL
|
|
}
|
|
$t[$id][$name][$result] = array(
|
|
(string) $next['state'],
|
|
(string) $next['action'])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|