BB.Command behaviour (bb v0.15.0)

View Source

Behaviour for implementing robot commands.

Commands are short-lived GenServers that can react to safety state changes and other messages during execution. The handle_command/3 callback is the entry point, returning GenServer-style tuples.

Example

defmodule NavigateToPose do
  use BB.Command

  @impl BB.Command
  def handle_command(%{target_pose: pose}, context, state) do
    # Subscribe to position updates
    BB.PubSub.subscribe(context.robot_module, [:sensor, :position])

    # Start navigation
    send_navigation_command(pose)

    {:noreply, %{state | target: pose}}
  end

  @impl BB.Command
  def handle_info({:bb, [:sensor, :position], msg}, state) do
    if close_enough?(msg.payload.position, state.target) do
      {:stop, :normal, %{state | final_pose: msg.payload.position}}
    else
      {:noreply, state}
    end
  end

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

State Transitions

By default, when a command completes successfully, the robot transitions to :idle. Commands can override this by returning a next_state option from result/1:

def result(state) do
  {:ok, :armed, next_state: :idle}
end

This is useful for commands like Arm and Disarm that need to control the robot's state machine.

Execution Model

Commands run as supervised GenServers spawned by the Runtime. The caller receives the command's pid and can use BB.Command.await/2 or BB.Command.yield/2 to get the result.

Safety Handling

Commands automatically subscribe to safety state changes. When the robot begins disarming, handle_safety_state_change/2 is called. The default implementation stops the command with :disarmed reason. Override this callback to implement graceful shutdown or to continue execution during safety transitions.

Parameterised Options

Commands can receive options via child_spec format in the DSL:

commands do
  command :move_joint do
    handler {MyMoveJointCommand, max_velocity: param([:motion, :max_velocity])}
  end
end

ParamRefs are resolved before init/1 is called. When parameters change, handle_options/2 is called with the new resolved options.

Summary

Callbacks

Handle synchronous calls.

Handle asynchronous casts.

Execute the command with the given goal.

Handle continue instructions.

Handle other messages.

Handle parameter changes.

Handle safety state changes.

Initialise the command state.

Define the options schema for this command.

Extract the result when the command completes.

Clean up when the command terminates.

Functions

Await the command result, blocking until completion or timeout.

Cancel a running command.

Transition to a new operational state during command execution.

Non-blocking check for command completion.

Types

goal()

@type goal() :: map()

options()

@type options() :: [{:next_state, BB.Robot.Runtime.robot_state()}]

result()

@type result() :: term()

state()

@type state() :: term()

Callbacks

handle_call(request, from, state)

@callback handle_call(request :: term(), from :: GenServer.from(), state()) ::
  {:reply, term(), state()}
  | {:reply, term(), state(), timeout() | :hibernate | {:continue, term()}}
  | {:noreply, state()}
  | {:noreply, state(), timeout() | :hibernate | {:continue, term()}}
  | {:stop, term(), state()}
  | {:stop, term(), term(), state()}

Handle synchronous calls.

Standard GenServer callback. The default implementation returns {:reply, {:error, :not_implemented}, state}.

handle_cast(request, state)

@callback handle_cast(request :: term(), state()) ::
  {:noreply, state()}
  | {:noreply, state(), timeout() | :hibernate | {:continue, term()}}
  | {:stop, term(), state()}

Handle asynchronous casts.

Standard GenServer callback. The default implementation returns {:noreply, state}.

handle_command(goal, t, state)

@callback handle_command(goal(), BB.Command.Context.t(), state()) ::
  {:noreply, state()}
  | {:noreply, state(), timeout() | :hibernate | {:continue, term()}}
  | {:stop, term(), state()}

Execute the command with the given goal.

Called via handle_continue(:execute) immediately after init/1. This is the main entry point for command execution.

The handler can:

  • Return {:noreply, state} to continue running (waiting for messages)
  • Return {:stop, reason, state} to complete immediately

For commands that complete immediately, simply return {:stop, :normal, state} with the result stored in state.

handle_continue(continue, state)

@callback handle_continue(continue :: term(), state()) ::
  {:noreply, state()}
  | {:noreply, state(), timeout() | :hibernate | {:continue, term()}}
  | {:stop, term(), state()}

Handle continue instructions.

Standard GenServer callback. The default implementation returns {:noreply, state}.

handle_info(msg, state)

