|
@@ -0,0 +1,357 @@
|
|
|
|
+/*
|
|
|
|
+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'])
|
|
|
|
+}
|
|
|
|
+}
|
|
|
|
+}
|
|
|
|
+}
|
|
|
|
+}
|