Skip to main content

lib/bb/jido/plugin/robot.ex

# SPDX-FileCopyrightText: 2026 James Harton
#
# SPDX-License-Identifier: Apache-2.0

defmodule BB.Jido.Plugin.Robot do
  @moduledoc """
  Jido v2 plugin that gives an agent the ability to control a Beam Bots robot.

  Provides:

  - The standard robot-control actions: `BB.Jido.Action.Command`,
    `BB.Jido.Action.Reactor`, `BB.Jido.Action.WaitForState`, and
    `BB.Jido.Action.GetJointState`.
  - Default signal routes for the canonical `bb.*` signal types.
  - Plugin-owned state: the configured `:robot` module and a cached
    `:safety_state` (updated automatically when `bb.state.transition`
    signals arrive).
  - A supervised `BB.Jido.PubSubBridge` mounted under the agent process that
    forwards BB PubSub events to the agent as Jido signals.

  ## Configuration

  Plugin config (passed via `{BB.Jido.Plugin.Robot, %{...}}` when attaching
  to an agent):

  - `:robot` — robot module (required).
  - `:topics` — list of `BB.PubSub` paths to bridge (default
    `[[:state_machine]]`).
  - `:message_types` — payload modules to filter on at subscribe time
    (default `[]`, meaning no filter).
  - `:throttle_ms` — optional per-signal-type throttle in milliseconds.

  ## Example

      defmodule MyRobot.Agent do
        use Jido.Agent,
          name: "my_robot",
          plugins: [{BB.Jido.Plugin.Robot, %{robot: MyRobot}}]
      end
  """

  # Plugin name is `bb` so that Jido v2's plugin-route prefixing yields
  # the canonical user-facing signal types: a route declared here as
  # `command.execute` ends up as `bb.command.execute` after prefixing.
  use Jido.Plugin,
    name: "bb",
    state_key: :robot,
    actions: [
      BB.Jido.Action.Command,
      BB.Jido.Action.GetJointState,
      BB.Jido.Action.Reactor,
      BB.Jido.Action.WaitForState
    ],
    signal_routes: [
      {"command.execute", BB.Jido.Action.Command},
      {"reactor.run", BB.Jido.Action.Reactor},
      {"state.wait", BB.Jido.Action.WaitForState}
    ]

  alias BB.Jido.PubSubBridge

  @impl Jido.Plugin
  def mount(_agent, config) do
    case Map.fetch(config, :robot) do
      {:ok, robot} when is_atom(robot) ->
        {:ok,
         %{
           robot: robot,
           safety_state: :unknown,
           last_joint_state: %{}
         }}

      _ ->
        {:error, "BB.Jido.Plugin.Robot requires :robot module in config"}
    end
  end

  @impl Jido.Plugin
  def child_spec(config) do
    agent_pid = self()
    robot = Map.fetch!(config, :robot)
    topics = Map.get(config, :topics, default_topics())
    message_types = Map.get(config, :message_types, [])
    throttle_ms = Map.get(config, :throttle_ms)

    bridge_opts =
      [
        robot: robot,
        agent: agent_pid,
        topics: topics,
        message_types: message_types
      ]
      |> maybe_put(:throttle_ms, throttle_ms)

    %{
      id: {__MODULE__, :pub_sub_bridge, robot},
      start: {PubSubBridge, :start_link, [bridge_opts]},
      restart: :transient,
      type: :worker
    }
  end

  @impl Jido.Plugin
  def handle_signal(%Jido.Signal{type: "bb.state.transition"} = signal, context) do
    case signal.data do
      %{message: %BB.Message{payload: %BB.StateMachine.Transition{to: to}}} ->
        update_safety_state(context, to)
        {:ok, :continue}

      _ ->
        {:ok, :continue}
    end
  end

  def handle_signal(_signal, _context), do: {:ok, :continue}

  defp update_safety_state(%{agent_ref: ref}, state) when not is_nil(ref) do
    send(ref, {:bb_jido, :safety_state, state})
    :ok
  end

  defp update_safety_state(_context, _state), do: :ok

  defp default_topics, do: [[:state_machine]]

  defp maybe_put(opts, _key, nil), do: opts
  defp maybe_put(opts, key, value), do: Keyword.put(opts, key, value)
end