Skip to main content

lib/drone/adapters/sim.ex

defmodule Drone.Adapters.Sim do
  @moduledoc """
  Simulator adapter for ex_drone.

  The simulator adapter implements `Drone.Adapter` with an in-process state
  machine. It requires no hardware, no network, and no external dependencies.

  This is the primary adapter for development, testing, and education. It
  enforces the same state machine and command protocol as the Tello adapter,
  but uses pure Elixir state instead of UDP communication.

  ## Usage

      {:ok, drone} = Drone.connect(:sim, name: :test)
      Drone.connect_sdk(drone)
      Drone.takeoff(drone)
      Drone.move(drone, :forward, 100)
      Drone.land(drone)
      Drone.disconnect(drone)

  ## Failure Injection

  The simulator can be configured to inject failures for testing error handling:

      {:ok, drone} = Drone.connect(:sim,
        name: :test,
        failure_rate: 1.0,        # always fail
        fail_commands: [:takeoff]  # only fail takeoff
      )

  ## Battery Simulation

  Battery drains at configurable rates per command. Set `battery: 50` to start
  with 50% battery.

      {:ok, drone} = Drone.connect(:sim, name: :test, battery: 30)
  """

  @behaviour Drone.Adapter

  alias Drone.{Adapters.Sim.State, Command, Geometry}

  @impl Drone.Adapter
  def connect(opts) do
    sim_opts =
      Keyword.take(opts, [
        :battery,
        :battery_drain_per_move,
        :battery_drain_per_takeoff,
        :battery_drain_per_land,
        :battery_drain_per_query,
        :failure_rate,
        :fail_commands
      ])

    {:ok, State.new(sim_opts)}
  end

  @impl Drone.Adapter
  def command(%State{} = state, %Command{type: :emergency}) do
    new_state = %{state | mode: :emergency, flying: false}
    {:ok, :ok, State.push_command(new_state, Command.emergency())}
  end

  def command(%State{} = state, %Command{} = cmd) do
    with :ok <- check_failure(state, cmd),
         :ok <- check_mode(state, cmd),
         {:ok, reply, new_state} <- execute(state, cmd) do
      {:ok, reply, new_state}
    else
      {:error, reason} -> {:error, reason, state}
    end
  end

  @impl Drone.Adapter
  def telemetry(%State{} = state) do
    {:ok,
     %{
       x: state.x,
       y: state.y,
       z: state.z,
       yaw: state.yaw,
       battery: trunc(state.battery),
       speed: state.speed,
       flying: state.flying,
       mode: state.mode,
       last_command: state.last_command,
       command_count: length(state.command_history)
     }, state}
  end

  @impl Drone.Adapter
  def disconnect(%State{}), do: :ok

  defp check_failure(%State{config: config}, %Command{type: type}) do
    cond do
      type in config.fail_commands ->
        {:error, :simulated_failure}

      config.failure_rate > 0 and :rand.uniform() < config.failure_rate ->
        {:error, :simulated_failure}

      true ->
        :ok
    end
  end

  defp check_mode(%State{mode: :idle}, %Command{type: :sdk_mode}), do: :ok
  defp check_mode(%State{mode: :idle}, _cmd), do: {:error, :not_in_sdk_mode}
  defp check_mode(%State{mode: :emergency}, _cmd), do: {:error, :emergency_active}
  defp check_mode(%State{mode: :sdk_mode}, %Command{type: :takeoff}), do: :ok
  defp check_mode(%State{mode: :sdk_mode}, %Command{type: :query}), do: :ok
  defp check_mode(%State{mode: :sdk_mode}, %Command{type: :speed}), do: :ok
  defp check_mode(%State{mode: :flying}, _cmd), do: :ok
  defp check_mode(%State{mode: :sdk_mode}, _cmd), do: {:error, :not_flying}

  defp execute(%State{} = state, %Command{type: :sdk_mode}) do
    new_state = %{state | mode: :sdk_mode} |> State.push_command(Command.sdk_mode())
    {:ok, :ok, new_state}
  end

  defp execute(%State{} = state, %Command{type: :takeoff}) do
    new_state =
      %{state | z: 30, flying: true, mode: :flying, speed: 0}
      |> State.push_command(Command.takeoff())
      |> State.drain_battery(state.config.battery_drain_per_takeoff)
      |> State.add_flight_time(3)

    {:ok, :ok, new_state}
  end

  defp execute(%State{} = state, %Command{type: :land}) do
    new_state =
      %{state | z: 0, flying: false, mode: :sdk_mode, speed: 0}
      |> State.push_command(Command.land())
      |> State.drain_battery(state.config.battery_drain_per_land)
      |> State.add_flight_time(3)

    {:ok, :ok, new_state}
  end

  defp execute(%State{} = 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)

    new_state =
      %{state | x: state.x + dx, y: state.y + dy, z: max(0, state.z + dz)}
      |> State.push_command(Command.move(direction, distance))
      |> State.drain_battery(state.config.battery_drain_per_move)
      |> State.add_flight_time(2)

    {:ok, :ok, new_state}
  end

  defp execute(%State{} = state, %Command{type: :rotate, args: args}) do
    direction = Keyword.fetch!(args, :direction)
    degrees = Keyword.fetch!(args, :degrees)
    new_yaw = Geometry.rotate_yaw(direction, state.yaw, degrees)

    new_state =
      %{state | yaw: new_yaw}
      |> State.push_command(Command.rotate(direction, degrees))
      |> State.drain_battery(state.config.battery_drain_per_move)
      |> State.add_flight_time(2)

    {:ok, :ok, new_state}
  end

  defp execute(%State{} = state, %Command{type: :flip, args: args}) do
    direction = Keyword.fetch!(args, :direction)
    {dx, dy} = Geometry.flip_delta(direction)

    new_state =
      %{state | x: state.x + dx, y: state.y + dy}
      |> State.push_command(Command.flip(direction))
      |> State.drain_battery(state.config.battery_drain_per_move)
      |> State.add_flight_time(2)

    {:ok, :ok, new_state}
  end

  defp execute(%State{} = state, %Command{type: :hover, args: args}) do
    seconds = Keyword.get(args, :seconds, 1)

    new_state =
      state
      |> State.push_command(Command.hover(seconds))
      |> State.add_flight_time(seconds)

    {:ok, :ok, new_state}
  end

  defp execute(%State{} = state, %Command{type: :speed, args: args}) do
    speed = Keyword.fetch!(args, :speed)
    new_state = %{state | speed: speed} |> State.push_command(Command.speed(speed))
    {:ok, :ok, new_state}
  end

  defp execute(%State{} = state, %Command{type: :stop}) do
    new_state = %{state | speed: 0} |> State.push_command(Command.stop())
    {:ok, :ok, new_state}
  end

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

    value =
      case query_type do
        :battery -> trunc(state.battery)
        :height -> state.z
        :speed -> state.speed
        :time -> state.flight_time_seconds
        :wifi -> "sim_wifi"
        :sdk_version -> "sim_1.0"
        :serial_number -> "SIM001"
      end

    new_state =
      state
      |> State.push_command(Command.query(query_type))
      |> State.drain_battery(state.config.battery_drain_per_query)

    {:ok, value, new_state}
  end
end