BB.Actuator behaviour (bb v0.30.1)

Copy Markdown View Source

Behaviour and API for actuators in the BB framework.

This module serves two purposes:

  1. Behaviour - Defines callbacks for actuator implementations
  2. API - Provides functions for sending commands to actuators

Behaviour

Actuators receive position/velocity/effort commands and drive hardware. They must implement the init/1 and disarm/1 callbacks.

Usage

The use BB.Actuator macro sets up your module as an actuator callback module. Your module is NOT a GenServer - the framework provides a wrapper GenServer (BB.Actuator.Server) that delegates to your callbacks.

Required Callbacks

  • init/1 - Initialise actuator state from resolved options
  • handle_command/2 - Act on an inbound command
  • disarm/1 - Make hardware safe (called without GenServer state)

Optional Callbacks

  • capabilities/1 - Declare that the driver reads position, velocity or effort back from the hardware. Without it the framework assumes it doesn't, and warns that the joint needs a sensor
  • handle_options/2 - React to parameter changes at runtime
  • handle_call/3, handle_cast/2, handle_info/2 - Standard GenServer-style callbacks, for the driver's own traffic
  • handle_continue/2, terminate/2 - Lifecycle callbacks
  • options_schema/0 - Define accepted configuration options

Options Schema

If your actuator accepts configuration options, pass them via :options_schema:

defmodule MyServoActuator do
  use BB.Actuator,
    options_schema: [
      channel: [type: {:in, 0..15}, required: true, doc: "PWM channel"],
      controller: [type: :atom, required: true, doc: "Controller name"]
    ]

  @impl BB.Actuator
  def init(opts) do
    channel = Keyword.fetch!(opts, :channel)
    bb = Keyword.fetch!(opts, :bb)
    {:ok, %{channel: channel, bb: bb}}
  end

  @impl BB.Actuator
  def disarm(opts) do
    MyHardware.disable(opts[:controller], opts[:channel])
    :ok
  end

  @impl BB.Actuator
  def handle_command(%BB.Message{payload: %Command.Position{} = cmd}, state) do
    MyHardware.write(state.channel, cmd.position)
    {:noreply, state}
  end
end

For actuators that don't need configuration, omit :options_schema:

defmodule SimpleActuator do
  use BB.Actuator

  @impl BB.Actuator
  def init(opts) do
    {:ok, %{bb: opts[:bb]}}
  end

  @impl BB.Actuator
  def handle_command(_message, state), do: {:noreply, state}

  @impl BB.Actuator
  def disarm(_opts), do: :ok
end

Parameter References

Options can reference parameters for runtime-adjustable configuration:

actuator :motor, {MyMotor, max_effort: param([:motion, :max_effort])}

When the parameter changes, handle_options/2 is called with the new resolved options. Override it to update your state accordingly.

Auto-injected Options

The :bb option is automatically provided and should NOT be included in your options_schema. It contains %{robot: module, path: [atom]}.

Safety Registration

Safety registration is automatic - the framework registers your module with BB.Safety using the resolved options. You don't need to call BB.Safety.register manually.

API

Delivery Methods

set_position/4 takes a :delivery option, the same one BB.Motion takes:

  • :pubsub (default) - The command is published to [:actuator | path] so orchestration and logging can observe it, and delivered to the actuator by a call. Returns :ok or {:error, reason}, so a caller finds out that a joint isn't moving rather than assuming it is.

  • :direct - Sent via BB.Process.cast, publishing nothing. Lower latency for time-critical control, at the price of a refusal that only the log and telemetry ever see. It always returns :ok.

The other payloads still offer the older trio - a pubsub function, a ! cast and a _sync call (set_velocity/4, set_velocity!/4, set_velocity_sync/5) - in which the pubsub form cannot report a refusal at all.

All of them converge on handle_command/2. Which transport a caller chose is not something a driver has to know about, and choosing one cannot skip the checks BB.Actuator.Server applies on the way in.

Addressing

Every function accepts either the actuator's unique name or its full path through the topology. Names are resolved against the robot with BB.Robot.actuator_path/2, so the two are interchangeable:

BB.Actuator.set_position(MyRobot, :shoulder_servo, 1.57)
BB.Actuator.set_position(MyRobot, [:base_link, :shoulder, :shoulder_servo], 1.57)

Naming an actuator the robot doesn't have raises ArgumentError rather than publishing to a topic nothing is listening on.

Examples

