Skip to main content

lib/drone/mission.ex

defmodule Drone.Mission do
  @moduledoc """
  Mission DSL for scripting drone command sequences.

  A mission is an ordered list of commands that can be validated and
  replayed against a drone or simulator.

  ## Example

      mission =
        Drone.Mission.new()
        |> Drone.Mission.sdk_mode()
        |> Drone.Mission.takeoff()
        |> Drone.Mission.hover(seconds: 3)
        |> Drone.Mission.move(:up, 40)
        |> Drone.Mission.move(:forward, 100)
        |> Drone.Mission.rotate(:cw, 90)
        |> Drone.Mission.land()

      Drone.Mission.run(mission, drone)
  """

  alias Drone.{Command, Vehicle}

  @type t :: %__MODULE__{
          commands: [Command.t()],
          name: String.t() | nil
        }

  defstruct commands: [], name: nil

  @doc """
  Creates a new empty mission.
  """
  @spec new(keyword()) :: t()
  def new(opts \\ []) do
    %__MODULE__{name: Keyword.get(opts, :name)}
  end

  @doc """
  Adds an SDK mode activation command to the mission.
  """
  @spec sdk_mode(t()) :: t()
  def sdk_mode(%__MODULE__{commands: commands} = mission) do
    %{mission | commands: [Command.sdk_mode() | commands]}
  end

  @doc """
  Adds a takeoff command to the mission.
  """
  @spec takeoff(t()) :: t()
  def takeoff(%__MODULE__{commands: commands} = mission) do
    %{mission | commands: [Command.takeoff() | commands]}
  end

  @doc """
  Adds a land command to the mission.
  """
  @spec land(t()) :: t()
  def land(%__MODULE__{commands: commands} = mission) do
    %{mission | commands: [Command.land() | commands]}
  end

  @doc """
  Adds an emergency stop command to the mission.
  """
  @spec emergency(t()) :: t()
  def emergency(%__MODULE__{commands: commands} = mission) do
    %{mission | commands: [Command.emergency() | commands]}
  end

  @doc """
  Adds a movement command to the mission.
  """
  @spec move(t(), Command.direction(), pos_integer()) :: t()
  def move(%__MODULE__{commands: commands} = mission, direction, distance) do
    %{mission | commands: [Command.move(direction, distance) | commands]}
  end

  @doc """
  Adds a rotation command to the mission.
  """
  @spec rotate(t(), Command.rotation(), pos_integer()) :: t()
  def rotate(%__MODULE__{commands: commands} = mission, direction, degrees) do
    %{mission | commands: [Command.rotate(direction, degrees) | commands]}
  end

  @doc """
  Adds a flip command to the mission.
  """
  @spec flip(t(), Command.flip_direction()) :: t()
  def flip(%__MODULE__{commands: commands} = mission, direction) do
    %{mission | commands: [Command.flip(direction) | commands]}
  end

  @doc """
  Adds a hover command to the mission.
  """
  @spec hover(t(), keyword()) :: t()
  def hover(%__MODULE__{commands: commands} = mission, opts \\ []) do
    seconds = Keyword.get(opts, :seconds, 1)
    %{mission | commands: [Command.hover(seconds) | commands]}
  end

  @doc """
  Adds a speed setting command to the mission.
  """
  @spec speed(t(), pos_integer()) :: t()
  def speed(%__MODULE__{commands: commands} = mission, speed) do
    %{mission | commands: [Command.speed(speed) | commands]}
  end

  @doc """
  Adds a stop command to the mission.
  """
  @spec stop(t()) :: t()
  def stop(%__MODULE__{commands: commands} = mission) do
    %{mission | commands: [Command.stop() | commands]}
  end

  @doc """
  Adds a query command to the mission.
  """
  @spec query(t(), Command.query_type()) :: t()
  def query(%__MODULE__{commands: commands} = mission, type) do
    %{mission | commands: [Command.query(type) | commands]}
  end

  @doc """
  Returns the list of commands in the mission in execution order.
  """
  @spec commands(t()) :: [Command.t()]
  def commands(%__MODULE__{commands: commands}), do: Enum.reverse(commands)

  @doc """
  Returns the number of commands in the mission.
  """
  @spec length(t()) :: non_neg_integer()
  def length(%__MODULE__{commands: commands}), do: Kernel.length(commands)

  @doc """
  Runs a mission against a drone process.

  Each command is sent sequentially. If any command fails, the mission
  stops and returns the error.

  Returns `{:ok, results}` with a list of results for each command,
  or `{:error, command, reason}` with the failing command and error.
  """
  @spec run(t(), atom()) :: {:ok, [term()]} | {:error, Command.t(), term()}
  def run(%__MODULE__{commands: commands}, drone_name) do
    pid = Vehicle.whereis(drone_name)

    if pid do
      run_commands(Enum.reverse(commands), pid, [])
    else
      {:error, Command.sdk_mode(), {:no_process, drone_name}}
    end
  end

  defp run_commands([], _pid, results), do: {:ok, Enum.reverse(results)}

  defp run_commands([cmd | rest], pid, results) do
    case GenServer.call(pid, {:command, cmd}) do
      {:ok, reply} ->
        run_commands(rest, pid, [reply | results])

      {:error, :safety, reason} ->
        {:error, cmd, {:safety, reason}}

      {:error, reason} ->
        {:error, cmd, reason}
    end
  end
end