Understanding the Command System

View Source

This document explains the design of Beam Bots' command system - why commands are short-lived processes, how they integrate with the state machine, and the patterns they enable.

The Core Model

Commands in BB are short-lived GenServers that execute discrete operations. They're not background services - they start, do something, and stop.

execute command
      
      

 Command Process   receives messages, updates state
   (GenServer)     decides when complete

      
      
  return result

This model is borrowed from action servers in ROS, but adapted for Erlang/OTP semantics.

Why Processes?

Commands could be simple function calls. Why make them processes?

Async by Default

Commands can take time. A movement command might wait for motors to reach position. Making commands processes means:

  • Callers don't block
  • Multiple commands can (potentially) run concurrently
  • Timeouts are handled naturally

Message-Driven

Commands often need to react to external events:

  • Sensor feedback (reached position?)
  • Hardware errors (servo overheating?)
  • User cancellation

As processes, commands can subscribe to PubSub and receive messages.

Supervision

Command processes are supervised. If a command crashes:

  • The robot returns to a safe state
  • Awaiting callers receive an error
  • Resources are cleaned up

State Machine Integration

Commands interact with the robot state machine. Being processes lets them:

  • Check state before executing
  • Hold the robot in a state while running
  • Transition state on completion

The State Machine

Every robot has an operational state:

:disarmed arm :idle disarm :disarmed

Commands declare which states they can run in:

command :move_to do
  handler MoveCommand
  allowed_states [:idle]
end

command :arm do
  handler BB.Command.Arm
  allowed_states [:disarmed]
end

The Runtime enforces these constraints:

BB.Robot.Runtime.state(MyRobot)  #=> :disarmed
MyRobot.move_to(position: 1.0)   #=> {:error, %NotAllowed{}}

Custom States

For complex robots, you can define custom states beyond :idle using the states DSL section:

states do
  state :recording do
    doc "Recording trajectory data"
  end

  state :playback do
    doc "Playing back recorded trajectory"
  end

  state :calibrating do
    doc "Running calibration routine"
  end
end

commands do
  command :start_recording do
    handler {BB.Command.SetState, to: :recording}
    allowed_states [:idle]
  end

  command :start_playback do
    allowed_states [:recording]
  end
end

The built-in states (:idle, :disarmed) are always available. Your custom states extend what's possible. See the Custom States and Categories tutorial for comprehensive coverage.

Command Lifecycle

1. Execution Request

{:ok, cmd_pid} = MyRobot.move_to(target: %{shoulder: 0.5})

The DSL generates this function. It calls BB.Robot.Runtime.execute/3.

2. State Validation

Runtime checks:

  • Robot is in an allowed state
  • Command category has capacity (for concurrent commands)

3. Process Start

A GenServer starts under the Runtime's DynamicSupervisor:

DynamicSupervisor.start_child(supervisor, {CommandServer, opts})

4. Handler Invocation

The CommandServer calls your handler's handle_command/3:

@impl BB.Command
def handle_command(goal, context, state) do
  # goal: arguments from the execute call
  # context: robot module, struct, state handle
  # state: command's internal state (includes :result)

  # Return GenServer-style tuple
  {:noreply, updated_state}
end

5. Execution

The command runs as a GenServer:

  • Receives messages via handle_info/2
  • Can make calls with handle_call/3
  • Subscribes to PubSub for sensor feedback

6. Completion

When done, return {:stop, reason, state}:

def handle_info(:done, state) do
  {:stop, :normal, %{state | result: {:ok, :completed}}}
end

7. Result Extraction

On termination, result/1 is called:

@impl BB.Command
def result(%{result: result}), do: result

The result goes to awaiting callers.

Awaiting Results

Callers have options:

Blocking Wait

{:ok, cmd} = MyRobot.move_to(target: %{shoulder: 0.5})
{:ok, result} = BB.Command.await(cmd)  # blocks until done

Timeout

