Adapter Contract Design
View SourceOverview
The Drone.Adapter behaviour defines the contract between Drone.Vehicle and drone-specific implementations. Every adapter must implement this behaviour, enabling the Vehicle to be adapter-agnostic.
The Behaviour
defmodule Drone.Adapter do
@type state :: term()
@callback connect(opts :: keyword()) ::
{:ok, state()}
| {:error, term()}
@callback command(state :: state(), command :: Drone.Command.t()) ::
{:ok, reply :: term(), new_state :: state()}
| {:error, reason :: term(), new_state :: state()}
@callback telemetry(state :: state()) ::
{:ok, map(), state()}
| {:error, term(), state()}
@callback disconnect(state :: state()) :: :ok
endContract Details
connect/1
Called when Drone.connect/2 is invoked. The adapter receives all options passed to Drone.connect/2 (except :name and :safety, which are consumed by the Vehicle).
Returns:
{:ok, state}-- Connection successful. Thestateis an opaque term that will be passed to all subsequent callbacks.{:error, reason}-- Connection failed. The Vehicle process will not start.
Responsibilities:
- Open any necessary connections (UDP socket for Tello, nothing for Sim)
- Initialize adapter-specific state
- Perform initial handshake if required (e.g., sending
commandto enter SDK mode for Tello)
Important: The adapter should NOT enter SDK mode in connect/1. The Vehicle will call command(state, %Command{type: :sdk_mode}) separately if needed. This separation allows testing the connection independently from the SDK mode activation.
However, for the Tello adapter specifically, the SDK mode command must be sent before any other command. The Vehicle handles this sequence.
command/2
Called for every command after the safety pipeline has approved it (except emergency, which bypasses safety).
Returns:
{:ok, reply, new_state}-- Command succeeded.replyis an adapter-specific response (typically:okfor movement commands, a value for queries).{:error, reason, new_state}-- Command failed. The error reason should be descriptive.
Responsibilities:
- Send the command to the drone (or simulate it)
- Parse the response
- Update adapter state (position, battery, etc.)
- Return the result
Important: new_state must always be returned, even on error. This allows partial state updates (e.g., updating battery drain even on a failed command).
telemetry/1
Called to retrieve current telemetry data from the adapter.
Returns:
{:ok, telemetry_map, state}-- Telemetry retrieved successfully.{:error, reason, state}-- Telemetry retrieval failed.
The telemetry map should include:
%{
x: integer(), # cm from launch point
y: integer(), # cm from launch point
z: integer(), # cm altitude
yaw: integer(), # degrees (0-360)
battery: integer(), # percent (0-100)
speed: integer(), # cm/s
flying: boolean(), # whether the drone is in the air
mode: atom(), # :idle | :sdk_mode | :flying | :emergency
last_command: Drone.Command.t() | nil,
command_count: integer()
}Adapters may include additional fields specific to their implementation.
disconnect/1
Called when Drone.disconnect/1 is invoked or when the Vehicle process is terminating.
Returns: :ok
Responsibilities:
- Close connections (UDP socket for Tello)
- Clean up resources
- No state update needed (the process is terminating)
Adapter Registration
Adapters are referenced by atom in Drone.connect/2:
Drone.connect(:sim, name: :test) # -> Drone.Adapters.Sim
Drone.connect(:tello, name: :tello_1) # -> Drone.Adapters.TelloThe mapping is:
@adapters %{
sim: Drone.Adapters.Sim,
tello: Drone.Adapters.Tello
}Users can also pass a module directly:
Drone.connect(MyCustomAdapter, name: :custom)Error Handling Contract
Adapters must follow these error conventions:
| Error Type | When |
|---|---|
:timeout | No response from drone within timeout |
:connection_error | Unable to establish connection |
:command_error | Drone returned error response |
:not_in_sdk_mode | Command sent before entering SDK mode |
:not_flying | Movement command sent while not airborne |
:already_flying | Takeoff sent while already flying |
:emergency_active | Command sent while in emergency state |
:simulated_failure | Sim adapter configured to fail |
These are returned as {:error, reason, new_state} from command/2.
State Isolation
Each adapter manages its own state independently. The Vehicle holds the adapter state as an opaque term and passes it to each callback. The adapter must not store state in process dictionaries, ETS, or other global state.
This design ensures:
- Multiple drones can be controlled simultaneously
- Adapters are testable in isolation
- No hidden global state
- Easy to swap adapters without changing user code
Testing Contract
Adapters should be testable without real hardware. To enable this:
- The Sim adapter should work with no external dependencies
- The Tello adapter should support a fake UDP server for testing
- All adapter callbacks should be pure functions of their state
Test pattern for any adapter:
# Connect
{:ok, state} = MyAdapter.connect(opts)
# Send commands
{:ok, _, state} = MyAdapter.command(state, %Drone.Command{type: :takeoff})
# Check telemetry
{:ok, telemetry, _} = MyAdapter.telemetry(state)
# Disconnect
:ok = MyAdapter.disconnect(state)Future Adapters
The adapter contract must be stable enough for future adapters:
Crazyflie (v0.3.0)
- Pure CRTP codecs + Crazyradio transport with pluggable
usb_backend - CI uses
mock://transport profiles (no USB NIF required in the Hex package) - Connection URI:
radio://0/80/2M/E7E7E7E7E7ormock://ready - Optional
capabilities/1(sdk_mode: :optional, no flips) - High-level commander takeoff / go_to / land / emergency
- Must implement the same behaviour
MAVLink (later)
- Will use a TCP/UDP connection to a MAVLink endpoint
- State will include full vehicle state (GPS, attitude, etc.)
- Must implement the same behaviour
The adapter contract should not need to change for these. If it does, that's a v2.0.0 concern.
Mermaid: Adapter Architecture
classDiagram
class Adapter {
<<behaviour>>
+connect(opts) {:ok, state} | {:error, reason}
+command(state, command) {:ok, reply, state} | {:error, reason, state}
+telemetry(state) {:ok, map, state} | {:error, reason, state}
+disconnect(state) :ok
+capabilities(state) map
}
class Sim {
+connect(opts)
+command(state, command)
+telemetry(state)
+disconnect(state)
}
class Tello {
+connect(opts)
+command(state, command)
+telemetry(state)
+disconnect(state)
}
class Crazyflie {
+connect(opts)
+command(state, command)
+telemetry(state)
+disconnect(state)
}
class MAVLink {
+connect(opts)
+command(state, command)
+telemetry(state)
+disconnect(state)
}
Adapter <|.. Sim
Adapter <|.. Tello
Adapter <|.. Crazyflie
Adapter <|.. MAVLink
class Vehicle {
-adapter: Adapter
-adapter_state: term()
-safety_policy: Policy
-vehicle_state: map()
+handle_call({:command, cmd}, from, state)
}
Vehicle --> Adapter : uses
Vehicle --> Safety : validates throughSee also
- Guide: Adapter Authoring
- Further reading: Platforms and adapters