sonntam / node-red-contrib-xstate-machine

A xstate-based state machine implementation using state-machine-cat visualization for node red.
MIT License
22 stars 8 forks source link

Help with using events for guards #42

Closed vacquah closed 2 years ago

vacquah commented 2 years ago

Hello - Just getting into trying out this module. I am stuck on trying to get a guard condition to work. The below setup doesn't work. Could use some help to tweak this. thx.

In the return state, I have the condition setup like this:

state_one: {target: 'system_off', cond: "myEvent"}

And

config: {
        guards: {myEvent:  (_, event) => event.payload = "incoming_payload_topic"}
 }

edit; Does xstate support the cond: setup like above?

sonntam commented 2 years ago

Could you elaborate on what you are trying to achieve or post a full minimal example?

The way you define your guard "myEvent" in the config object is fine (apart from one apparent missing "=" in the condition --> event.payload == "incoming_payload_topic").

See the following example:

As node-red import:

[{"id":"11e76c6e13b9cecd","type":"smxstate","z":"923927cd.3b9538","name":"","xstateDefinition":"// Available variables/objects/functions:\n// xstate\n// - .Machine\n// - .interpret\n// - .assign\n// - .send\n// - .sendParent\n// - .spawn\n// - .raise\n// - .actions\n//\n// Common\n// - setInterval, setTimeout, clearInterval, clearTimeout\n// - node.send, node.warn, node.log, node.error\n// - context.get, context.set\n// - flow.get, flow.set\n// - env.get\n// - util\n\n// @ts-ignore\nconst { assign } = xstate;\n\n// First define names guards, actions, ...\n\n/**\n * Guards\n */\n\n\n/**\n * Actions\n */\n\n\n/**\n * Activities\n */\n\n\n/***************************\n * Main machine definition * \n ***************************/\nreturn {\n  machine: {\n    context: {\n\n    },\n    initial: 'state_one',\n    states: {\n      state_one: {\n        on: [\n          { PAUSE: 'state_two' },\n          { event: \"*\", target: 'state_two', cond: \"myEvent\" }\n        ]\n      },\n      state_two: {\n        on: {\n          RESUME: 'state_one'\n        }\n      }\n    }\n  },\n  // Configuration containing guards, actions, activities, ...\n  // see above\n  config: {\n    guards: {\n      myEvent: (_, event) => {\n        node.warn(event);\n        return event.payload == \"incoming_payload_topic\";\n      }\n    },\n    actions: {},\n    activities: {}\n  },\n  // Define listeners (can be an array of functions)\n  //    Functions get called on every state/context update\n  listeners: (data) => {\n    \n  }\n};","noerr":0,"x":530,"y":700,"wires":[[],[]]},{"id":"279de70ccaef7437","type":"inject","z":"923927cd.3b9538","name":"","props":[{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"PAUSE","x":270,"y":700,"wires":[["11e76c6e13b9cecd"]]},{"id":"666edeac833aecfd","type":"inject","z":"923927cd.3b9538","name":"","props":[{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"RESUME","x":280,"y":740,"wires":[["11e76c6e13b9cecd"]]},{"id":"abf6f61dd9263c7a","type":"inject","z":"923927cd.3b9538","name":"","props":[{"p":"payload"},{"p":"topic","vt":"str"},{"p":"type","v":"what","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"anything","payload":"incoming_payload_topic","payloadType":"str","x":350,"y":800,"wires":[["11e76c6e13b9cecd"]]}]

The source of the statechart:

// Available variables/objects/functions:
// xstate
// - .Machine
// - .interpret
// - .assign
// - .send
// - .sendParent
// - .spawn
// - .raise
// - .actions
//
// Common
// - setInterval, setTimeout, clearInterval, clearTimeout
// - node.send, node.warn, node.log, node.error
// - context.get, context.set
// - flow.get, flow.set
// - env.get
// - util

// @ts-ignore
const { assign } = xstate;

// First define names guards, actions, ...

/**
 * Guards
 */

/**
 * Actions
 */

/**
 * Activities
 */

/***************************
 * Main machine definition * 
 ***************************/
return {
  machine: {
    context: {

    },
    initial: 'state_one',
    states: {
      state_one: {
        on: [
          { PAUSE: 'state_two' },
          { event: "*", target: 'state_two', cond: "myEvent" }
        ]
      },
      state_two: {
        on: {
          RESUME: 'state_one'
        }
      }
    }
  },
  // Configuration containing guards, actions, activities, ...
  // see above
  config: {
    guards: {
      myEvent: (_, event) => {
        node.warn(event);
        return event.payload == "incoming_payload_topic";
      }
    },
    actions: {},
    activities: {}
  },
  // Define listeners (can be an array of functions)
  //    Functions get called on every state/context update
  listeners: (data) => {

  }
};

Does that help?

vacquah commented 2 years ago

@sonntam yes it helps a lot. thank you. But it's still not working i.e. setting conditions on the events. I am beginning to think I am not specifying the correct data point in the guard configuration. My events are driven by the topic of the incoming payload. So is the topic captured by event.payload == "topic here"? Or do I need to assign the external payload topic value to a variable before plugging it into the myEvent code below?

      myEvent: (_, event) => {
        node.warn(event);
        return event.payload == "incoming_payload_topic";
      }

Here is what the incoming payload looks like. Here "myEvent" is the topic of the incoming payload. So I am looking to set a guard where the topic value of the incoming payload = myEvent

myEvent : msg.payload : Object
object
name: "status"
currentValue: "stopped"
dataType: "STRING"
value: "stopped"
deviceId: "16"
sonntam commented 2 years ago

A message to smxstate should have the following form:

msg = {
    topic: ”any_event_name_string“,
    payload: any type
}

Within a transition guard the data is passed as follows:

event = {
    type: msg.topic,
    payload: msg.payload
}

Also, if you use the null/always transition there is no event data attached as it is fired internally on every update.

vacquah commented 2 years ago

Still not working. Here is what I have now:

const { assign } = xstate;

// main machine definition
return {
    machine: {
        id: "myMachine",
        initial: 'state_1',
        states: {
            state_1: {
                on: {
                    event_1: {target: 'state_3'},
                    event_2: {target: 'state_2'}
                }
            },
            state_2: {
                on: [
                        {event: "event_1_subevent_1", target: 'state_3', cond: "event2Stopped" },
                        {event: "event_1_subevent_2", target: 'state_3', cond: "event2Stopped" },
                        {event_2_stopped: 'state_1'}
                ]
            },
            state_3: {
                initial: 'event_1',
                states: {
                    event_1: {
                        on: {
                            event_1_subevent_1: {target: 'state_3.1'},
                            event_1_subevent_2: {target: 'state_3.2'}
                        }
                    },
                    state_3.1: {
                        on: {
                            event_1_stopped: {target: 'state_1'},
                            event_1_subevent_2: {target: 'state_3.2'}
                        }
                    },
                    state_3.2: {
                        on: {
                            event_1_stopped: {target: 'state_1'},
                            event_1_subevent_1: {target: 'state_3.1'}
                        }
                    },
                    state_1: {
                        type: 'final'
                    }
                },
                on: {
                    event_2: {target: 'state_2'},
                    event_1_stopped: {target: 'state_1'}
                }
            }
        }
    },
    config: {
        guards: {
            event2Stopped: (_, event) => {
                node.warn(event);
                return event.type == "event_2_stopped";
            }
        },
        actions: {},
        activities: {}
    },
    listeners: (data) => {}
};
sonntam commented 2 years ago

I‘m not sure what process you are trying to model. Can you maybe describe what you are trying to achieve in words?

The state chart you posted works as intended. The transition in state “state_2” that reacts to the “event_2_stopped” consumes the event in question so there is no chance that the event is passed to the guard at all.

vacquah commented 2 years ago

If I remove {event_2_stopped: 'state_1'} from state 2, it still doesn't work. I first tested it without that before inserting it in as a temp measure.

I am looking to model the state of a fan and an air conditioner in a large room - trying to ensure only one can be on at a time. But for the fan, I also want to control fan speed ( speed 1 / sub event 1 and speed 2/ sub event 2).

So,

  1. For the fan, state_1 & state_2 captures the on / off states (event_1/event_1_stopped ). However, from state_2, it can transition to 2 other different states , with their own events - ( state_3.1 + event_1_subevent_1) and ( state_3.2. + event_1_subevent_2). But this transition to the other sub states can only happen if the AC is off. From any of these states, turning off the fan (event_1_stopped) will send it back to the initial off state ( state_1).

  2. For the AC, I only need to know if its on or off. So turning it on from the initial off state ( state_1) with the event _2, will send it to the on state - state_2. Turning it off ( event_2_stopped) will send it back to the original off state.

Hope I explained it ok.

vacquah commented 2 years ago

Update: I found another way to do what I wanted so problem solved. But would have liked to use conditional guards. thx.

sonntam commented 2 years ago

@vacquah Thanks for describing your use-case! I'm sorry for the late reply but I didn't have access to a computer the last few days. One suggestion I can give is the following using a history state for the fan speed. Maybe you can use that as a base to further experiment with event payloads!

image

Node-red flow:

[{"id":"a48c928fd358eddf","type":"smxstate","z":"e1108061.bbef","name":"ac fan control","xstateDefinition":"// Available variables/objects/functions:\n// xstate\n// - .Machine\n// - .interpret\n// - .assign\n// - .send\n// - .sendParent\n// - .spawn\n// - .raise\n// - .actions\n//\n// Common\n// - setInterval, setTimeout, clearInterval, clearTimeout\n// - node.send, node.warn, node.log, node.error\n// - context.get, context.set\n// - flow.get, flow.set\n// - env.get\n// - util\n\nconst { assign } = xstate;\n\n// First define names guards, actions, ...\n\n/**\n * Guards\n */\nconst useLastSpeed = (ctx,ev) => ev.payload === undefined;\nconst fanSpeed1 = (ctx,ev) => ev.hasOwnProperty('payload') && ev.payload == 1;\nconst fanSpeed2 = (ctx,ev) => ev.hasOwnProperty('payload') && ev.payload == 2;\nconst fanOff = (ctx,ev) => ev.hasOwnProperty('payload') && ev.payload == 0;\n\n/**\n * Actions\n */\n\n/**\n * Activities\n */\n\n\n/***************************\n * Main machine definition * \n ***************************/\nreturn {\n  machine: {\n    context: {\n      counter: 0\n    },\n    initial: 'off',\n    states: {\n      off: {\n          on: {\n            fanControl: [\n                { target: 'fanOn.hist', cond: 'useLastSpeed' },\n                { target: 'fanOn.speed1', cond: 'fanSpeed1'},\n                { target: 'fanOn.speed2', cond: 'fanSpeed2'},\n            ],\n            acOn: 'acOn'\n          }\n      },\n      acOn: {\n          on: {\n              acOff: 'off',\n              allOff: 'off',\n              fanControl: [\n                { target: 'fanOn.hist', cond: 'useLastSpeed' },\n                { target: 'fanOn.speed1', cond: 'fanSpeed1'},\n                { target: 'fanOn.speed2', cond: 'fanSpeed2'},\n              ]\n          }\n      },\n      fanOn: {\n          initial: 'speed1',\n          on: {\n            fanOff: 'off',\n            allOff: 'off',\n            fanControl: [\n                { target: 'off', cond: 'fanOff' },\n                { target: 'fanOn.speed1', cond: 'fanSpeed1' },\n                { target: 'fanOn.speed2', cond: 'fanSpeed2' }\n            ],\n            acOn: 'acOn'\n          },\n          states: {\n              speed1: {\n                  on: {\n                  }\n              },\n              speed2: {\n                  on: {\n                  }\n              },\n              hist: {\n                type: 'history',\n                history: 'shallow'\n              }\n          }\n      }\n    }\n  },\n  // Configuration containing guards, actions, activities, ...\n  // see above\n  config: {\n    guards: { useLastSpeed, fanSpeed1, fanSpeed2, fanOff },\n    actions: { },\n    activities: { }\n  },\n  // Define listeners (can be an array of functions)\n  //    Functions get called on every state/context update\n  listeners: (data) => {\n    //node.warn(data.state + \":\" + data.context.counter);\n  }\n};","noerr":0,"x":1400,"y":480,"wires":[[],[]]},{"id":"653287f2d8ca1f52","type":"inject","z":"e1108061.bbef","name":"","props":[{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"acOn","x":1110,"y":360,"wires":[["a48c928fd358eddf"]]},{"id":"ad1696d9e5a994ac","type":"inject","z":"e1108061.bbef","name":"","props":[{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"acOff","x":1110,"y":400,"wires":[["a48c928fd358eddf"]]},{"id":"4ea5992890d98fce","type":"inject","z":"e1108061.bbef","name":"","props":[{"p":"topic","vt":"str"},{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"fanControl","payload":"0","payloadType":"num","x":1130,"y":440,"wires":[["a48c928fd358eddf"]]},{"id":"58b76af5ff26b810","type":"inject","z":"e1108061.bbef","name":"","props":[{"p":"topic","vt":"str"},{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"fanControl","payload":"1","payloadType":"num","x":1130,"y":480,"wires":[["a48c928fd358eddf"]]},{"id":"17b800762bc30ada","type":"inject","z":"e1108061.bbef","name":"","props":[{"p":"topic","vt":"str"},{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"fanControl","payload":"2","payloadType":"num","x":1130,"y":520,"wires":[["a48c928fd358eddf"]]},{"id":"44f33888e14559c4","type":"inject","z":"e1108061.bbef","name":"","props":[{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"fanControl","x":1120,"y":560,"wires":[["a48c928fd358eddf"]]},{"id":"d076215d1a447fe7","type":"inject","z":"e1108061.bbef","name":"","props":[{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"allOff","payloadType":"str","x":1110,"y":600,"wires":[["a48c928fd358eddf"]]}]

Just the xstate code:

// Available variables/objects/functions:
// xstate
// - .Machine
// - .interpret
// - .assign
// - .send
// - .sendParent
// - .spawn
// - .raise
// - .actions
//
// Common
// - setInterval, setTimeout, clearInterval, clearTimeout
// - node.send, node.warn, node.log, node.error
// - context.get, context.set
// - flow.get, flow.set
// - env.get
// - util

const { assign } = xstate;

// First define names guards, actions, ...

/**
 * Guards
 */
const useLastSpeed = (ctx,ev) => ev.payload === undefined;
const fanSpeed1 = (ctx,ev) => ev.hasOwnProperty('payload') && ev.payload == 1;
const fanSpeed2 = (ctx,ev) => ev.hasOwnProperty('payload') && ev.payload == 2;
const fanOff = (ctx,ev) => ev.hasOwnProperty('payload') && ev.payload == 0;

/**
 * Actions
 */

/**
 * Activities
 */

/***************************
 * Main machine definition * 
 ***************************/
return {
  machine: {
    context: {
      counter: 0
    },
    initial: 'off',
    states: {
      off: {
          on: {
            fanControl: [
                { target: 'fanOn.hist', cond: 'useLastSpeed' },
                { target: 'fanOn.speed1', cond: 'fanSpeed1'},
                { target: 'fanOn.speed2', cond: 'fanSpeed2'},
            ],
            acOn: 'acOn'
          }
      },
      acOn: {
          on: {
              acOff: 'off',
              allOff: 'off',
              fanControl: [
                { target: 'fanOn.hist', cond: 'useLastSpeed' },
                { target: 'fanOn.speed1', cond: 'fanSpeed1'},
                { target: 'fanOn.speed2', cond: 'fanSpeed2'},
              ]
          }
      },
      fanOn: {
          initial: 'speed1',
          on: {
            fanOff: 'off',
            allOff: 'off',
            fanControl: [
                { target: 'off', cond: 'fanOff' },
                { target: 'fanOn.speed1', cond: 'fanSpeed1' },
                { target: 'fanOn.speed2', cond: 'fanSpeed2' }
            ],
            acOn: 'acOn'
          },
          states: {
              speed1: {
                  on: {
                  }
              },
              speed2: {
                  on: {
                  }
              },
              hist: {
                type: 'history',
                history: 'shallow'
              }
          }
      }
    }
  },
  // Configuration containing guards, actions, activities, ...
  // see above
  config: {
    guards: { useLastSpeed, fanSpeed1, fanSpeed2, fanOff },
    actions: { },
    activities: { }
  },
  // Define listeners (can be an array of functions)
  //    Functions get called on every state/context update
  listeners: (data) => {
    //node.warn(data.state + ":" + data.context.counter);
  }
};

The state machine (the layout is not pretty...): image