Skip to content

FSM finite state machine

A Finite State Machine (FSM) is a computational model used to design systems that can exist in only one of a set number of "states" at any given time. It transitions from one state to another in response to specific inputs or events


transitions

A lightweight, object-oriented state machine implementation in Python with many extensions github

pip install transitions

Demo: simple example

Very simple example that move from state to state without condition every time that trigger called

The demo attaches a Machine to an empty Robot object, starts it in the IDLE state, and registers three transitions: start_tracking moves from IDLE to TRACKING, error moves from any state to ERROR, and reset moves from ERROR back to IDLE. It prints the robot state after each trigger, showing the sequence IDLE, TRACKING, ERROR, IDLE.

simple demo
from transitions import Machine

class Robot:
    pass


robot = Robot()

states = [
    "IDLE",
    "TRACKING",
    "ERROR"
]

machine = Machine(
    model=robot,
    states=states,
    initial="IDLE"
)

machine.add_transition(
    trigger="start_tracking",
    source="IDLE",
    dest="TRACKING"
)

machine.add_transition(
    trigger="error",
    source="*",
    dest="ERROR"
)

machine.add_transition(
    trigger="reset",
    source="ERROR",
    dest="IDLE"
)

print(robot.state)
# call trigger
robot.start_tracking()
# print state
print(robot.state)
# call trigger
robot.error()
print(robot.state)
# call trigger
robot.reset()
print(robot.state)

Demo: simple example II

This demo defines the states with a Python Enum, which keeps the state names grouped and avoids repeating raw strings throughout the code. The transitions are the same basic flow as the first example: start_tracking moves from IDLE to TRACKING, error can move from any state to ERROR, and reset moves from ERROR back to IDLE.

The Robot class also defines on_enter_ERROR and on_exit_ERROR, so the transitions library automatically calls those methods when the robot enters or leaves the ERROR state. After triggering error, the demo uses the generated robot.is_ERROR() helper to check the current state, then calls reset. At the end it calls the generated robot.to_ERROR() method to move directly into ERROR without using a custom trigger.

more simple demo
from enum import Enum

from transitions import Machine


class RobotState(Enum):
    IDLE = "IDLE"
    TRACKING = "TRACKING"
    ERROR = "ERROR"


class Robot:
    def on_enter_ERROR(self):
        print("----enter error")

    def on_exit_ERROR(self):
        print("----exit error")



robot = Robot()

states = [
    RobotState.IDLE,
    RobotState.TRACKING,
    RobotState.ERROR
]

machine = Machine(
    model=robot,
    states=states,
    initial=RobotState.IDLE
)

machine.add_transition(
    trigger="start_tracking",
    source=RobotState.IDLE,
    dest=RobotState.TRACKING
)

machine.add_transition(
    trigger="error",
    source="*",
    dest=RobotState.ERROR
)

machine.add_transition(
    trigger="reset",
    source=RobotState.ERROR,
    dest=RobotState.IDLE
)

print(robot.state)
# call trigger
robot.start_tracking()
# print state
print(robot.state)
# call trigger
robot.error()
# check if in state
if robot.is_ERROR():
    print("**robot has error**")
# print state
print(robot.state)
# call trigger
robot.reset()
print(robot.state)
# move to state without trigger
robot.to_ERROR()
print(robot.state)

Demo: Conditions

This demo shows how to keep transition logic inside named condition methods instead of scattering if statements around the application. The robot stores sensor and system data in a Context dataclass, then several transitions share the same resolve trigger. When resolve() is called, the machine checks the valid transition for the current state and only moves if its condition returns True.

IDLE moves to SEARCH only when the camera is connected, the battery is above 10.5, and there is no active error. SEARCH moves to TRACKING when a target is found with confidence greater than 0.75. TRACKING moves to RECOVERY when the target is lost but the retry count is still below 3. The wildcard transition from * to ERROR catches critical faults from any state when an error flag is set, the battery drops below 9.5, or the retry count reaches 3.

The script updates the context before each resolve() call, so the printed states show the conditional path SEARCH, TRACKING, then RECOVERY.

State Machine with conditions
from dataclasses import dataclass
from enum import Enum

from transitions import Machine


class RobotState(Enum):
    IDLE = "IDLE"
    SEARCH = "SEARCH"
    TRACKING = "TRACKING"
    RECOVERY = "RECOVERY"
    ERROR = "ERROR"


@dataclass
class Context:
    camera_connected: bool = False
    target_found: bool = False
    target_confidence: float = 0.0
    battery_voltage: float = 12.0
    retry_count: int = 0
    error: bool = False


