Commands and State Machine
View SourceIn this tutorial, you'll learn how to control your robot using commands and understand the robot state machine.
Prerequisites
Complete Starting and Stopping. You should understand how to start a robot's supervision tree.
The Robot State Machine
Every Beam Bots robot has a state machine with two core operational states:
:disarmed ──arm──→ :idle ──disarm──→ :disarmed- :disarmed - Robot is safe, actuators won't respond
- :idle - Robot is ready, waiting for commands
When commands are running, the robot remains in its current operational state. For backwards compatibility, BB.Robot.Runtime.state/1 returns :executing when commands are running in :idle state:
BB.Robot.Runtime.state(MyRobot) # => :executing (when commands running in :idle)
BB.Robot.Runtime.operational_state(MyRobot) # => :idle (the actual state)For Roboticists: This is similar to the arming concept in flight controllers. A disarmed robot won't move even if commanded to.
For Elixirists: Commands are short-lived GenServers with state machine guards. The robot only accepts certain commands based on its current state.
Checking Robot State
Query the current state:
iex> {:ok, _} = BB.Supervisor.start_link(MyRobot)
iex> BB.Robot.Runtime.state(MyRobot)
:disarmedNew robots always start in :disarmed.
Built-in Arm and Disarm Commands
To use the standard arm/disarm commands, add them to your robot:
defmodule MyRobot do
use BB
commands do
command :arm do
handler BB.Command.Arm
allowed_states [:disarmed]
end
command :disarm do
handler BB.Command.Disarm
allowed_states [:idle]
end
end
topology do
# ... your robot topology
end
endThe DSL generates convenience functions on your module:
iex> {:ok, cmd} = MyRobot.arm()
iex> {:ok, :armed, _opts} = BB.Command.await(cmd)
iex> BB.Robot.Runtime.state(MyRobot)
:idleCommand Execution Model
Commands are short-lived GenServers. When you execute a command:
- The Runtime spawns a supervised GenServer for the command
- You receive the command's pid
- Use
BB.Command.await/2orBB.Command.yield/2to get the result
# Execute and wait for result
{:ok, cmd} = MyRobot.arm()
{:ok, result, _opts} = BB.Command.await(cmd)
# Execute with timeout
{:ok, cmd} = MyRobot.move(shoulder: 0.5)
case BB.Command.yield(cmd, 5000) do
{:ok, result} -> handle_result(result)
{:error, reason} -> handle_error(reason)
nil -> handle_still_running()
endCommands run in supervised GenServers - if they crash, the robot returns to :idle (or the appropriate safe state) and awaiting callers receive an error.
Defining Custom Commands
Add commands to your robot with the commands block:
commands do
command :arm do
handler BB.Command.Arm
allowed_states [:disarmed]
end
command :disarm do
handler BB.Command.Disarm
allowed_states [:idle]
end
command :move_joint do
handler MyMoveJointCommand
allowed_states [:idle]
end
endEach command specifies:
- handler - Module using
BB.Command - allowed_states - Robot states where this command can execute
Implementing a Command Handler
Create a module using BB.Command:
defmodule MyMoveJointCommand do
use BB.Command
alias BB.Robot.State, as: RobotState
@impl BB.Command
def handle_command(goal, context, state) do
# goal is a map of the arguments passed to the command
# context provides access to robot state
# state is the command's internal state (includes :result key)
joint = Map.fetch!(goal, :joint)
position = Map.fetch!(goal, :position)
# Update joint position
:ok = RobotState.set_joint_position(context.robot_state, joint, position)
# Get the new position and store result
new_position = RobotState.get_joint_position(context.robot_state, joint)
{:stop, :normal, %{state | result: {:ok, %{joint: joint, position: new_position}}}}
end
@impl BB.Command
def result(%{result: result}), do: result
endRequired Callbacks
handle_command/3 - The main entry point:
- goal - Map of arguments passed when executing the command
- context - Struct containing:
robot_module- The robot modulerobot- The static robot structrobot_state- The dynamic state (ETS-backed joint positions)execution_id- Unique ID for this execution
- state - The command's internal state map (includes
:resultand:next_statekeys)
Returns GenServer-style tuples:
{:noreply, state}- Continue running (waiting for messages){:noreply, state, timeout | :hibernate | {:continue, term}}- Continue with action{:stop, reason, state}- Complete the command
result/1 - Extract the result when command stops:
- Called in
terminate/2to get the result for awaiting callers - Returns
{:ok, result},{:ok, result, opts}, or{:error, reason}
Optional Callbacks
init/1 - Initialise command state (default returns {:ok, Map.new(opts)})
handle_safety_state_change/2 - Handle safety transitions:
@impl BB.Command
def handle_safety_state_change(:disarming, state) do
# Robot is being disarmed - stop gracefully
{:stop, :disarmed, state}
end
def handle_safety_state_change(_new_state, state) do
# Continue execution (use with care!)
{:continue, state}
endThe default implementation stops with :disarmed on any safety state change.
handle_info/2, handle_call/3, handle_cast/2 - Standard GenServer callbacks for receiving messages during execution.
Async Commands
Commands that wait for external events (sensors, timers) can use the full GenServer lifecycle:
defmodule WaitForPositionCommand do
use BB.Command
alias BB.PubSub
@impl BB.Command
def handle_command(goal, context, state) do
target = Map.fetch!(goal, :target_position)
joint = Map.fetch!(goal, :joint)
# Subscribe to sensor updates
PubSub.subscribe(context.robot_module, [:sensor, joint])
# Store target in state and wait
{:noreply, %{state | target: target, joint: joint}}
end
@impl BB.Command
def handle_info({:bb, [:sensor, _joint], %{payload: joint_state}}, state) do
current = hd(joint_state.positions)
if abs(current - state.target) < 0.01 do
# Reached target
{:stop, :normal, %{state | result: {:ok, %{final_position: current}}}}
else
{:noreply, state}
end
end
def handle_info(_msg, state), do: {:noreply, state}
@impl BB.Command
def result(%{result: result}), do: result
endState vs Physical Movement
Important: Calling RobotState.set_joint_position/3 only updates Beam Bots' internal representation of where joints are. It does not move physical hardware.
To actually move a robot, you need:
- Actuators - GenServer processes that subscribe to command messages and drive motors
- Sensors - GenServer processes that read encoders and publish
JointStatemessages - Runtime - subscribes to sensor messages and updates the internal state
Here's the typical flow:
sequenceDiagram
participant Cmd as Command Handler
participant Act as Actuator
participant HW as Hardware
participant Sens as Sensor
participant RT as Runtime
Cmd->>Act: publish target position
loop Control Loop
Act->>HW: drive motor (PWM/CAN)
HW->>Sens: encoder signal
Sens->>RT: publish JointState
RT->>RT: update internal state
end
Cmd->>Cmd: await completionA command handler might publish a target position:
@impl BB.Command
def handle_command(goal, context, state) do
target = Map.fetch!(goal, :position)
# Publish target for actuator to follow
message = JointCommand.new!(:shoulder, target: target)
PubSub.publish(context.robot_module, [:actuator, :shoulder], message)
# Subscribe to sensor feedback
PubSub.subscribe(context.robot_module, [:sensor, :shoulder])
{:noreply, %{state | target: target}}
end
@impl BB.Command
def handle_info({:bb, [:sensor, :shoulder], %{payload: joint_state}}, state) do
if close_enough?(joint_state, state.target) do
{:stop, :normal, %{state | result: {:ok, :moved}}}
else
{:noreply, state}
end
endCommand Arguments
Define expected arguments with the argument entity:
command :move_joint do
handler MyMoveJointCommand
allowed_states [:idle]
argument :joint, :atom do
required true
doc "The joint to move"
end
argument :position, :float do
required true
doc "Target position in radians"
end
argument :velocity, :float do
required false
default 1.0
doc "Movement velocity in rad/s"
end
endExecute with keyword arguments:
{:ok, cmd} = MyRobot.move_joint(joint: :shoulder, position: 0.5)
{:ok, result} = BB.Command.await(cmd)Return Values
The result/1 callback returns:
# Success - robot returns to :idle
{:ok, result}
# Success with state transition
{:ok, result, next_state: :disarmed}
# Failure - robot returns to :idle
{:error, reason}The next_state option is how Arm and Disarm control the state machine:
# In BB.Command.Arm
@impl BB.Command
def result(%{result: {:ok, value}, next_state: next_state}) do
{:ok, value, next_state: next_state}
end
# In BB.Command.Disarm - transitions to :disarmed
def result(%{result: {:ok, value}, next_state: next_state}) do
{:ok, value, next_state: next_state}
endError Handling
Commands should return structured errors from BB.Error:
alias BB.Error.State.NotAllowed
@impl BB.Command
def handle_command(goal, context, state) do
case validate_goal(goal) do
:ok ->
# proceed
{:noreply, state}
{:error, reason} ->
{:stop, :normal, %{state | result: {:error, reason}}}
end
endWhen a command cannot start (wrong state), execute/3 returns the error directly:
iex> BB.Robot.Runtime.state(MyRobot)
:disarmed
iex> MyRobot.move_joint(joint: :shoulder, position: 0.5)
{:error, %BB.Error.State.NotAllowed{
current_state: :disarmed,
allowed_states: [:idle]
}}State Validation
Commands only execute in their allowed states:
iex> BB.Robot.Runtime.state(MyRobot)
:disarmed
iex> MyRobot.move_joint(joint: :shoulder, position: 0.5)
{:error, %BB.Error.State.NotAllowed{
current_state: :disarmed,
allowed_states: [:idle]
}}A Complete Example
Here's a robot with arm, disarm, and a custom move command:
defmodule SimpleArm do
use BB
defmodule MoveCommand do
use BB.Command
alias BB.Robot.State, as: RobotState
@impl BB.Command
def handle_command(goal, context, state) do
positions =
goal
|> Enum.into(%{})
|> Map.take([:shoulder, :elbow])
:ok = RobotState.set_positions(context.robot_state, positions)
new_positions = RobotState.get_all_positions(context.robot_state)
{:stop, :normal, %{state | result: {:ok, new_positions}}}
end
@impl BB.Command
def result(%{result: result}), do: result
end
commands do
command :arm do
handler BB.Command.Arm
allowed_states [:disarmed]
end
command :disarm do
handler BB.Command.Disarm
allowed_states [:idle]
end
command :move do
handler MoveCommand
allowed_states [:idle]
end
end
topology do
link :base do
joint :shoulder do
type :revolute
axis do
end
limit do
effort(~u(50 newton_meter))
velocity(~u(2 radian_per_second))
end
link :upper_arm do
joint :elbow do
type :revolute
axis do
end
limit do
effort(~u(30 newton_meter))
velocity(~u(3 radian_per_second))
end
link :forearm do
end
end
end
end
end
end
endUse it:
iex> {:ok, _} = BB.Supervisor.start_link(SimpleArm)
# Arm the robot
iex> {:ok, cmd} = SimpleArm.arm()
iex> {:ok, :armed, _} = BB.Command.await(cmd)
# Move joints
iex> {:ok, cmd} = SimpleArm.move(shoulder: 0.5, elbow: 1.0)
iex> {:ok, positions} = BB.Command.await(cmd)
# Disarm
iex> {:ok, cmd} = SimpleArm.disarm()
iex> {:ok, :disarmed, _} = BB.Command.await(cmd)Subscribing to State Transitions
Monitor state machine changes via PubSub:
BB.PubSub.subscribe(MyRobot, [:state_machine])
{:ok, cmd} = MyRobot.arm()
BB.Command.await(cmd)
# Receive transition messages
receive do
{:bb, [:state_machine], %BB.Message{payload: transition}} ->
IO.puts("#{transition.from} → #{transition.to}")
endCommand Cancellation
By default, when a command's category is at capacity (typically 1 command), starting another command in that category returns an error. But sometimes you want commands that can cancel running commands to make room.
Use the cancel option to specify which categories a command can cancel:
commands do
command :move_to do
handler MoveToCommand
allowed_states [:idle]
cancel [:default] # Can cancel other commands in :default category
end
command :emergency_stop do
handler EmergencyStopCommand
allowed_states :* # Can run in any state
cancel :* # Cancels all running commands
end
endWhen a command with cancel starts:
- Running commands in the specified categories are cancelled
- The new command starts
- Cancelled commands'
result/1is called and awaiting callers receive{:error, :cancelled}
The cancel option accepts:
:*- cancels all categories (expanded to all defined categories at compile time)[:category1, :category2]- cancels specific categories[](default) - cannot cancel anything, errors if category is full
This is useful for:
- Motion commands - send a new target without waiting for the previous move to complete
- Emergency stop - immediately halt regardless of what's running
- Trajectory updates - smoothly blend into a new path
Example with cancellable motion:
# Start moving to position A
{:ok, cmd_a} = MyRobot.move_to(position: 1.0)
# Before it completes, redirect to position B
# This cancels cmd_a automatically
{:ok, cmd_b} = MyRobot.move_to(position: 2.0)
# cmd_a returns {:error, :cancelled}
# cmd_b continues to completionCaution: Only enable cancellation for commands where interruption is safe. A calibration routine or homing sequence probably shouldn't be cancellable.
Cancelling Commands
Cancel a running command explicitly:
{:ok, cmd} = MyRobot.long_running_command()
# Later, if needed
BB.Robot.Runtime.cancel(MyRobot)
# The command's result/1 is called and awaiting callers receive the resultWhat's Next?
You now understand the command system and robot state machine. In the next tutorial, we'll:
- Export your robot to URDF format
- Visualise it in external tools
- Understand URDF limitations
Continue to Exporting to URDF.
For more advanced state management, see Custom States and Command Categories to learn about:
- Defining custom operational modes beyond
:idle(e.g.,:recording,:reacting) - Running multiple commands concurrently with category-based concurrency
- Mid-execution state transitions