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