class Robot:
    states = list(RobotState)

    def __init__(self):
        self.ctx = Context()

        self.machine = Machine(
            model=self,
            states=self.states,
            initial=RobotState.IDLE,
            ignore_invalid_triggers=True,
        )

        self.machine.add_transition(
            "resolve",
            RobotState.IDLE,
            RobotState.SEARCH,
            conditions=[self.can_start_search],
        )

        self.machine.add_transition(
            "resolve",
            RobotState.SEARCH,
            RobotState.TRACKING,
            conditions=[self.good_target],
        )

        self.machine.add_transition(
            "resolve",
            RobotState.TRACKING,
            RobotState.RECOVERY,
            conditions=[self.target_lost_but_can_retry],
        )

        self.machine.add_transition(
            "resolve",
            "*",
            RobotState.ERROR,
            conditions=[self.critical_error],
        )

    def can_start_search(self):
        return (
            self.ctx.camera_connected
            and self.ctx.battery_voltage > 10.5
            and not self.ctx.error
        )

    def good_target(self):
        return (
            self.ctx.target_found
            and self.ctx.target_confidence > 0.75
        )

    def target_lost_but_can_retry(self):
        return (
            not self.ctx.target_found
            and self.ctx.retry_count < 3
        )

    def critical_error(self):
        return (
            self.ctx.error
            or self.ctx.battery_voltage < 9.5
            or self.ctx.retry_count >= 3
        )

robot = Robot()

robot.ctx.camera_connected = True
robot.ctx.battery_voltage = 11.8
robot.resolve()
print(robot.state)  # RobotState.SEARCH

robot.ctx.target_found = True
robot.ctx.target_confidence = 0.9
robot.resolve()
print(robot.state)  # RobotState.TRACKING

robot.ctx.target_found = False
robot.ctx.retry_count = 1
robot.resolve()
print(robot.state)  # RobotState.RECOVERY

Demo: Conditions with state change callback

This example uses the same conditional state machine, but adds a callback that runs after every successful state change. The machine is created with send_event=True, so callbacks receive an event object with transition metadata. after_state_change=self.on_state_changed registers one callback for all transitions, and event.transition.source / event.transition.dest provide the previous and new state.

Because send_event=True also passes the event object to condition callbacks, the condition methods accept an event argument even when they do not use it.

State Machine with conditions and callback
from dataclasses import dataclass
from enum import Enum

from transitions import Machine


class RobotState(Enum):
    IDLE = "IDLE"
    SEARCH = "SEARCH"
    TRACKING = "TRACKING"
    RECOVERY = "RECOVERY"
    ERROR = "ERROR"


@dataclass
class Context:
    camera_connected: bool = False
    target_found: bool = False
    target_confidence: float = 0.0
    battery_voltage: float = 12.0
    retry_count: int = 0
    error: bool = False


class Robot:
    states = list(RobotState)

    def __init__(self):
        self.ctx = Context()

        self.machine = Machine(
            model=self,
            states=self.states,
            initial=RobotState.IDLE,
            ignore_invalid_triggers=True,
            send_event=True,
            after_state_change=self.on_state_changed,
        )

        self.machine.add_transition(
            "resolve",
            RobotState.IDLE,
            RobotState.SEARCH,
            conditions=[self.can_start_search],
        )

        self.machine.add_transition(
            "resolve",
            RobotState.SEARCH,
            RobotState.TRACKING,
            conditions=[self.good_target],
        )

        self.machine.add_transition(
            "resolve",
            RobotState.TRACKING,
            RobotState.RECOVERY,
            conditions=[self.target_lost_but_can_retry],
        )

        self.machine.add_transition(
            "resolve",
            "*",
            RobotState.ERROR,
            conditions=[self.critical_error],
        )

    def on_state_changed(self, event):
        previous_state = event.transition.source
        new_state = event.transition.dest
        print(f"State changed: {previous_state} -> {new_state}")

    def can_start_search(self, event):
        return (
            self.ctx.camera_connected
            and self.ctx.battery_voltage > 10.5
            and not self.ctx.error
        )

    def good_target(self, event):
        return (
            self.ctx.target_found
            and self.ctx.target_confidence > 0.75
        )

    def target_lost_but_can_retry(self, event):
        return (
            not self.ctx.target_found
            and self.ctx.retry_count < 3
        )

    def critical_error(self, event):
        return (
            self.ctx.error
            or self.ctx.battery_voltage < 9.5
            or self.ctx.retry_count >= 3
        )


robot = Robot()

robot.ctx.camera_connected = True
robot.ctx.battery_voltage = 11.8
robot.resolve()
print(robot.state)  # RobotState.SEARCH

