SimonGAndrews / xstate-fsmPlus-Espruino

Fork of the FSM finite state machine package in the XState library from STATELY Ai, to enable the FSM to be run as a module within the Espruino JavaScript Interpreter for Microcontrollers. Providing enhancements to enable some basic StateChart features to be supported (eg nested states).
MIT License
0 stars 0 forks source link

Hierarchical State Nodes - validation and lookup of state values #15

Open SimonGAndrews opened 2 years ago

SimonGAndrews commented 2 years ago

Functionality is required to:

A solution approach was chosen using an initial pass thru the machine config to store state values and custom ids with thier corresponding paths (full state value Ids). A recursive function getStateMap() creates a state mapping object with keys identifying the state ids (inc custom ids) with values containing the paths to those ids. The resulting state mapping object stateMap is built upon machine creation. using getStateMap(), and attached to machine.initial and all subsequent transitioned states as an additional property.

An additional runtime function validateStateID() validates/converts and returns full state value from varying types of state reference (eg custom id) using lookups into stateMap

SimonGAndrews commented 2 years ago

While working issue #14 (determining initial states on compound state nodes) it was concluded that the stateMap was an optimal place to store the initial state for any compound state nodes. In this way each compound states initial is evaluated just one time when the machine is created. Thus reducing the run time effort on transitions. So getStateMap now contains the calls to getInitial().

SimonGAndrews commented 2 years ago
/* fsmPlus ref issue 15 - returns a map of all state nodes in a machine 
 * in the form of an object with a key set to ID of each state node (custom ID or full def ID).
 * the keys value is an object with optional properties:
 *  - pathID: full state ID if state node ID is a custom ID
 *  - initial: id of the state nodes initial state/substate when node is compound
 *  - type: M machine, C Compound, A Atomic, 
 * a recursive function , Called with arg obj set to machine config object, other args are empty on initial call
 * see functions: validateStateID(), for usage
*/
function getStateMap(obj,sofar,stateMap){ // recursive function to generate stateMap from fsmConfig
  if (!obj) throw new Error('(getStateMap) - called without a configuration object');
  if (!sofar) { //assume calling at top of config (machine)
    stateMap = {};
    if (! ('id' in obj)) {throw new Error ("(getStateMap) - Machine config requires 'id' at top level");}
    sofar = '#' + obj.id;
    if (!('initial' in obj) && 'states' in obj ) {throw new Error ("(getStateMap) - Machine " + sofar + " requires 'initial' state at top level");}
    stateMap[sofar]={type:'M',initial:getInitial(sofar,obj)};
  }

  if ('states' in obj) { 
    Object.keys(obj.states).forEach((k) => {
      let node="";  
      if ('id' in obj.states[k]) { // add node using Custom ID as key with a pathID property
        node = '#'+ obj.states[k].id;
        stateMap[node] = Object.assign({},{pathID: sofar +'.'+ k});
      } else {                    // add node using full state ID as key no pathID
        node = sofar +'.'+ k;
        stateMap[node]= {};
      }
      if ('states' in obj.states[k]) { //Compound state node - go down to substates
        stateMap[node].type = 'C';
        stateMap[node].initial = getInitial(sofar +'.'+ k,obj.states[k]);
        getStateMap(obj.states[k],sofar +'.'+ k,stateMap);
      }else {                         //Atomic state node 
        stateMap[node].type = 'A';
        return stateMap
      }
    });
  } 
  return stateMap;
}
SimonGAndrews commented 2 years ago

the following function, validateStateID, uses the resulting stateMap to validate a given statenode exists in the config/stateMap, in addition the function converts custom IDs to sull state IDs and returns the initial state of a statenode when called with mapInitial = true.

/** fsmPlus - validates/converts and returns full state ID from a state reference
  * using lookups into stateMap. expects arg stateRef is in form of a full state id or a custom id 
  *  - in all cases returns null if resolved state does not exist in stateMap
  *  - returns stateRef as full ID if exists as key in stateMap
  *  - returns full ID lookup in stateMap if stateRef is a custom ID 
  *  - returns stateRef as full ID if stateRef is pathID of a custom ID
  *  - returns lookup of initial in stateMap when mapInitial is true (used validating target paths)
  */
function validateStateID(stateRef,stateMap,mapInitial) {
  if (!stateRef || !stateMap) return null;
  if (!(stateMap[stateRef])){ 
    /* stateRef not a full state ID - maybe a custom id */
    let id = '';
    if (id = Object.keys(stateMap).find( k => stateMap[k].pathID === stateRef)){
      /* return key or initial of a custom id */ 
      if (mapInitial && stateMap[id].initial) return stateMap[id].initial;
      return  stateMap[id].pathID;
    } 
    else return null ;
  }
  else if (mapInitial && stateMap[stateRef].initial) return stateMap[stateRef].initial;
  return (stateMap[stateRef].pathID) ? stateMap[stateRef].pathID : stateRef;
 }
SimonGAndrews commented 2 years ago

example of stateMap for this machine


carBuildMachine1 = { // baseline case
  id: 'buildCar',
  initial: 'chassie',
  states: {
          chassie: {
          initial: 'axle',
          on:{SCRAP: '#buildCar.scrap'},
          states:{
            axle: { on:{DONE: '#buildCar.body', RESET:'#buildCar.chassie.engine'}},
            engine: {
              initial: 'transmission',
              on:{SCRAP: '#buildCar.chassie.engine.scrapEngine'},
              states:{
                  block:{id:'engineBlock', on:{DONE: 'transmission'}},
                  transmission:{ on:{DONE: '#buildCar.chassie.axle'}},
                  scrapEngine:{}
              }
            }
          },
      },
      body: {
        id:'body',
        type:'atomic',
        initial:'aType',
        states: {aType:{},bType:{}}
      },
      scrap: {}
  }
};
stateMap is
    {
      '#buildCar': { type: 'M', initial: '#buildCar.chassie.axle' },
      '#buildCar.chassie': { type: 'C', initial: '#buildCar.chassie.axle' },
      '#buildCar.chassie.axle': { type: 'A' },
      '#buildCar.chassie.engine': { type: 'C', initial: '#buildCar.chassie.engine.transmission' },
      '#engineBlock': { pathID: '#buildCar.chassie.engine.block', type: 'A' },
      '#buildCar.chassie.engine.transmission': { type: 'A' },
      '#buildCar.chassie.engine.scrapEngine': { type: 'A' },
      '#body': {
        pathID: '#buildCar.body',
        type: 'C',
        initial: '#buildCar.body.aType'
      },
      '#buildCar.body.aType': { type: 'A' },
      '#buildCar.body.bType': { type: 'A' },
      '#buildCar.scrap': { type: 'A' }
    }
SimonGAndrews commented 1 year ago

unit test stateID.test.js is used to test this functionality.