# Published for observers, acknowledged by the actuator
:ok = BB.Actuator.set_position(MyRobot, :shoulder_servo, 1.57)

# Fire-and-forget (for time-critical control)
BB.Actuator.set_position(MyRobot, :shoulder_servo, 1.57, delivery: :direct)

Summary

Types

Something an actuator can do beyond taking commands.

How to address an actuator: its unique name, or its full path through the topology. Every function below accepts either.

Callbacks

What this actuator can do besides move.

The command payloads this actuator accepts.

Make the hardware safe.

Handle synchronous calls other than commands.

Handle asynchronous casts other than commands.

Act on an inbound command.

Handle continue instructions.

Handle all other messages.

Handle parameter changes at runtime.

Initialise actuator state from resolved options.

Returns the options schema for this actuator.

Clean up before termination.

Functions

The command payloads an actuator accepts unless it says otherwise.

Send a trajectory command via pubsub.

Send a trajectory command directly to an actuator (bypasses pubsub).

Send a trajectory command and wait for acknowledgement.

Send a hold command via pubsub.

Send a hold command directly to an actuator (bypasses pubsub).

Send a hold command and wait for acknowledgement.

Publish a BeginMotion message for the actuator at path, converting the supplied motor-space values into joint-space before publishing.

Send an effort (torque/force) command via pubsub.

Send an effort command directly to an actuator (bypasses pubsub).

Send an effort command and wait for acknowledgement.

Send a velocity command via pubsub.

Send a velocity command directly to an actuator (bypasses pubsub).

Send a velocity command and wait for acknowledgement.

Send a stop command via pubsub.

Send a stop command directly to an actuator (bypasses pubsub).

Send a stop command and wait for acknowledgement.

Translate a motor-space outbound message into joint-space using the transmission of the joint above the actuator at actuator_path.

Types

capability()

@type capability() :: :position_feedback | :velocity_feedback | :effort_feedback

Something an actuator can do beyond taking commands.

Each value names a field of BB.Message.Sensor.JointState the driver can fill in for itself, having read it back from the hardware.

target()

@type target() :: atom() | [atom()]

How to address an actuator: its unique name, or its full path through the topology. Every function below accepts either.

Callbacks

capabilities(opts)

(optional)
@callback capabilities(opts :: keyword()) :: [capability()]

What this actuator can do besides move.

Defaults to [] - the honest answer for a driver that only writes to its hardware, like a PWM servo or a step/direction driver. Such a joint needs a sensor to say where it ended up, and BB.Dsl warns at compile time when it doesn't have one.

A driver that reads state back from the hardware - a smart servo answering position queries on its bus - says so here, and publishes what it reads as BB.Message.Sensor.JointState on its joint's sensor topic:

@impl BB.Actuator
def capabilities(_opts), do: [:position_feedback, :velocity_feedback]

Declaring :position_feedback tells the framework this actuator is its own position sensor, so no warning is issued for the joint it drives. Declare it only if the driver really does publish JointState: the warning exists because BB.Robot.State is written from those messages and from nothing else, so a joint nobody reports on never moves as far as the rest of the framework is concerned.

Options

opts lets a driver answer for how it was wired up, rather than for the hardware in general - an encoder input that may or may not be connected:

@impl BB.Actuator
def capabilities(opts) do
  if opts[:feedback_pin], do: [:position_feedback], else: []
end

These are not the options init/1 receives

This is asked at compile time, by a DSL verifier, so opts is what the robot's author wrote in the DSL, checked against options_schema/0 and with its defaults applied. Two things follow:

  • There is no :bb key, and no :motor_profile. The robot doesn't exist yet.
  • A param() reference can't be resolved before the robot is running, so a parameterised option arrives holding its schema default instead of the value the robot will run with. A capability that genuinely depends on one can't be answered here; say what is true of the common case, and prefer claiming a capability you sometimes lack over disclaiming one you usually have - a spurious warning teaches people to ignore warnings.

Keep it pure for the same reason: no hardware, no processes, no Mix.

command_payloads(opts)

(optional)
@callback command_payloads(opts :: keyword()) :: [module()]

The command payloads this actuator accepts.

Defaults to default_command_payloads/0 — the six built-in BB.Message.Actuator.Command.* types — which is right for almost every driver. Override it to either end of the range:

  • Widen it. A driver whose hardware speaks a command BB doesn't model can name its own payload module here, and it will arrive through the same gated pipeline as any other command. Without that it would have to subscribe to [:actuator | path] itself, and those messages reach the driver having skipped the arm check.
  • Narrow it. A port that only ever accepts Command.Effort can say so, and the framework refuses everything else before the driver sees it.

