Architecture

View Source

ex_drone is a BEAM-native drone control library built on OTP principles.

Module Overview

Drone (Public API)
  |
  +-- Drone.Vehicle (GenServer, one per drone)
  |     |
  |     +-- Drone.Safety (pure validation)
  |     +-- Drone.Telemetry (event helpers)
  |     +-- Drone.Geometry (position math)
  |     +-- Drone.Adapter (behaviour)
  |           |
  |           +-- Drone.Adapters.Sim (in-process state machine)
  |           +-- Drone.Adapters.Tello (UDP connection)
  |
  +-- Drone.Command (struct and encoding)
  +-- Drone.Error (error types)
  +-- Drone.Mission (sequence DSL)
  +-- Drone.Safety.Policy (policy configuration)
  +-- Drone.Safety.Geofence (area restrictions)

Supervision Tree

Drone.Supervisor.Root (Supervisor)
  |
  +-- Drone.Vehicle.Registry (Registry)
  +-- Drone.Supervisor (DynamicSupervisor)
        |
        +-- Drone.Vehicle :sim_1
        +-- Drone.Vehicle :tello_1
        +-- ...

Each Drone.Vehicle is a supervised GenServer that:

  • Owns the adapter state for one drone
  • Runs every command through the safety pipeline
  • Emits telemetry events
  • Updates vehicle state from adapter responses
  • Can be started and stopped dynamically

Command Pipeline

User -> Drone.takeoff(drone)
       |
       v
Drone.Vehicle.handle_call({:command, cmd})
       |
       v
Emergency? -> Bypass all, send immediately to adapter
       |
       v
Drone.Safety.check(cmd, policy, state)
       |
       +-- Validate Args -> Reject if out of Tello SDK range
       +-- Check Mode -> Reject if not in valid state
       +-- Check Allowlist -> Reject if not allowed
       +-- Check Flying Requirement -> Reject if state does not match
       +-- Check Altitude -> Reject if exceeds max
       +-- Check Distance -> Reject if exceeds max
       +-- Check Battery -> Reject takeoff if too low
       +-- Check Geofence -> Reject if outside area
       |
       +-- {:error, :safety, reason} -> emit [:drone, :safety, :reject] -> return error
       |
       +-- {:ok, cmd} or {:ok, cmd, warnings}
              |
              v
       (dry_run?) -> return {:ok, :dry_run}
              |
              v
       emit [:drone, :command, :start]
              |
              v
       Adapter.command(state, cmd)
              |
              +-- {:ok, reply, new_state} -> update state -> emit [:drone, :command, :stop]
              +-- {:error, reason, new_state} -> emit [:drone, :command, :error]

Emergency commands bypass the entire safety pipeline.

Adapter Behaviour

All adapters implement Drone.Adapter:

@callback connect(opts :: keyword()) :: {:ok, state()} | {:error, term()}
@callback command(state(), command()) :: {:ok, reply, new_state} | {:error, reason, new_state}
@callback telemetry(state()) :: {:ok, map(), state()} | {:error, term(), state()}
@callback disconnect(state()) :: :ok

This allows swapping adapters without changing user code.

Why One GenServer Per Drone?

  • Each drone has independent state (position, battery, mode)
  • Sequential command processing matches the Tello UDP protocol
  • A crash in one drone process does not affect others
  • Supervision enables automatic restart
  • Named processes via Registry provide easy lookup

Why Adapters as Behaviours?

  • Simulator adapts the same API for testing
  • Tello adapter handles UDP communication
  • Future adapters (Crazyflie, MAVLink) will use the same contract
  • User code is adapter-agnostic

Why Simulator-First?

  • Test all APIs without hardware
  • Safety validation in simulation shows identical behavior
  • Fast iteration cycle
  • Educational value
  • Mission replay and failure injection