@callback handle_info(msg :: term(), state()) ::
  {:noreply, state()}
  | {:noreply, state(), timeout() | :hibernate | {:continue, term()}}
  | {:stop, term(), state()}

Handle other messages.

Standard GenServer callback. The default implementation returns {:noreply, state}.

handle_options(new_opts, state)

@callback handle_options(new_opts :: keyword(), state()) ::
  {:ok, state()} | {:stop, term()}

Handle parameter changes.

Called when a parameter that this command depends on changes. The new resolved options are passed in. The default implementation returns {:ok, state} unchanged.

handle_safety_state_change(new_state, state)

@callback handle_safety_state_change(
  new_state :: :disarming | :disarmed | :error,
  state()
) :: {:continue, state()} | {:stop, term(), state()}

Handle safety state changes.

Called when the robot's safety state transitions to :disarming, :disarmed, or :error. The default implementation stops the command with :disarmed reason.

Return {:continue, state} to keep the command running during safety transitions (use with care).

init(opts)

@callback init(opts :: keyword()) :: {:ok, state()} | {:stop, term()}

Initialise the command state.

Called when the command server starts. Receives resolved options including:

  • :bb - Map with :robot (robot module)
  • :goal - The command goal (arguments)
  • :context - The command context

The default implementation returns {:ok, Map.new(opts)}.

options_schema()

(optional)
@callback options_schema() :: Spark.Options.schema()

Define the options schema for this command.

Optional. If defined, options passed to the command handler will be validated against this schema.

result(state)

@callback result(state()) ::
  {:ok, result()} | {:ok, result(), options()} | {:error, term()}

Extract the result when the command completes.

Called in terminate/2 to get the result to return to awaiting callers.

Return Values

  • {:ok, result} - Command succeeded, robot transitions to :idle
  • {:ok, result, options} - Command succeeded with options:
    • next_state: state - Robot transitions to specified state instead of :idle
  • {:error, reason} - Command failed, robot transitions to :idle

terminate(reason, state)

@callback terminate(reason :: term(), state()) :: term()

Clean up when the command terminates.

Standard GenServer callback. Called after the result has been extracted and sent to awaiting callers.

Functions

await(pid, timeout \\ 5000)

@spec await(pid(), timeout()) ::
  {:ok, term()} | {:ok, term(), options()} | {:error, term()}

Await the command result, blocking until completion or timeout.

Uses GenServer.call internally, so standard timeout semantics apply. If the command crashes, returns {:error, {:command_failed, reason}}.

Examples

{:ok, cmd} = MyRobot.navigate(target: pose)
{:ok, result} = BB.Command.await(cmd)

# With custom timeout
{:ok, result} = BB.Command.await(cmd, 30_000)

cancel(pid)

@spec cancel(pid()) :: :ok

Cancel a running command.

Stops the command server with :cancelled reason. Awaiting callers will receive {:error, :cancelled} (depending on how result/1 handles this).

transition_state(context, target_state)

@spec transition_state(BB.Command.Context.t(), atom()) :: :ok | {:error, term()}

Transition to a new operational state during command execution.

This function allows a command to change the robot's operational state mid-execution. This is useful for multi-phase commands where different phases require different contexts.

Arguments

  • context - The command context (passed to handle_command/3)
  • target_state - The state to transition to (must be defined in DSL)

Returns

  • :ok - Transition successful
  • {:error, reason} - Transition failed

Example

def handle_command(_goal, context, state) do
  # Start in processing state
  :ok = BB.Command.transition_state(context, :processing)
  # Do work...
  send(self(), :start_phase_two)
  {:noreply, state}
end

def handle_info(:start_phase_two, context, state) do
  # Move to finalising state
  :ok = BB.Command.transition_state(context, :finalising)
  # Do more work...
  {:stop, :normal, state}
end

yield(pid, timeout \\ 0)

@spec yield(pid(), timeout()) ::
  {:ok, term()} | {:ok, term(), options()} | {:error, term()} | nil

Non-blocking check for command completion.

Returns nil if the command is still running (timeout), otherwise returns the result. Use this for polling-style waiting.

Examples

{:ok, cmd} = MyRobot.navigate(target: pose)

case BB.Command.yield(cmd, 100) do
  nil -> IO.puts("Still running...")
  {:ok, result} -> IO.puts("Done!")
  {:error, reason} -> IO.puts("Failed: #{inspect(reason)}")
end