Called once at init, with the resolved options, because the answer isn't always known at compile time — it may depend on a port or channel named in the driver's own options.

@impl BB.Actuator
def command_payloads(opts) do
  [opts |> Keyword.fetch!(:port) |> MyDriver.command_struct()]
end

The result is used for both the actuator's pubsub subscription and its dispatch guard, so narrowing holds across all three transports rather than only the published one.

Nothing is admitted outside this list, Stop included. A driver is never handed a payload it didn't declare, so it can't be crashed by one it has no clause for.

disarm(opts)

@callback disarm(opts :: keyword()) :: :ok | {:error, term()}

Make the hardware safe.

Called with the opts provided at registration. Must work without GenServer state. This callback is required for actuators since they control physical hardware.

handle_call(request, from, state)

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

Handle synchronous calls other than commands.

Same semantics as GenServer.handle_call/3. Commands arrive at handle_command/2 regardless of transport.

handle_cast(request, state)

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

Handle asynchronous casts other than commands.

Same semantics as GenServer.handle_cast/2. Commands arrive at handle_command/2 regardless of transport.

handle_command(command, state)

@callback handle_command(command :: BB.Message.t(), state :: term()) ::
  {:reply, reply :: term(), new_state :: term()}
  | {:reply, reply :: term(), new_state :: term(),
     timeout() | :hibernate | {:continue, term()}}
  | {:noreply, new_state :: term()}
  | {:noreply, new_state :: term(),
     timeout() | :hibernate | {:continue, term()}}
  | {:stop, reason :: term(), new_state :: term()}

Act on an inbound command.

Called for every command that reaches this actuator, whichever transport delivered it. By the time it arrives, BB.Actuator.Server has checked that the robot is armed and translated the payload from joint-space into motor-space, so the values are ready to write to hardware.

The reply is used only by callers that wait for one (set_position/4, set_velocity_sync/5 and friends); it is discarded for cast delivery. Returning {:noreply, state} replies {:ok, :accepted} to such a caller, which set_position/4 reports as :ok. Only an {:error, reason} reply tells a caller its command was refused.

@impl BB.Actuator
def handle_command(%BB.Message{payload: %Command.Position{} = cmd}, state) do
  MyHardware.write(state.channel, cmd.position)
  {:noreply, state}
end

Commands the driver doesn't implement should fall through to a catch-all clause rather than crashing the actuator - a Command.Trajectory sent to a position-only servo is a caller error, not a hardware fault.

Command.Stop is a motion command, not a safety one

Stop means cease travelling and become passive — it's the counterpart to Command.Hold, which maintains position and resists external force. Its :decelerate mode makes that plain: nothing that slows down smoothly is an emergency stop.

Making hardware safe is disarm/1, which is robot-wide, runs without GenServer state, and leaves the robot unable to move until re-armed. Don't reach for Stop to do that job.

If you declare Stop in command_payloads/1 — and the default does — give it a clause that genuinely stops driving, rather than letting a catch-all swallow it and report success while the joint keeps moving:

def handle_command(%BB.Message{payload: %Command.Stop{}}, state) do
  MyHardware.cut_drive(state.channel)
  {:noreply, state}
end

handle_continue(continue_arg, state)

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

Handle continue instructions.

Same semantics as GenServer.handle_continue/2.

handle_info(msg, state)

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

Handle all other messages.

Same semantics as GenServer.handle_info/2. Messages from topics the driver subscribed to itself arrive here untouched - the server neither transforms nor intercepts them. Commands addressed to this actuator arrive at handle_command/2 instead.

handle_options(new_opts, state)

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

Handle parameter changes at runtime.

Called when a referenced parameter changes. The new_opts contain all options with the updated parameter value(s) resolved.

Return {:ok, new_state} to update state, or {:stop, reason} to shut down.

init(opts)

@callback init(opts :: keyword()) ::
  {:ok, state :: term()}
  | {:ok, state :: term(), timeout() | :hibernate | {:continue, term()}}
  | {:stop, reason :: term()}
  | :ignore

Initialise actuator state from resolved options.

Called with options after parameter references have been resolved. The :bb key contains %{robot: module, path: [atom]}.

