fsm/machine.go
Frédéric G. MARAND 950b4ce20f
WIP
2023-04-04 10:45:10 +02:00

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'])
}
}
}
}
}