lib/bb/mcp/resources/robot_topology.ex

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

defmodule BB.MCP.Resources.RobotTopology do
  @moduledoc """
  Static topology of a robot — links, joints, sensors, and actuators.

  Read via `bb://robots/{robot}/topology`. The contents do not change
  after the robot is compiled.
  """

  use Anubis.Server.Component,
    type: :resource,
    uri_template: "bb://robots/{robot}/topology",
    name: "robot_topology"

  alias Anubis.Server.Response
  alias BB.MCP.Resources
  alias BB.Robot
  alias BB.Robot.Runtime

  @impl true
  def description, do: "Links, joints, sensors, and actuators that make up the robot"

  @impl true
  def mime_type, do: "application/json"

  @impl true
  def read(params, frame) do
    case Resources.fetch_robot(params) do
      {:ok, module} ->
        robot = Runtime.get_robot(module)

        payload = %{
          "name" => to_string(robot.name),
          "root_link" => to_string(robot.root_link),
          "links" => robot.links |> Map.keys() |> Enum.map(&to_string/1) |> Enum.sort(),
          "joints" => Enum.map(Robot.joints_in_order(robot), &format_joint(robot, &1)),
          "sensors" => robot.sensors |> Map.values() |> Enum.map(&format_sensor/1),
          "actuators" => robot.actuators |> Map.values() |> Enum.map(&format_actuator/1)
        }

        {:reply, Response.json(Response.resource(), payload), frame}

      {:error, error} ->
        {:error, error, frame}
    end
  end

  defp format_joint(_robot, joint) do
    %{
      "name" => to_string(joint.name),
      "type" => to_string(joint.type),
      "parent_link" => to_string(joint.parent_link),
      "child_link" => to_string(joint.child_link)
    }
    |> maybe_put_limits(joint)
  end

  defp maybe_put_limits(map, %{limit: nil}), do: map

  defp maybe_put_limits(map, %{limit: limit}) when is_map(limit) do
    Map.put(map, "limit", %{
      "lower" => Map.get(limit, :lower),
      "upper" => Map.get(limit, :upper),
      "velocity" => Map.get(limit, :velocity),
      "effort" => Map.get(limit, :effort)
    })
  end

  defp maybe_put_limits(map, _), do: map

  defp format_sensor(%{name: name, attached_to: attached_to}) do
    %{"name" => to_string(name), "attached_to" => format_attached_to(attached_to)}
  end

  defp format_actuator(%{name: name, joint: joint}) do
    %{"name" => to_string(name), "joint" => to_string(joint)}
  end

  defp format_attached_to({:link, name}), do: %{"link" => to_string(name)}
  defp format_attached_to({:joint, name}), do: %{"joint" => to_string(name)}
  defp format_attached_to(:robot), do: %{"robot" => true}
  defp format_attached_to(other), do: inspect(other)
end