Return {:ok, state} or {:ok, state, timeout_or_continue} on success, {:stop, reason} to abort startup, or :ignore to skip this actuator.

options_schema()

@callback options_schema() :: Spark.Options.t()

Returns the options schema for this actuator.

The schema should NOT include the :bb option - it is auto-injected. If this callback is not implemented, the module cannot accept options in the DSL (must be used as a bare module).

terminate(reason, state)

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

Clean up before termination.

Same semantics as GenServer.terminate/2.

Functions

default_command_payloads()

@spec default_command_payloads() :: [module()]

The command payloads an actuator accepts unless it says otherwise.

These are the payload types BB.Actuator's own API can produce, so they are the set every driver is expected to understand — or at least to ignore gracefully.

follow_trajectory(robot, target, waypoints, opts \\ [])

@spec follow_trajectory(module(), target(), [keyword() | map()], keyword()) :: :ok

Send a trajectory command via pubsub.

Waypoint Structure

Each waypoint should be a keyword list or map with:

  • position - Position (radians or metres)
  • velocity - Velocity (rad/s or m/s)
  • acceleration - Acceleration (rad/s² or m/s²)
  • time_from_start - Time from trajectory start (milliseconds)

Options

  • :repeat - Number of repetitions: positive integer or :forever (default 1)
  • :command_id - Correlation ID for feedback tracking

follow_trajectory!(robot, target, waypoints, opts \\ [])

@spec follow_trajectory!(module(), target(), [keyword() | map()], keyword()) :: :ok

Send a trajectory command directly to an actuator (bypasses pubsub).

follow_trajectory_sync(robot, target, waypoints, opts \\ [], timeout \\ 5000)

@spec follow_trajectory_sync(
  module(),
  target(),
  [keyword() | map()],
  keyword(),
  timeout()
) ::
  {:ok, :accepted | {:accepted, map()}} | {:error, term()}

Send a trajectory command and wait for acknowledgement.

hold(robot, target, opts \\ [])

@spec hold(module(), target(), keyword()) :: :ok

Send a hold command via pubsub.

Instructs the actuator to actively maintain its current position.

Options

  • :command_id - Correlation ID for feedback tracking

hold!(robot, target, opts \\ [])

@spec hold!(module(), target(), keyword()) :: :ok

Send a hold command directly to an actuator (bypasses pubsub).

hold_sync(robot, target, opts \\ [], timeout \\ 5000)

@spec hold_sync(module(), target(), keyword(), timeout()) ::
  {:ok, :accepted | {:accepted, map()}} | {:error, term()}

Send a hold command and wait for acknowledgement.

publish_begin_motion(robot, path, opts)

@spec publish_begin_motion(module(), [atom()], keyword()) :: :ok

Publish a BeginMotion message for the actuator at path, converting the supplied motor-space values into joint-space before publishing.

The driver builds the message in motor-space (the only coordinate space it knows about); this helper looks up the joint above the actuator, resolves its transmission against the current parameter store, applies BB.Transmission.unapply_to_payload/2, and publishes the joint-space message to [:actuator | path].

path is the actuator's full path (i.e. the :bb.path injected into driver opts). opts is the keyword list accepted by BB.Message.Actuator.BeginMotion's schema, with :initial_position, :target_position, :peak_velocity, and :acceleration in motor-space.

set_effort(robot, target, effort, opts \\ [])

@spec set_effort(module(), target(), number(), keyword()) :: :ok

Send an effort (torque/force) command via pubsub.

Options

  • :duration - Duration (milliseconds), nil = until stopped
  • :command_id - Correlation ID for feedback tracking

set_effort!(robot, target, effort, opts \\ [])

@spec set_effort!(module(), target(), number(), keyword()) :: :ok

Send an effort command directly to an actuator (bypasses pubsub).

set_effort_sync(robot, target, effort, opts \\ [], timeout \\ 5000)

@spec set_effort_sync(module(), target(), number(), keyword(), timeout()) ::
  {:ok, :accepted | {:accepted, map()}} | {:error, term()}

Send an effort command and wait for acknowledgement.

set_position(robot, target, position, opts \\ [])

@spec set_position(module(), target(), number(), keyword()) :: :ok | {:error, term()}

Send a position command.

Under the default delivery: :pubsub the command is published to [:actuator | path] for whoever is watching the topic, and delivered to the actuator itself by a call, so the caller learns whether the joint is actually moving. An actuator refuses a command it doesn't accept, or any command at all while the robot is disarmed.

