Custom States and Command Categories
View SourceIn this tutorial, you'll learn how to define custom operational states and use command categories to run multiple commands concurrently.
Prerequisites
Complete Commands and State Machine. You should understand the basic state machine and how to define commands.
Beyond Idle
The default state machine has just two operational states: :disarmed and :idle. This works well for simple robots, but real applications often need more operational modes:
- A data collection robot might have a recording mode
- A reactive robot might have a reacting mode where it responds to stimuli
- A robot running learned behaviours might switch between inference and training modes
Beam Bots lets you define custom operational states that represent these modes.
Defining Custom States
Add a states section to your robot:
defmodule DataCollectionRobot do
use BB
states do
initial_state :idle # Default, can be omitted
state :recording do
doc "Recording sensor data for dataset collection"
end
state :processing do
doc "Processing recorded data"
end
end
commands do
command :arm do
handler BB.Command.Arm
allowed_states [:disarmed]
end
command :disarm do
handler BB.Command.Disarm
allowed_states [:idle, :recording, :processing]
cancel :* # Can cancel any running commands
end
end
topology do
link :base_link
end
endThe built-in states (:idle, :disarmed) are always available. Your custom states extend what's possible.
Transitioning Between States
States can only change via commands - there's no direct API to set the state. This ensures all state transitions are tracked, auditable, and follow the command lifecycle.
Simple State Transitions with SetState
For straightforward state changes, use the built-in BB.Command.SetState handler:
commands do
command :enter_recording do
handler {BB.Command.SetState, to: :recording}
allowed_states [:idle]
end
command :exit_recording do
handler {BB.Command.SetState, to: :idle}
allowed_states [:recording]
end
command :start_processing do
handler {BB.Command.SetState, to: :processing}
allowed_states [:recording] # Can only process after recording
end
endUse these commands like any other:
iex> {:ok, _} = BB.Supervisor.start_link(DataCollectionRobot)
iex> {:ok, cmd} = DataCollectionRobot.arm()
iex> {:ok, :armed, _} = BB.Command.await(cmd)
iex> BB.Robot.Runtime.state(DataCollectionRobot)
:idle
iex> {:ok, cmd} = DataCollectionRobot.enter_recording()
iex> {:ok, :recording, _} = BB.Command.await(cmd)
iex> BB.Robot.Runtime.state(DataCollectionRobot)
:recordingState Transitions During Command Execution
Commands that do work over time can transition through multiple states using BB.Command.transition_state/2:
defmodule DataPipelineCommand do
use BB.Command
@impl BB.Command
def handle_command(_goal, context, state) do
# Start in :recording state
:ok = BB.Command.transition_state(context, :recording)
# Begin recording
send(self(), :start_recording)
{:noreply, Map.put(state, :context, context)}
end
@impl BB.Command
def handle_info(:start_recording, state) do
# ... record data ...
Process.send_after(self(), :finish_recording, 5000)
{:noreply, state}
end
def handle_info(:finish_recording, state) do
# Transition to processing
:ok = BB.Command.transition_state(state.context, :processing)
# Process the data
send(self(), :process_data)
{:noreply, state}
end
def handle_info(:process_data, state) do
# ... process data ...
{:stop, :normal, Map.put(state, :result, {:ok, :pipeline_complete})}
end
@impl BB.Command
def result(%{result: result}) do
# Return to :idle when complete
{:ok, result, next_state: :idle}
end
def result(_state), do: {:error, :cancelled}
endQuerying State
Use BB.Robot.Runtime to query the current state:
# Get the operational state (what mode the robot is in)
BB.Robot.Runtime.operational_state(MyRobot)
# => :idle | :recording | :processing | ...
# Get the "classic" state (backwards compatible)
BB.Robot.Runtime.state(MyRobot)
# => :disarmed | :idle | :executing | :recording | ...The difference between state/1 and operational_state/1:
operational_state/1returns the actual operational modestate/1returns:executingwhen in:idlewith commands running (for backwards compatibility)
For custom states, both return the actual state regardless of whether commands are running.
Command Categories
By default, only one command runs at a time. But some robots need concurrent operations:
- Move the arm while recording sensor data
- Blink an LED while executing a motion
- Run multiple sensing operations in parallel
Command categories let you define groups of commands with independent concurrency.
Defining Categories
Add categories to your commands section:
commands do
category :motion do
doc "Physical movement commands"
concurrency_limit 1 # Only one motion at a time (default)
end
category :sensing do
doc "Sensor and recording commands"
concurrency_limit 2 # Up to 2 concurrent sensing operations
end
category :auxiliary do
doc "LEDs, sounds, indicators"
concurrency_limit 3 # Multiple concurrent auxiliary commands
end
# Commands specify their category
command :move_to do
handler MyMoveCommand
category :motion
allowed_states [:idle]
cancel [:motion] # Can cancel previous motion commands
end
command :record_frame do
handler MyRecordCommand
category :sensing
allowed_states [:idle]
# No cancel - concurrent sensing up to limit
end
command :set_led do
handler MyLedCommand
category :auxiliary
allowed_states [:idle]
# No cancel - concurrent auxiliary up to limit
end
endHow Categories Work
- Each category has a
concurrency_limit(default: 1) - Commands in a category run concurrently up to that limit
- Commands in different categories can run concurrently
- Commands without an explicit category use the
:defaultcategory (limit: 1)
# Start a motion command
{:ok, move_cmd} = MyRobot.move_to(target: position)
# While moving, start recording (different category - runs concurrently)
{:ok, record_cmd} = MyRobot.record_frame(sensor: :camera)
# Both commands are now running
BB.Robot.Runtime.executing_commands(MyRobot)
# => [
# %{name: :move_to, category: :motion, pid: #PID<...>},
# %{name: :record_frame, category: :sensing, pid: #PID<...>}
# ]Category Full Behaviour
When a category is at capacity, the behaviour depends on the cancel option:
- If the command has
cancelthat includes the full category, it cancels commands to make room - Otherwise, the new command is rejected with
{:error, %BB.Error.Category.Full{}}
# Start a motion command
{:ok, cmd1} = MyRobot.move_to(target: pos1)
# Start another motion (same category, at limit)
{:ok, cmd2} = MyRobot.move_to(target: pos2)
# cmd1 is cancelled, cmd2 runs
# Because :move_to has cancel: [:motion]The cancel option accepts:
:*- cancels all categories[:motion, :sensing]- cancels specific categories[](default) - cannot cancel, errors if category is full
Introspection APIs
Query the execution state:
# Is anything executing?
BB.Robot.Runtime.executing?(MyRobot)
# => true | false
# Is a specific category occupied?
BB.Robot.Runtime.executing?(MyRobot, :motion)
# => true | false
# List all running commands
BB.Robot.Runtime.executing_commands(MyRobot)
# => [%{name: :move_to, category: :motion, pid: #PID<...>, ...}]
# Get category availability
BB.Robot.Runtime.category_availability(MyRobot)
# => %{motion: {1, 1}, sensing: {0, 2}, default: {0, 1}}
# Format: {current_count, limit}Compile-Time Validation
The DSL validates your state and category references at compile time:
# This will produce a warning:
command :bad_cmd do
handler MyHandler
allowed_states [:nonexistent_state] # Warning: undefined state
end
# This will also produce a warning:
command :bad_cmd do
handler MyHandler
category :nonexistent_category # Warning: undefined category
endA Complete Example
Here's a robot that collects data while moving:
defmodule DataCollectorArm do
use BB
states do
state :recording do
doc "Actively recording sensor data"
end
end
commands do
category :motion do
concurrency_limit 1
end
category :data do
concurrency_limit 1
end
command :arm do
handler BB.Command.Arm
allowed_states [:disarmed]
end
command :disarm do
handler BB.Command.Disarm
allowed_states [:idle, :recording]
cancel :* # Can cancel any running commands
end
command :enter_recording do
handler {BB.Command.SetState, to: :recording}
allowed_states [:idle]
end
command :exit_recording do
handler {BB.Command.SetState, to: :idle}
allowed_states [:recording]
end
command :move_to do
handler MoveToCommand
category :motion
allowed_states [:idle, :recording]
cancel [:motion] # Can cancel previous motion commands
end
command :capture_frame do
handler CaptureFrameCommand
category :data
allowed_states [:recording]
cancel [:data] # Can cancel previous capture commands
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 :arm
end
end
end
endUsing it:
# Start and arm
{:ok, _} = BB.Supervisor.start_link(DataCollectorArm)
{:ok, cmd} = DataCollectorArm.arm()
{:ok, :armed, _} = BB.Command.await(cmd)
# Enter recording mode
{:ok, cmd} = DataCollectorArm.enter_recording()
{:ok, :recording, _} = BB.Command.await(cmd)
# Now we can move AND capture frames concurrently
{:ok, move_cmd} = DataCollectorArm.move_to(position: 0.5)
{:ok, capture_cmd} = DataCollectorArm.capture_frame(sensor: :camera)
# Both commands run in parallel (different categories)
BB.Robot.Runtime.executing_commands(DataCollectorArm)
# => [%{name: :move_to, category: :motion}, %{name: :capture_frame, category: :data}]
# Wait for both
BB.Command.await(move_cmd)
BB.Command.await(capture_cmd)
# Exit recording mode
{:ok, cmd} = DataCollectorArm.exit_recording()
{:ok, :idle, _} = BB.Command.await(cmd)Best Practices
Use states for operational modes, not for tracking progress. A state like
:recordingis good; a state like:step_3_of_5is probably better handled inside a command.Keep category limits low. High concurrency limits can make reasoning about robot behaviour difficult. Most categories should have limit 1.
Validate state transitions. Use
allowed_statesto ensure commands can only run in appropriate modes.Consider safety implications. Can your robot safely run concurrent motions? Usually not - keep motion commands in a single category with limit 1.
Use SetState for simple transitions. Only implement custom command handlers when you need to do work during the transition.
What's Next?
You now understand custom states and command categories. Continue exploring:
- Parameters for runtime-adjustable configuration
- Safety for implementing safe hardware control