Current section
Files
Jump to
Current section
Files
lib/bb/actuator.ex
# SPDX-FileCopyrightText: 2025 James Harton## SPDX-License-Identifier: Apache-2.0defmodule BB.Actuator do @moduledoc """ 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 - `disarm/1` - Make hardware safe (called without GenServer state) ### Optional Callbacks - `handle_options/2` - React to parameter changes at runtime - `handle_call/3`, `handle_cast/2`, `handle_info/2` - Standard GenServer-style callbacks - `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_cast({:command, msg}, state) do # Handle actuator commands {: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 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 Supports both pubsub delivery (for orchestration, logging, replay) and direct GenServer delivery (for time-critical control paths). ### Delivery Methods - **Pubsub** (`set_position/4`, etc.) - Commands published to `[:actuator | path]`. Enables logging, replay, and multi-subscriber patterns. Actuators receive commands via `handle_info/2`. - **Direct** (`set_position!/4`, etc.) - Commands sent directly via `BB.Process.cast`. Lower latency for time-critical control. Actuators receive via `handle_cast/2`. - **Synchronous** (`set_position_sync/5`, etc.) - Commands sent via `BB.Process.call`. Returns acknowledgement or error. Actuators respond via `handle_call/3`. ### Examples # Pubsub delivery (for kinematics/orchestration) BB.Actuator.set_position(MyRobot, [:base_link, :shoulder, :servo], 1.57) # Direct delivery (for time-critical control) BB.Actuator.set_position!(MyRobot, :shoulder_servo, 1.57) # Synchronous with acknowledgement {:ok, :accepted} = BB.Actuator.set_position_sync(MyRobot, :shoulder_servo, 1.57) """ # ---------------------------------------------------------------------------- # Behaviour # ---------------------------------------------------------------------------- @doc """ 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. """ @callback init(opts :: keyword()) :: {:ok, state :: term()} | {:ok, state :: term(), timeout() | :hibernate | {:continue, term()}} | {:stop, reason :: term()} | :ignore @doc """ 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. """ @callback disarm(opts :: keyword()) :: :ok | {:error, term()} @doc """ 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. """ @callback handle_options(new_opts :: keyword(), state :: term()) :: {:ok, new_state :: term()} | {:stop, reason :: term()} @doc """ Handle synchronous calls. Same semantics as `c:GenServer.handle_call/3`. """ @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()} @doc """ Handle asynchronous casts. Same semantics as `c:GenServer.handle_cast/2`. """ @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()} @doc """ Handle all other messages. Same semantics as `c:GenServer.handle_info/2`. """ @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()} @doc """ Handle continue instructions. Same semantics as `c:GenServer.handle_continue/2`. """ @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()} @doc """ Clean up before termination. Same semantics as `c:GenServer.terminate/2`. """ @callback terminate(reason :: term(), state :: term()) :: term() @doc """ 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). """ @callback options_schema() :: Spark.Options.t() @optional_callbacks [ options_schema: 0, handle_options: 2, handle_call: 3, handle_cast: 2, handle_info: 2, handle_continue: 2, terminate: 2 ] @doc false defmacro __using__(opts) do schema_opts = opts[:options_schema] quote do @behaviour BB.Actuator # Default implementations - all overridable @impl BB.Actuator def handle_options(_new_opts, state), do: {:ok, state} @impl BB.Actuator def handle_call(_request, _from, state), do: {:reply, {:error, :not_implemented}, state} @impl BB.Actuator def handle_cast(_request, state), do: {:noreply, state} @impl BB.Actuator def handle_info(_msg, state), do: {:noreply, state} @impl BB.Actuator def handle_continue(_continue_arg, state), do: {:noreply, state} @impl BB.Actuator def terminate(_reason, _state), do: :ok defoverridable handle_options: 2, handle_call: 3, handle_cast: 2, handle_info: 2, handle_continue: 2, terminate: 2 unquote( if schema_opts do quote do @__bb_options_schema Spark.Options.new!(unquote(schema_opts)) @impl BB.Actuator def options_schema, do: @__bb_options_schema defoverridable options_schema: 0 end end ) end end # ---------------------------------------------------------------------------- # API # ---------------------------------------------------------------------------- alias BB.Message alias BB.Message.Actuator.Command # ---------------------------------------------------------------------------- # Position Commands # ---------------------------------------------------------------------------- @doc """ Send a position command via pubsub. The command is published to `[:actuator | path]` where subscribers can receive it via `handle_info({:bb, path, message}, state)`. ## Options - `:velocity` - Velocity hint (rad/s or m/s) - `:duration` - Duration hint (milliseconds) - `:command_id` - Correlation ID for feedback tracking ## Examples BB.Actuator.set_position(MyRobot, [:base_link, :shoulder, :servo], 1.57) BB.Actuator.set_position(MyRobot, [:shoulder, :servo], 1.57, velocity: 0.5) """ @spec set_position(module(), [atom()], number(), keyword()) :: :ok def set_position(robot, path, position, opts \\ []) do message = build_position_message(path, position, opts) BB.publish(robot, [:actuator | path], message) end @doc """ Send a position command directly to an actuator (bypasses pubsub). Uses `BB.Process.cast` for fire-and-forget delivery. The actuator receives the command via `handle_cast({:command, message}, state)`. ## Options Same as `set_position/4`. """ @spec set_position!(module(), atom(), number(), keyword()) :: :ok def set_position!(robot, actuator_name, position, opts \\ []) do message = build_position_message(actuator_name, position, opts) BB.cast(robot, actuator_name, {:command, message}) end @doc """ Send a position command and wait for acknowledgement. Uses `BB.Process.call` for synchronous delivery. Returns the actuator's response or raises on timeout. ## Options Same as `set_position/4`, plus: - Fifth argument is timeout in milliseconds (default 5000) ## Returns - `{:ok, :accepted}` - Command accepted - `{:ok, :accepted, map()}` - Command accepted with additional info - `{:error, reason}` - Command rejected """ @spec set_position_sync(module(), atom(), number(), keyword(), timeout()) :: {:ok, :accepted | {:accepted, map()}} | {:error, term()} def set_position_sync(robot, actuator_name, position, opts \\ [], timeout \\ 5000) do message = build_position_message(actuator_name, position, opts) BB.call(robot, actuator_name, {:command, message}, timeout) end defp build_position_message(frame_id, position, opts) do frame_id = if is_list(frame_id), do: List.last(frame_id), else: frame_id Message.new!(Command.Position, frame_id, position: position * 1.0, velocity: opts[:velocity], duration: opts[:duration], command_id: opts[:command_id] ) end # ---------------------------------------------------------------------------- # Velocity Commands # ---------------------------------------------------------------------------- @doc """ Send a velocity command via pubsub. ## Options - `:duration` - Duration (milliseconds), nil = until stopped - `:command_id` - Correlation ID for feedback tracking """ @spec set_velocity(module(), [atom()], number(), keyword()) :: :ok def set_velocity(robot, path, velocity, opts \\ []) do message = build_velocity_message(path, velocity, opts) BB.publish(robot, [:actuator | path], message) end @doc """ Send a velocity command directly to an actuator (bypasses pubsub). """ @spec set_velocity!(module(), atom(), number(), keyword()) :: :ok def set_velocity!(robot, actuator_name, velocity, opts \\ []) do message = build_velocity_message(actuator_name, velocity, opts) BB.cast(robot, actuator_name, {:command, message}) end @doc """ Send a velocity command and wait for acknowledgement. """ @spec set_velocity_sync(module(), atom(), number(), keyword(), timeout()) :: {:ok, :accepted | {:accepted, map()}} | {:error, term()} def set_velocity_sync(robot, actuator_name, velocity, opts \\ [], timeout \\ 5000) do message = build_velocity_message(actuator_name, velocity, opts) BB.call(robot, actuator_name, {:command, message}, timeout) end defp build_velocity_message(frame_id, velocity, opts) do frame_id = if is_list(frame_id), do: List.last(frame_id), else: frame_id Message.new!(Command.Velocity, frame_id, velocity: velocity * 1.0, duration: opts[:duration], command_id: opts[:command_id] ) end # ---------------------------------------------------------------------------- # Effort Commands # ---------------------------------------------------------------------------- @doc """ Send an effort (torque/force) command via pubsub. ## Options - `:duration` - Duration (milliseconds), nil = until stopped - `:command_id` - Correlation ID for feedback tracking """ @spec set_effort(module(), [atom()], number(), keyword()) :: :ok def set_effort(robot, path, effort, opts \\ []) do message = build_effort_message(path, effort, opts) BB.publish(robot, [:actuator | path], message) end @doc """ Send an effort command directly to an actuator (bypasses pubsub). """ @spec set_effort!(module(), atom(), number(), keyword()) :: :ok def set_effort!(robot, actuator_name, effort, opts \\ []) do message = build_effort_message(actuator_name, effort, opts) BB.cast(robot, actuator_name, {:command, message}) end @doc """ Send an effort command and wait for acknowledgement. """ @spec set_effort_sync(module(), atom(), number(), keyword(), timeout()) :: {:ok, :accepted | {:accepted, map()}} | {:error, term()} def set_effort_sync(robot, actuator_name, effort, opts \\ [], timeout \\ 5000) do message = build_effort_message(actuator_name, effort, opts) BB.call(robot, actuator_name, {:command, message}, timeout) end defp build_effort_message(frame_id, effort, opts) do frame_id = if is_list(frame_id), do: List.last(frame_id), else: frame_id Message.new!(Command.Effort, frame_id, effort: effort * 1.0, duration: opts[:duration], command_id: opts[:command_id] ) end # ---------------------------------------------------------------------------- # Trajectory Commands # ---------------------------------------------------------------------------- @doc """ 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 """ @spec follow_trajectory(module(), [atom()], [keyword() | map()], keyword()) :: :ok def follow_trajectory(robot, path, waypoints, opts \\ []) do message = build_trajectory_message(path, waypoints, opts) BB.publish(robot, [:actuator | path], message) end @doc """ Send a trajectory command directly to an actuator (bypasses pubsub). """ @spec follow_trajectory!(module(), atom(), [keyword() | map()], keyword()) :: :ok def follow_trajectory!(robot, actuator_name, waypoints, opts \\ []) do message = build_trajectory_message(actuator_name, waypoints, opts) BB.cast(robot, actuator_name, {:command, message}) end @doc """ Send a trajectory command and wait for acknowledgement. """ @spec follow_trajectory_sync(module(), atom(), [keyword() | map()], keyword(), timeout()) :: {:ok, :accepted | {:accepted, map()}} | {:error, term()} def follow_trajectory_sync(robot, actuator_name, waypoints, opts \\ [], timeout \\ 5000) do message = build_trajectory_message(actuator_name, waypoints, opts) BB.call(robot, actuator_name, {:command, message}, timeout) end defp build_trajectory_message(frame_id, waypoints, opts) do frame_id = if is_list(frame_id), do: List.last(frame_id), else: frame_id normalised_waypoints = Enum.map(waypoints, fn wp -> wp = if is_map(wp), do: Keyword.new(wp), else: wp [ position: wp[:position] * 1.0, velocity: wp[:velocity] * 1.0, acceleration: wp[:acceleration] * 1.0, time_from_start: wp[:time_from_start] ] end) Message.new!(Command.Trajectory, frame_id, waypoints: normalised_waypoints, repeat: opts[:repeat] || 1, command_id: opts[:command_id] ) end # ---------------------------------------------------------------------------- # Stop Commands # ---------------------------------------------------------------------------- @doc """ Send a stop command via pubsub. ## Options - `:mode` - `:immediate` (default) or `:decelerate` - `:command_id` - Correlation ID for feedback tracking """ @spec stop(module(), [atom()], keyword()) :: :ok def stop(robot, path, opts \\ []) do message = build_stop_message(path, opts) BB.publish(robot, [:actuator | path], message) end @doc """ Send a stop command directly to an actuator (bypasses pubsub). """ @spec stop!(module(), atom(), keyword()) :: :ok def stop!(robot, actuator_name, opts \\ []) do message = build_stop_message(actuator_name, opts) BB.cast(robot, actuator_name, {:command, message}) end @doc """ Send a stop command and wait for acknowledgement. """ @spec stop_sync(module(), atom(), keyword(), timeout()) :: {:ok, :accepted | {:accepted, map()}} | {:error, term()} def stop_sync(robot, actuator_name, opts \\ [], timeout \\ 5000) do message = build_stop_message(actuator_name, opts) BB.call(robot, actuator_name, {:command, message}, timeout) end defp build_stop_message(frame_id, opts) do frame_id = if is_list(frame_id), do: List.last(frame_id), else: frame_id Message.new!(Command.Stop, frame_id, mode: opts[:mode] || :immediate, command_id: opts[:command_id] ) end # ---------------------------------------------------------------------------- # Hold Commands # ---------------------------------------------------------------------------- @doc """ Send a hold command via pubsub. Instructs the actuator to actively maintain its current position. ## Options - `:command_id` - Correlation ID for feedback tracking """ @spec hold(module(), [atom()], keyword()) :: :ok def hold(robot, path, opts \\ []) do message = build_hold_message(path, opts) BB.publish(robot, [:actuator | path], message) end @doc """ Send a hold command directly to an actuator (bypasses pubsub). """ @spec hold!(module(), atom(), keyword()) :: :ok def hold!(robot, actuator_name, opts \\ []) do message = build_hold_message(actuator_name, opts) BB.cast(robot, actuator_name, {:command, message}) end @doc """ Send a hold command and wait for acknowledgement. """ @spec hold_sync(module(), atom(), keyword(), timeout()) :: {:ok, :accepted | {:accepted, map()}} | {:error, term()} def hold_sync(robot, actuator_name, opts \\ [], timeout \\ 5000) do message = build_hold_message(actuator_name, opts) BB.call(robot, actuator_name, {:command, message}, timeout) end defp build_hold_message(frame_id, opts) do frame_id = if is_list(frame_id), do: List.last(frame_id), else: frame_id Message.new!(Command.Hold, frame_id, command_id: opts[:command_id]) endend