Skip to main content

lib/drone/adapters/tello.ex

defmodule Drone.Adapters.Tello do
  @moduledoc """
  Tello drone adapter for ex_drone.

  This adapter communicates with DJI Tello and Tello EDU drones over
  Wi-Fi UDP using the official Tello SDK protocol.

  ## Default Configuration

    - Drone IP: `192.168.10.1`
    - Drone port: `8889`
    - Local port: `8889`
    - Command timeout: `10_000` ms

  ## Usage

      {:ok, drone} = Drone.connect(:tello, name: :tello_1)
      Drone.connect_sdk(drone)
      Drone.takeoff(drone)
      Drone.move(drone, :forward, 50)
      Drone.land(drone)
      Drone.disconnect(drone)

  ## Custom Configuration

      {:ok, drone} = Drone.connect(:tello,
        name: :tello_1,
        drone_ip: {192, 168, 10, 1},
        drone_port: 8889,
        local_port: 9030,
        timeout: 15_000
      )

  **Safety warning**: The Tello is a real physical drone. Always test in
  the simulator first. Use prop guards. Do not fly near faces. Have an
  emergency stop ready.
  """

  @behaviour Drone.Adapter

  alias Drone.{
    Adapters.Tello.Connection,
    Adapters.Tello.Encoder,
    Adapters.Tello.Parser,
    Command,
    Geometry
  }

  defstruct [
    :socket,
    :drone_ip,
    :drone_port,
    :timeout,
    mode: :idle,
    flying: false,
    x: 0,
    y: 0,
    z: 0,
    yaw: 0
  ]

  @type t :: %__MODULE__{
          socket: port() | nil,
          drone_ip: :inet.ip_address(),
          drone_port: non_neg_integer(),
          timeout: non_neg_integer(),
          mode: :idle | :sdk_mode | :flying | :emergency,
          flying: boolean(),
          x: integer(),
          y: integer(),
          z: integer(),
          yaw: integer()
        }

  @impl Drone.Adapter
  def connect(opts) do
    ip = Keyword.get(opts, :drone_ip, Connection.default_drone_ip())
    port = Keyword.get(opts, :drone_port, Connection.default_drone_port())
    local_port = Keyword.get(opts, :local_port, Connection.default_local_port())
    timeout = Keyword.get(opts, :timeout, Connection.default_timeout())

    case Connection.open(local_port: local_port) do
      {:ok, socket} ->
        state = %__MODULE__{
          socket: socket,
          drone_ip: ip,
          drone_port: port,
          timeout: timeout
        }

        {:ok, state}

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

  @impl Drone.Adapter
  def command(
        %__MODULE__{socket: socket, drone_ip: ip, drone_port: port, timeout: timeout} = state,
        %Command{} = cmd
      ) do
    encoded = Encoder.encode(cmd)
    conn_opts = [drone_ip: ip, drone_port: port, timeout: timeout]

    case Connection.send_command(socket, encoded, conn_opts) do
      {:ok, response} ->
        case Parser.parse(response) do
          {:ok, :ok} ->
            new_state = update_state(state, cmd)
            {:ok, :ok, new_state}

          {:ok, value} ->
            new_state = update_state(state, cmd)
            {:ok, value, new_state}

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

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

  @impl Drone.Adapter
  def telemetry(%__MODULE__{} = state) do
    {:ok,
     %{
       x: state.x,
       y: state.y,
       z: state.z,
       yaw: state.yaw,
       flying: state.flying,
       mode: state.mode
     }, state}
  end

  @impl Drone.Adapter
  def disconnect(%__MODULE__{socket: socket} = _state) do
    if socket, do: Connection.close(socket)
    :ok
  end

  defp update_state(%__MODULE__{} = state, %Command{type: :sdk_mode}) do
    %{state | mode: :sdk_mode}
  end

  defp update_state(%__MODULE__{} = state, %Command{type: :takeoff}) do
    %{state | z: 30, flying: true, mode: :flying}
  end

  defp update_state(%__MODULE__{} = state, %Command{type: :land}) do
    %{state | z: 0, flying: false, mode: :sdk_mode}
  end

  defp update_state(%__MODULE__{} = state, %Command{type: :emergency}) do
    %{state | mode: :emergency, flying: false}
  end

  defp update_state(%__MODULE__{} = state, %Command{type: :move, args: args}) do
    direction = Keyword.fetch!(args, :direction)
    distance = Keyword.fetch!(args, :distance)
    {dx, dy, dz} = Geometry.move_delta(direction, distance, state.yaw)

    %{state | x: state.x + dx, y: state.y + dy, z: max(0, state.z + dz)}
  end

  defp update_state(%__MODULE__{} = state, %Command{type: :rotate, args: args}) do
    direction = Keyword.fetch!(args, :direction)
    degrees = Keyword.fetch!(args, :degrees)
    %{state | yaw: Geometry.rotate_yaw(direction, state.yaw, degrees)}
  end

  defp update_state(%__MODULE__{} = state, %Command{type: :flip, args: args}) do
    direction = Keyword.fetch!(args, :direction)
    {dx, dy} = Geometry.flip_delta(direction)
    %{state | x: state.x + dx, y: state.y + dy}
  end

  defp update_state(%__MODULE__{} = state, %Command{type: :speed, args: _args}) do
    state
  end

  defp update_state(%__MODULE__{} = state, %Command{type: :stop}) do
    state
  end

  defp update_state(%__MODULE__{} = state, %Command{type: :hover}) do
    state
  end

  defp update_state(%__MODULE__{} = state, %Command{type: :query, args: args}) do
    query_type = Keyword.fetch!(args, :type)

    case query_type do
      :height -> %{state | z: nil}
      _ -> state
    end
  end
end