The publication records that a command was issued; the return value says whether it was accepted. A refused command still appears on the topic.

Runs in the caller's process, so a driver must not call this from inside its own handle_command/2 - it would be waiting on itself.

Options

  • :delivery - :pubsub (default) publishes the command and waits for the actuator to accept it; :direct casts to the actuator and returns at once, publishing nothing. Use :direct for control paths where the round trip costs more than knowing the outcome is worth
  • :velocity - Velocity hint (rad/s or m/s)
  • :duration - Duration hint (milliseconds)
  • :command_id - Correlation ID for feedback tracking
  • :timeout - How long to wait for the actuator, in milliseconds (default 5000). Unused under :direct, which waits for nothing

Returns

  • :ok - Command accepted
  • {:error, reason} - Command refused, reason being a BB.Error struct

Under delivery: :pubsub, exits if the actuator isn't running or doesn't answer within :timeout, like any other GenServer.call/3.

delivery: :direct always returns :ok

A cast has nowhere to put an answer, so :direct returns :ok whether the actuator accepted the command or refused it. The refusal reaches the log and a [:bb, :actuator, :rejected] telemetry event, and nowhere else — matching on {:error, reason} there is a branch that can never run.

Commanding several joints

This is an ordinary blocking call, so several joints cost several round trips and how they overlap is yours to choose:

[shoulder: 1.57, elbow: 0.5]
|> Enum.map(fn {joint, position} ->
  Task.async(fn -> BB.Actuator.set_position(MyRobot, joint, position) end)
end)
|> Task.await_many()

Three things to know before reaching for that:

  • Task.async/1 links. From a long-lived process - a controller loop - use Task.Supervisor.async_nolink/3 instead, or an actuator that has died takes the caller down with it.
  • Task.await_many/2 applies one deadline to the whole set and kills the stragglers when it expires. It isn't "collect as they land".
  • Each command publishes from inside its own task, so observers see the commands interleaved rather than in the order you listed them.

BB.Motion.send_positions/3 already does this for the joints of one motion.

Examples

:ok = BB.Actuator.set_position(MyRobot, [:base_link, :shoulder, :servo], 1.57)

case BB.Actuator.set_position(MyRobot, :servo, 1.57, velocity: 0.5) do
  :ok -> :moving
  {:error, error} -> Logger.error(Exception.message(error))
end

set_velocity(robot, target, velocity, opts \\ [])

@spec set_velocity(module(), target(), number(), keyword()) :: :ok

Send a velocity command via pubsub.

Options

  • :duration - Duration (milliseconds), nil = until stopped
  • :command_id - Correlation ID for feedback tracking

set_velocity!(robot, target, velocity, opts \\ [])

@spec set_velocity!(module(), target(), number(), keyword()) :: :ok

Send a velocity command directly to an actuator (bypasses pubsub).

set_velocity_sync(robot, target, velocity, opts \\ [], timeout \\ 5000)

@spec set_velocity_sync(module(), target(), number(), keyword(), timeout()) ::
  {:ok, :accepted | {:accepted, map()}} | {:error, term()}

Send a velocity command and wait for acknowledgement.

stop(robot, target, opts \\ [])

@spec stop(module(), target(), keyword()) :: :ok

Send a stop command via pubsub.

Options

  • :mode - :immediate (default) or :decelerate
  • :command_id - Correlation ID for feedback tracking

stop!(robot, target, opts \\ [])

@spec stop!(module(), target(), keyword()) :: :ok

Send a stop command directly to an actuator (bypasses pubsub).

stop_sync(robot, target, opts \\ [], timeout \\ 5000)

@spec stop_sync(module(), target(), keyword(), timeout()) ::
  {:ok, :accepted | {:accepted, map()}} | {:error, term()}

Send a stop command and wait for acknowledgement.

to_joint_space(robot, actuator_path, motor_message)

@spec to_joint_space(module(), [atom()], BB.Message.t()) :: BB.Message.t()

Translate a motor-space outbound message into joint-space using the transmission of the joint above the actuator at actuator_path.

Convenient for callers that build a message in motor-space and then publish it on a topic of their own choosing — e.g. a controller publishing JointState on a sensor topic. Performs a fresh transmission resolution against the current parameter store on every call, so it stays correct across runtime parameter changes without the caller needing to subscribe.

Returns the message unchanged when the joint has no transmission.