case BB.Command.await(cmd, 5000) do
  {:ok, result} -> handle_result(result)
  {:error, :timeout} -> handle_timeout()
end

Non-Blocking Check

case BB.Command.yield(cmd, 0) do
  {:ok, result} -> done(result)
  nil -> still_running()
end

Fire and Forget

{:ok, _cmd} = MyRobot.move_to(target: %{shoulder: 0.5})
# Don't await - let it run

Command Categories

By default, only one command runs at a time. Categories enable concurrency:

commands do
  category :motion do
    doc "Physical movement commands"
    concurrency_limit 1
  end

  category :sensing do
    doc "Sensor and data collection commands"
    concurrency_limit 2  # Allow concurrent sensing
  end

  command :move_to do
    category :motion
    allowed_states [:idle]
  end

  command :read_sensor do
    category :sensing
    allowed_states [:idle]
  end
end

Each category has a concurrency_limit (default: 1). Commands in different categories can run concurrently. Commands in the same category are limited by that category's concurrency limit.

Cancellation

Commands can declare they cancel others:

command :move_to do
  cancel [:motion]  # Cancels running motion commands
end

command :emergency_stop do
  cancel :*  # Cancels everything
end

When cancelled, the command process terminates and result/1 is called with the current state. Awaiting callers receive whatever result/1 returns. Commands should handle cancellation with a fallback clause:

@impl BB.Command
def result(%{result: result}) do
  {:ok, result}
end

def result(_state), do: {:error, :cancelled}

State Transitions

Commands can change robot state:

@impl BB.Command
def result(%{result: {:ok, value}}) do
  {:ok, value, next_state: :recording}
end

This is how BB.Command.Arm works - it returns {:ok, :armed, next_state: :idle}.

Safety Integration

Commands receive safety state changes:

@impl BB.Command
def handle_safety_state_change(:disarming, state) do
  # Robot is being disarmed - stop gracefully
  {:stop, :disarmed, %{state | result: {:error, :disarmed}}}
end

The default implementation stops on any safety change. Override for commands that should continue (use with care).

Design Patterns

Request-Feedback-Result

The canonical pattern for motion commands:

  1. Request: Send target to actuators
  2. Feedback: Subscribe to sensors, monitor progress
  3. Result: Complete when target reached or timeout
def handle_command(goal, context, state) do
  # Request
  publish_targets(goal.target, context)
  subscribe_to_sensors(goal.target, context)

  {:noreply, %{state | target: goal.target}}
end

def handle_info({:bb, [:sensor, _], msg}, state) do
  if reached_target?(msg, state.target) do
    # Result
    {:stop, :normal, %{state | result: {:ok, :reached}}}
  else
    # Feedback (continue waiting)
    {:noreply, state}
  end
end

Coordination

For commands coordinating multiple subsystems:

def handle_command(goal, context, state) do
  # Start multiple operations
  start_arm_motion(goal, context)
  start_gripper_action(goal, context)

  {:noreply, %{state | arm_done: false, gripper_done: false}}
end

def handle_info({:arm_complete, _}, state) do
  check_completion(%{state | arm_done: true})
end

def handle_info({:gripper_complete, _}, state) do
  check_completion(%{state | gripper_done: true})
end

defp check_completion(%{arm_done: true, gripper_done: true} = state) do
  {:stop, :normal, %{state | result: {:ok, :complete}}}
end

defp check_completion(state), do: {:noreply, state}

Timeout with Progress

For commands that might hang:

def handle_command(goal, _context, state) do
  schedule_timeout(5000)
  {:noreply, %{state | last_progress: now()}}
end

def handle_info(:timeout, state) do
  if stale?(state.last_progress) do
    {:stop, :normal, %{state | result: {:error, :timeout}}}
  else
    schedule_timeout(5000)
    {:noreply, state}
  end
end

def handle_info({:progress, _}, state) do
  {:noreply, %{state | last_progress: now()}}
end