Skip to main content

lib/drone/vehicle.ex

defmodule Drone.Vehicle do
  @moduledoc """
  Supervised GenServer that manages a single drone connection.

  Each `Drone.Vehicle` process represents one drone. It holds the adapter
  state, safety policy, and vehicle state. All commands flow through
  the safety pipeline before reaching the adapter.

  Drivers should not call `Drone.Vehicle` directly. Use the `Drone`
  public API module instead.
  """

  use GenServer

  alias Drone.{Adapter, Command, Geometry, Safety, Safety.Policy, Telemetry}

  @default_vehicle_state %{
    x: 0,
    y: 0,
    z: 0,
    yaw: 0,
    battery: 100,
    speed: 0,
    flying: false,
    mode: :idle,
    last_command: nil,
    command_history: []
  }

  @type state :: %{
          name: atom(),
          adapter_module: module(),
          adapter_state: term(),
          safety_policy: Policy.t(),
          vehicle_state: %{
            x: integer(),
            y: integer(),
            z: integer(),
            yaw: integer(),
            battery: integer(),
            speed: integer(),
            flying: boolean(),
            mode: :idle | :sdk_mode | :flying | :emergency,
            last_command: Command.t() | nil,
            command_history: [Command.t()]
          }
        }

  defstruct [
    :name,
    :adapter_module,
    :adapter_state,
    :safety_policy,
    vehicle_state: @default_vehicle_state
  ]

  def child_spec(opts) do
    %{
      id: __MODULE__,
      start: {__MODULE__, :start_link, [opts]},
      restart: :temporary
    }
  end

  @spec start_link(keyword()) :: GenServer.on_start()
  def start_link(opts) do
    name = Keyword.fetch!(opts, :name)
    GenServer.start_link(__MODULE__, opts, name: via_tuple(name))
  end

  @spec via_tuple(atom()) :: {:via, Registry, {Drone.Vehicle.Registry, atom()}}
  def via_tuple(name) do
    {:via, Registry, {Drone.Vehicle.Registry, name}}
  end

  @spec whereis(atom()) :: pid() | nil
  def whereis(name) do
    case Registry.lookup(Drone.Vehicle.Registry, name) do
      [{pid, _}] -> pid
      [] -> nil
    end
  end

  @impl GenServer
  def init(opts) do
    adapter_key = Keyword.fetch!(opts, :adapter)
    name = Keyword.fetch!(opts, :name)
    safety_opts = Keyword.get(opts, :safety, [])
    adapter_opts = Keyword.drop(opts, [:name, :adapter, :safety])

    case Adapter.resolve(adapter_key) do
      {:ok, adapter_module} ->
        init_with_adapter(adapter_key, name, adapter_module, adapter_opts, safety_opts)

      {:error, :unknown_adapter} = err ->
        {:stop, err}
    end
  end

  @impl GenServer
  def terminate(_reason, %__MODULE__{adapter_module: mod, adapter_state: as} = state) do
    try do
      Telemetry.emit_disconnect(adapter_key_from_module(mod), state.name)
      mod.disconnect(as)
    rescue
      _ -> :ok
    end

    :ok
  end

  @impl GenServer
  def handle_call({:command, %Command{} = cmd}, _from, %__MODULE__{} = state) do
    adapter_key = adapter_key_from_module(state.adapter_module)

    case Safety.check(cmd, state.safety_policy, state.vehicle_state) do
      {:error, :safety, reason} ->
        Telemetry.emit_safety_reject(adapter_key, state.name, cmd.type, reason)
        {:reply, {:error, :safety, reason}, state}

      {:ok, cmd} ->
        execute_command(cmd, state, [])

      {:ok, cmd, warnings} ->
        for warning <- warnings do
          Telemetry.emit_safety_warning(adapter_key, state.name, cmd.type, warning)
        end

        execute_command(cmd, state, warnings)
    end
  end

  def handle_call(:emergency, _from, %__MODULE__{} = state) do
    adapter_key = adapter_key_from_module(state.adapter_module)
    Telemetry.emit_emergency(adapter_key, state.name)

    cmd = Command.emergency()
    start_time = System.monotonic_time()

    case state.adapter_module.command(state.adapter_state, cmd) do
      {:ok, _reply, new_adapter_state} ->
        duration = System.monotonic_time() - start_time
        Telemetry.emit_command_stop(adapter_key, state.name, :emergency, :ok, duration)

        new_vehicle_state = %{state.vehicle_state | mode: :emergency, flying: false}
        new_state = %{state | adapter_state: new_adapter_state, vehicle_state: new_vehicle_state}
        {:reply, :ok, new_state}

      {:error, reason, new_adapter_state} ->
        duration = System.monotonic_time() - start_time
        Telemetry.emit_command_error(adapter_key, state.name, :emergency, reason, duration)
        {:reply, {:error, reason}, %{state | adapter_state: new_adapter_state}}
    end
  end

  def handle_call(:telemetry, _from, %__MODULE__{} = state) do
    adapter_key = adapter_key_from_module(state.adapter_module)

    case state.adapter_module.telemetry(state.adapter_state) do
      {:ok, adapter_telemetry, new_adapter_state} ->
        Telemetry.emit_telemetry_update(adapter_key, state.name, adapter_telemetry)
        merged = Map.merge(state.vehicle_state, adapter_telemetry)
        {:reply, {:ok, merged}, %{state | adapter_state: new_adapter_state}}

      {:error, reason, new_adapter_state} ->
        {:reply, {:error, reason}, %{state | adapter_state: new_adapter_state}}
    end
  end

  def handle_call(:disconnect, _from, %__MODULE__{} = state) do
    # Cleanup (adapter disconnect + telemetry) is performed in terminate/2,
    # which always runs on a :normal stop. Doing it here too would close the
    # adapter twice.
    {:stop, :normal, :ok, state}
  end

  def handle_call(:get_state, _from, %__MODULE__{} = state) do
    {:reply, state.vehicle_state, state}
  end

  def handle_call(:get_policy, _from, %__MODULE__{} = state) do
    {:reply, state.safety_policy, state}
  end

  defp execute_command(%Command{} = cmd, %__MODULE__{} = state, _warnings) do
    adapter_key = adapter_key_from_module(state.adapter_module)

    if state.safety_policy.dry_run do
      new_vehicle_state = update_vehicle_state(state.vehicle_state, cmd, :dry_run)
      start_time = System.monotonic_time()
      Telemetry.emit_command_start(adapter_key, state.name, cmd)
      duration = System.monotonic_time() - start_time
      Telemetry.emit_command_stop(adapter_key, state.name, cmd.type, :dry_run, duration)
      {:reply, {:ok, :dry_run}, %{state | vehicle_state: new_vehicle_state}}
    else
      start_time = System.monotonic_time()
      Telemetry.emit_command_start(adapter_key, state.name, cmd)

      case state.adapter_module.command(state.adapter_state, cmd) do
        {:ok, reply, new_adapter_state} ->
          duration = System.monotonic_time() - start_time
          Telemetry.emit_command_stop(adapter_key, state.name, cmd.type, :ok, duration)

          new_vehicle_state = update_vehicle_state(state.vehicle_state, cmd, reply)

          new_state = %{
            state
            | adapter_state: new_adapter_state,
              vehicle_state: new_vehicle_state
          }

          {:reply, {:ok, reply}, new_state}

        {:error, reason, new_adapter_state} ->
          duration = System.monotonic_time() - start_time
          Telemetry.emit_command_error(adapter_key, state.name, cmd.type, reason, duration)
          {:reply, {:error, reason}, %{state | adapter_state: new_adapter_state}}
      end
    end
  end

  defp init_with_adapter(adapter_key, name, adapter_module, adapter_opts, safety_opts) do
    Telemetry.emit_connect_start(adapter_key, name)

    case adapter_module.connect(adapter_opts) do
      {:ok, adapter_state} ->
        safety_policy = build_policy(safety_opts)
        initial_vehicle_state = fetch_initial_vehicle_state(adapter_module, adapter_state)

        state = %__MODULE__{
          name: name,
          adapter_module: adapter_module,
          adapter_state: adapter_state,
          safety_policy: safety_policy,
          vehicle_state: initial_vehicle_state
        }

        Telemetry.emit_connect_stop(adapter_key, name, 0)
        {:ok, state}

      {:error, reason} ->
        Telemetry.emit_connect_error(adapter_key, name, reason)
        {:stop, reason}
    end
  end

  # The :safety option accepts either a keyword list (built into a policy via
  # Policy.new/1) or an already-constructed %Policy{} struct.
  defp build_policy(%Policy{} = policy), do: policy
  defp build_policy(opts) when is_list(opts), do: Policy.new(opts)

  defp fetch_initial_vehicle_state(adapter_module, adapter_state) do
    case adapter_module.telemetry(adapter_state) do
      {:ok, telemetry, _} ->
        %{
          x: Map.get(telemetry, :x, 0),
          y: Map.get(telemetry, :y, 0),
          z: Map.get(telemetry, :z, 0),
          yaw: Map.get(telemetry, :yaw, 0),
          battery: Map.get(telemetry, :battery, 100),
          speed: Map.get(telemetry, :speed, 0),
          flying: Map.get(telemetry, :flying, false),
          mode: Map.get(telemetry, :mode, :idle),
          last_command: nil,
          command_history: []
        }

      {:error, _, _} ->
        @default_vehicle_state
    end
  end

  defp update_vehicle_state(vehicle_state, %Command{type: :sdk_mode}, _reply) do
    %{vehicle_state | mode: :sdk_mode}
  end

  defp update_vehicle_state(vehicle_state, %Command{type: :takeoff}, _reply) do
    %{vehicle_state | z: 30, flying: true, mode: :flying, speed: 0}
  end

  defp update_vehicle_state(vehicle_state, %Command{type: :land}, _reply) do
    %{vehicle_state | z: 0, flying: false, mode: :sdk_mode, speed: 0}
  end

  defp update_vehicle_state(vehicle_state, %Command{type: :emergency}, _reply) do
    %{vehicle_state | mode: :emergency, flying: false}
  end

  defp update_vehicle_state(vehicle_state, %Command{type: :move, args: args} = cmd, _reply) do
    direction = Keyword.fetch!(args, :direction)
    distance = Keyword.fetch!(args, :distance)
    {dx, dy, dz} = Geometry.move_delta(direction, distance, vehicle_state.yaw)

    vehicle_state
    |> Map.merge(%{
      x: vehicle_state.x + dx,
      y: vehicle_state.y + dy,
      z: max(0, vehicle_state.z + dz)
    })
    |> add_to_history(cmd)
  end

  defp update_vehicle_state(vehicle_state, %Command{type: :rotate, args: args} = cmd, _reply) do
    direction = Keyword.fetch!(args, :direction)
    degrees = Keyword.fetch!(args, :degrees)
    new_yaw = Geometry.rotate_yaw(direction, vehicle_state.yaw, degrees)

    %{vehicle_state | yaw: new_yaw}
    |> add_to_history(cmd)
  end

  defp update_vehicle_state(vehicle_state, %Command{type: :flip, args: args} = cmd, _reply) do
    direction = Keyword.fetch!(args, :direction)
    {dx, dy} = Geometry.flip_delta(direction)

    %{vehicle_state | x: vehicle_state.x + dx, y: vehicle_state.y + dy}
    |> add_to_history(cmd)
  end

  defp update_vehicle_state(vehicle_state, %Command{type: :speed, args: args} = cmd, _reply) do
    speed = Keyword.fetch!(args, :speed)

    %{vehicle_state | speed: speed}
    |> add_to_history(cmd)
  end

  defp update_vehicle_state(vehicle_state, %Command{type: :stop}, _reply) do
    %{vehicle_state | speed: 0}
  end

  defp update_vehicle_state(vehicle_state, %Command{type: :hover} = cmd, _reply) do
    add_to_history(vehicle_state, cmd)
  end

  defp update_vehicle_state(vehicle_state, %Command{type: :query, args: args} = cmd, reply) do
    query_type = Keyword.fetch!(args, :type)

    vehicle_state =
      case query_type do
        :battery -> %{vehicle_state | battery: reply}
        :height -> %{vehicle_state | z: reply}
        :speed -> %{vehicle_state | speed: reply}
        _ -> vehicle_state
      end

    add_to_history(vehicle_state, cmd)
  end

  defp add_to_history(vehicle_state, cmd) do
    %{vehicle_state | last_command: cmd, command_history: [cmd | vehicle_state.command_history]}
  end

  defp adapter_key_from_module(Drone.Adapters.Sim), do: :sim
  defp adapter_key_from_module(Drone.Adapters.Tello), do: :tello
  defp adapter_key_from_module(mod), do: mod
end