robot.ctx.target_found = True
robot.ctx.target_confidence = 0.9
robot.resolve()
print(robot.state)  # RobotState.TRACKING

robot.ctx.target_found = False
robot.ctx.retry_count = 1
robot.resolve()
print(robot.state)  # RobotState.RECOVERY

The idea is to create main loop the update the context and resolve the state machine if two or more state has matching condition the first one return `declare order matter"

1
2
3
while True:
    update_context()
    fsm.resolve()

More add_transition arguments

  • conditions: Runs guard functions. The transition happens only if all return True.

  • unless: Opposite of conditions. The transition happens only if all unless callbacks return False.

  • prepare: Runs before condition checks. Use it to update or normalize data needed by guards.

  • before: Runs after the transition is approved, but before the state actually changes. Use it for actions that should happen only when the transition is definitely going to occur.

  • after: Runs after the state has changed. Use it for actions that depend on the new state already being active.


DEMO: Add callback when state changed

This example is based on the conditional FSM, but the Machine also registers a global state-change callback:

after_state_change=self.on_state_changed

after_state_change runs after every successful transition, no matter which trigger caused it. In this example, every transition uses the same resolve() trigger, so the callback runs after IDLE -> SEARCH, SEARCH -> TRACKING, and TRACKING -> RECOVERY. It does not run when resolve() is called but no transition condition matches, because the state did not change.

The machine is also created with send_event=True. This tells transitions to pass one event object into callbacks instead of calling them with no arguments. The callback uses that event object to read the transition that just happened:

1
2
3
4
def on_state_changed(self, event):
    previous_state = event.transition.source
    new_state = event.transition.dest
    print(f"State changed: {previous_state} -> {new_state}")

event.transition.source is the previous state and event.transition.dest is the new state. That makes this callback useful for logging, publishing state changes, updating a UI, or sending diagnostics whenever the robot changes mode.

Because send_event=True affects all transition callbacks, the condition methods also receive the event object:

def can_start_search(self, event):

The condition methods in this example do not need the event, but they still accept it so their signatures match the way the machine calls them.

code
from dataclasses import dataclass
from enum import Enum

from transitions import Machine


class RobotState(Enum):
    IDLE = "IDLE"
    SEARCH = "SEARCH"
    TRACKING = "TRACKING"
    RECOVERY = "RECOVERY"
    ERROR = "ERROR"


@dataclass
class Context:
    camera_connected: bool = False
    target_found: bool = False
    target_confidence: float = 0.0
    battery_voltage: float = 12.0
    retry_count: int = 0
    error: bool = False


class Robot:
    states = list(RobotState)

    def __init__(self):
        self.ctx = Context()

        self.machine = Machine(
            model=self,
            states=self.states,
            initial=RobotState.IDLE,
            ignore_invalid_triggers=True,
            send_event=True,
            after_state_change=self.on_state_changed,
        )

        self.machine.add_transition(
            "resolve",
            RobotState.IDLE,
            RobotState.SEARCH,
            conditions=[self.can_start_search],
        )

        self.machine.add_transition(
            "resolve",
            RobotState.SEARCH,
            RobotState.TRACKING,
            conditions=[self.good_target],
        )

        self.machine.add_transition(
            "resolve",
            RobotState.TRACKING,
            RobotState.RECOVERY,
            conditions=[self.target_lost_but_can_retry],
        )

        self.machine.add_transition(
            "resolve",
            "*",
            RobotState.ERROR,
            conditions=[self.critical_error],
        )

    def on_state_changed(self, event):
        previous_state = event.transition.source
        new_state = event.transition.dest
        print(f"State changed: {previous_state} -> {new_state}")

    def can_start_search(self, event):
        return (
            self.ctx.camera_connected
            and self.ctx.battery_voltage > 10.5
            and not self.ctx.error
        )

    def good_target(self, event):
        return (
            self.ctx.target_found
            and self.ctx.target_confidence > 0.75
        )

    def target_lost_but_can_retry(self, event):
        return (
            not self.ctx.target_found
            and self.ctx.retry_count < 3
        )

    def critical_error(self, event):
        return (
            self.ctx.error
            or self.ctx.battery_voltage < 9.5
            or self.ctx.retry_count >= 3
        )


robot = Robot()

robot.ctx.camera_connected = True
robot.ctx.battery_voltage = 11.8
robot.resolve()
print(robot.state)  # RobotState.SEARCH

robot.ctx.target_found = True
robot.ctx.target_confidence = 0.9
robot.resolve()
print(robot.state)  # RobotState.TRACKING

robot.ctx.target_found = False
robot.ctx.retry_count = 1
robot.resolve()
print(robot.state)  # RobotState.RECOVERY