Skip to main content

lib/drone.ex

defmodule Drone do
  @moduledoc """
  BEAM-native drone control for Elixir.

  ex_drone provides a supervised, safety-first API for controlling programmable
  drones. It supports pluggable adapters (simulator, Tello, and more in the
  future), a safety pipeline that validates every command, and telemetry events
  for observability.

  ## Getting Started

      # Connect to the simulator (no hardware needed)
      {:ok, drone} = Drone.connect(:sim, name: :test)

      # Enter SDK mode (required for Tello, automatic for sim)
      Drone.connect_sdk(drone)

      # Fly
      Drone.takeoff(drone)
      Drone.move(drone, :up, 40)
      Drone.move(drone, :forward, 100)
      Drone.rotate(drone, :cw, 90)
      Drone.land(drone)

      # Disconnect
      Drone.disconnect(drone)

  ## Safety

  All commands pass through a safety pipeline before reaching the drone.
  Safety policies can be configured at connection time:

      {:ok, drone} = Drone.connect(:sim,
        name: :classroom,
        safety: [indoor: true, prop_guards: true]
      )

  See `Drone.Safety.Policy` for all safety options.

  **Safety warning**: Drones are physical devices that can cause injury.
  Always test in the simulator first. Use prop guards. Do not fly near
  faces. Have an emergency stop ready. Understand local laws and regulations.
  """

  alias Drone.{Command, Vehicle}

  @type drone :: atom()
  @type connect_result :: {:ok, atom()} | {:error, term()}

  @doc """
  Connects to a drone and starts a supervised process.

  Accepts an adapter identifier (`:sim` or `:tello`) or a module that
  implements `Drone.Adapter`. Options are passed to the adapter and
  safety policy.

  ## Options

    - `:name` (required) -- a unique name for this drone process
    - `:safety` -- keyword list of safety policy options (see `Drone.Safety.Policy.new/1`)
    - All other options are passed to the adapter

  ## Examples

      {:ok, drone} = Drone.connect(:sim, name: :test)
      {:ok, drone} = Drone.connect(:tello, name: :tello_1, drone_ip: {192, 168, 10, 1})
  """
  @spec connect(atom() | module(), keyword()) :: connect_result()
  def connect(adapter, opts) when is_atom(adapter) and is_list(opts) do
    name = Keyword.fetch!(opts, :name)
    opts = Keyword.put(opts, :adapter, adapter)

    case Drone.Supervisor.start_vehicle(opts) do
      {:ok, _pid} -> {:ok, name}
      {:error, {:already_started, _pid}} -> {:error, :name_already_taken}
      {:error, reason} -> {:error, reason}
    end
  end

  @doc """
  Sends the SDK mode activation command.

  Required for Tello drones before any other command. The simulator
  enters SDK mode automatically on connect.
  """
  @spec connect_sdk(drone()) :: :ok | {:error, term()}
  def connect_sdk(drone) do
    command(drone, Command.sdk_mode())
  end

  @doc """
  Sends a takeoff command.

  The drone must be in SDK mode and not already flying. Safety checks
  are applied (battery, altitude, geofence, etc.).
  """
  @spec takeoff(drone()) :: :ok | {:error, term()}
  def takeoff(drone) do
    command(drone, Command.takeoff())
  end

  @doc """
  Sends a land command.

  The drone must be flying.
  """
  @spec land(drone()) :: :ok | {:error, term()}
  def land(drone) do
    command(drone, Command.land())
  end

  @doc """
  Sends an emergency stop command.

  This command bypasses all safety checks and immediately stops the
  drone's motors. Use only in actual emergencies.
  """
  @spec emergency(drone()) :: :ok | {:error, term()}
  def emergency(drone) do
    call(drone, :emergency)
  end

  @doc """
  Sends a movement command.

  Direction must be one of: `:up`, `:down`, `:left`, `:right`,
  `:forward`, `:back`.
  Distance must be between 20 and 500 cm.
  """
  @spec move(drone(), Command.direction(), pos_integer()) :: :ok | {:error, term()}
  def move(drone, direction, distance) do
    command(drone, Command.move(direction, distance))
  end

  @doc """
  Sends a rotation command.

  Direction must be `:cw` (clockwise) or `:ccw` (counter-clockwise).
  Degrees must be between 1 and 3600.
  """
  @spec rotate(drone(), Command.rotation(), pos_integer()) :: :ok | {:error, term()}
  def rotate(drone, direction, degrees) do
    command(drone, Command.rotate(direction, degrees))
  end

  @doc """
  Sends a flip command.

  Direction must be one of: `:left`, `:right`, `:forward`, `:back`.
  The drone must be flying. A safety warning is emitted if prop guards
  are not installed.
  """
  @spec flip(drone(), Command.flip_direction()) :: :ok | {:error, term()}
  def flip(drone, direction) do
    command(drone, Command.flip(direction))
  end

  @doc """
  Sends a hover command.

  The drone will hover in place for the specified number of seconds.
  """
  @spec hover(drone(), keyword()) :: :ok | {:error, term()}
  def hover(drone, opts \\ []) do
    seconds = Keyword.get(opts, :seconds, 1)
    command(drone, Command.hover(seconds))
  end

  @doc """
  Sets the drone speed.

  Speed must be between 10 and 100 cm/s.
  """
  @spec set_speed(drone(), pos_integer()) :: :ok | {:error, term()}
  def set_speed(drone, speed) do
    command(drone, Command.speed(speed))
  end

  @doc """
  Sends a stop command (hover in place).
  """
  @spec stop(drone()) :: :ok | {:error, term()}
  def stop(drone) do
    command(drone, Command.stop())
  end

  @doc """
  Sends a query command to the drone.

  Query type must be one of: `:battery`, `:height`, `:speed`,
  `:time`, `:wifi`, `:sdk_version`, `:serial_number`.

  Returns `{:ok, value}` where value depends on the query type.
  """
  @spec query(drone(), Command.query_type()) :: {:ok, term()} | {:error, term()}
  def query(drone, type) do
    call(drone, {:command, Command.query(type)})
  end

  @doc """
  Retrieves telemetry data from the drone.

  Returns a map with position, battery, and state information.
  """
  @spec telemetry(drone()) :: {:ok, map()} | {:error, term()}
  def telemetry(drone) do
    call(drone, :telemetry)
  end

  @doc """
  Disconnects from the drone and stops the process.
  """
  @spec disconnect(drone()) :: :ok | {:error, :not_connected}
  def disconnect(drone) do
    call(drone, :disconnect)
  end

  # Sends a command to the vehicle process and normalizes the reply.
  defp command(drone, %Command{} = cmd) do
    case call(drone, {:command, cmd}) do
      {:ok, :ok} -> :ok
      {:ok, :dry_run} -> :ok
      {:ok, value} -> {:ok, value}
      {:error, :safety, reason} -> {:error, :safety, reason}
      {:error, reason} -> {:error, reason}
    end
  end

  # Resolves the drone process and issues a GenServer call, returning
  # {:error, :not_connected} when no process is registered for the name.
  defp call(drone, message) do
    case Vehicle.whereis(drone) do
      nil -> {:error, :not_connected}
      pid -> GenServer.call(pid, message)
    end
  end
end