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