Writing Plugins
MAVSDK-C++ is split into a core and multiple independent plugins.
Plugins that are located in the correct location (a subfolder of /plugins) and have the correct structure are built at compile time. The CMakeLists.txt takes care of including the plugin folders and integration tests.
Plugin Architecture
Plugins should be written so that they are independent of each other (they will still need to be dependent on the core source). This allows plugins to be removed/replaced as needed at the cost of some duplicated functionality across the plugin modules.
The code for each plugin (and its unit test if one has been defined) is stored in a sub-folder of the plugins directory. Integration tests for all plugins in the library are stored in integration_tests.
A simplified view of the folder structure is shown below:
├── MAVSDK
│ ├── core
│ ├── integration_tests
│ └── plugins
│ ├── action
│ ├── ...
│ └── telemetry
Each plugin must have the same files/structure, as shown for the "example" plugin below.
└── plugins
└── example
├── CMakeLists.txt
├── example.cpp
├── example.h
├── example_impl.cpp
├── example_impl.h
└── example_foo_test.cpp ##optional
Auto-generation
In order to support various language wrappers around MAVSDK without having to write the same things multiple times, once for every language, we opted to use auto-generation as much as possible. The APIs are defined as proto definitions.
From that, several parts are auto-generated, such as:
- Language wrappers based on gRPC client (formerly called frontend)
- gRPC mavsdk_server in C++ (formerly called backend)
- Plugin C++ headers defining the API.
Looking at the plugin structure again, this means that some of the files are auto-generated:
└── plugins
└── example
├── CMakeLists.txt # auto-generated
├── example.cpp # auto-generated
├── example.h # auto-generated
├── example_impl.cpp # hand-written (can initally be generated)
├── example_impl.h # hand-written (can initally be generated)
└── example_foo_test.cpp # optional
Create a Plugin
To create a new plugin do not copy an existing one, instead follow the steps below:
Think about public API
Before writing the API, take a step back and think what a user of it needs and expect, rather than what MAVLink already provides.
Generally, MAVSDK APIs ought to be:
- Simple and easy to use.
- Reduced to the essentials; no functionality that is not actually implemented/supported should be exposed.
- Clearly named and if possible without too much drone jargon and acronyms.
- Abstracted from the MAVLink implementation and therefore to provide specific functionality instead of just forwarding direct MAVLink.
This advice is important if you are planning to contribute the new plugin back and would like it to get accepted and merged. We are convinced it is also applicable for internal development but - of course - that's up to you.
About proto structure
There are a couple of different API types supported for MAVSDK plugins.
Requests:
A request is a simple one time call with a response. An example would be the takeoff command of the action plugin:
service ActionService {
rpc Takeoff(TakeoffRequest) returns(TakeoffResponse) {}
// all other services
}
message TakeoffRequest {}
message TakeoffResponse {
ActionResult action_result = 1;
}
In this case the request has no argument and no return value except the result but this can vary, e.g. for getters and setters:
service ActionService {
rpc GetReturnToLaunchAltitude(GetReturnToLaunchAltitudeRequest) returns(GetReturnToLaunchAltitudeResponse) {}
rpc SetReturnToLaunchAltitude(SetReturnToLaunchAltitudeRequest) returns(SetReturnToLaunchAltitudeResponse) {}
// all other services
}
message GetReturnToLaunchAltitudeRequest {}
message GetReturnToLaunchAltitudeResponse {
ActionResult action_result = 1;
float relative_altitude_m = 2;
}
message SetReturnToLaunchAltitudeRequest {
float relative_altitude_m = 1;
}
message SetReturnToLaunchAltitudeResponse {
ActionResult action_result = 1;
}
that requests can defined SYNC, ASYNC, or BOTH using
option (mavsdk.options.async_type) = ...;
. The choice depends on the functionality that is being implemented and how it would generally be used. There are no hard rules, it's something that makes sense to be discussed one by one in a pull request. The default implementation isBOTH
.
Subscriptions:
A subscription is triggered once and will then continuously send responses as a stream. An example would be a the position information of the telemetry plugin:
service TelemetryService {
rpc SubscribePosition(SubscribePositionRequest) returns(stream PositionResponse) {}
// all other services
}
message SubscribePositionRequest {}
message PositionResponse {
Position position = 1;
}
that subscriptions also can defined SYNC, ASYNC, or BOTH using
option (mavsdk.options.async_type) = ...;
. The sync implementation of a subscription is just a getter for the last received value.Note that subscriptions can be defined finite using
option (mavsdk.options.is_finite) = true;
. This means that the stream of messages will end at some point instead of continuing indefinitely. An example would be progress updates about a calibration which eventually finishes.
Add API to proto
The first step should be to define the user API in the proto repository.
This repository is part of the MAVSDK as a submodule in the proto/
directory.
You usually want to work from master in the proto/
directory, and then create a feature branch with your additions:
cd proto
git switch master
git pull
git switch -c my-new-plugin
cd ../
Now you can add a folder with your proto file (or copy an existing one and rename it) and draft the API.
Once the API is defined, it makes sense to commit the changes, push them and make a pull request to MAVSDK-Proto to get feedback from the MAVSDK maintainers.
Generate .h and .cpp files
Once the proto file has been created, you can generate all files required for the new plugin.
- Run the configure step to prepare the tools required:
cmake -DBUILD_BACKEND=ON -Bbuild/default -H.
- Run the auto-generation:
tools/generate_from_protos.sh
- Fix style after auto-generation:
tools/fix_style.sh .
the files
my_new_plugin.h
andmy_new_plugin.cpp
are generated and overwritten everytime the script is run. However, the filesmy_new_plugin_impl.h
andmy_new_plugin_impl.cpp
are only generated once. To re-generate them, delete them and run the script again. This approach is used to prevent the script from overwriting your local changes.
Actually implement MAVLink messages
You can now add the actual "business logic" which is usually sending and receiving MAVLink messages, waiting for timeouts, etc.
All implementation goes into the files my_new_plugin_impl.h
and my_new_plugin_impl.cpp
or additional files for separete classes required.
You can also add unit tests with unittest_source_files
, as discussed below.
The standard plugins can be reviewed for guidance on how to write plugin code, including how to send and process MAVLink messages.
Plugin Code
Plugin Base Class
All plugins should derive their implementation from PluginImplBase
(core/plugin_impl_base.h) and override virtual methods as needed.
Plugin Enable/Disable
The SDK provides virtual methods that a plugin should implement to allow the core to better manage resources.
For example, to prevent callback being created before the System
is instantiated, or messages being sent when a vehicle is not connected.
Plugin authors should provide an implementation of the following PluginImplBase
pure virtual methods:
- init()/deinit(): These are called when a system is created and just before it is destroyed. These should be used for setting up and cleaning everything that depends on having the
System
instantiated. This includes calls that set up callbacks. - enable()/disable(): These are called when a vehicle is discovered or has timed out. They should be used for managing resources needed to access a connected system/vehicle (e.g. getting a parameter or changing a setting).
Additional detail is provided for methods below.
init()
virtual void init() = 0
The init()
method is called when a plugin is instantiated.
This happens when a System
is constructed (this does not mean that the system actually exists and is connected - it might just be an empty dummy system).
Plugins should do initialization steps with other parts of the SDK at this state, e.g. set up callbacks with _parent
(DeviceImpl
).
deinit()
virtual void deinit() = 0
The deinit()
method is called before a plugin is destroyed. This usually happens only at the very end, when a MAVSDK
instance is destroyed.
Plugins should cleanup anything that was set up during init()
.
enable()
virtual void enable() = 0
The enable()
method is called when a system is discovered (connected).
Plugins should do all initialization/configuration steps that require a system to be connected.
For example, setting/getting parameters.
If any threads, call_every or timeouts are needed, they can be started in this method.
disable()
virtual void disable() = 0
The disable()
method is called when a system has timed out.
The method is also called before deinit()
is called to stop any systems with active plugins from communicating (in order to prevent warnings and errors because communication to the system no longer works).
If any threads, call_every, or timeouts are running, they should be stopped in this method.
Test Code
Tests must be created for all new and updated plugin code. The tests should be exhaustive, and cover all aspects of using the plugin API.
The Google Test Primer provides an excellent overview of how tests are written and used.
Writing Unit Tests
Most of the existing plugins do not have unit tests, because we do not yet have the ability to mock MAVLink communications (needed to test most plugins). Unit tests are therefore considered optional!
Comprehensive integration tests should be written instead, with the simulator providing appropriate MAVLink messages.
Adding Unit Tests
Unit test files are stored in the same directory as their associated source code.
In order to include a test in the SDK unit test program (unit_tests_runner
),
it must be added to the UNIT_TEST_SOURCES
variable in the plugin CMakeLists.txt file.
For example, to add the example_foo_test.cpp unit test you would append the following lines to its CMakeLists.txt:
list(APPEND UNIT_TEST_SOURCES
${CMAKE_SOURCE_DIR}/src/plugins/mission/example_foo_test.cpp
)
set(UNIT_TEST_SOURCES ${UNIT_TEST_SOURCES} PARENT_SCOPE)
Unit Test Code
Unit tests typically include the file to be tested, mavsdk.h, and gtest.h.
There are no standard shared test unit resources so test functions are declared using TEST
.
All tests in a file should share the same test-case name (the first argument to TEST
).
The skeleton example plugin unit test is shown below:
#include "example.h"
#include "mavsdk.h"
#include "global_include.h"
#include <gtest/gtest.h>
namespace mavsdk {
TEST(Example, NoTest)
{
ASSERT_TRUE(true);
}
} // namespace mavsdk
Writing Integration Tests
MAVSDK provides the integration_tests_runner
application for running the integration tests and some helper code to make it easier to log tests and run them against the simulator.
Check out the Google Test Primer and the integration_tests for our existing plugins to better understand how to write your own!
Adding Integration Tests
In order to run an integration test it needs to be added to the integration_tests_runner
program.
Integration tests for core functionality and plugins delivered by the project are stored in MAVSDK/src/integration_tests. The files are added to the test program in that folder's CMakeLists.txt file:
# This includes all GTests that run integration tests
add_executable(integration_tests_runner
../core/unittests_main.cpp
simple_connect.cpp
async_connect.cpp
telemetry_simple.cpp
...
gimbal.cpp
transition_multicopter_fixedwing.cpp
follow_me.cpp
)
Integration Test Files/Code
The main MAVSDK-specific functionality is provided by integration_test_helper.h.
This provides access to the Plugin/Test Logger and a shared test class SitlTest
for setting up and tearing down the PX4 simulator.
All tests running against SITL can be declared using
TEST_F
and have a first argumentSitlTest
as shown. This is required in order to use the shared class to set up and tear down the simulator between tests.
For reference inspect the existing integration tests.
Example Code
It is quicker and easier to write and modify integration tests than examples. Do not write example code until the plugin has been accepted!
A simple example should be written that demonstrates basic usage of its API by 3rd parties. The example need not cover all functionality, but should demonstrate enough that developers can see how it is used and how the example might be extended.
Where possible examples should demonstrate realistic use cases such that the code can usefully be copied and reused by external developers.
Documentation
In-Source Comments
The public API must be fully documented using the proto files.
The in-source comments will be compiled to markdown and included in the API Reference. The process is outlined in Documentation > API Reference.
Internal/implementation classes need not be documented, but should be written using expressive naming of variables and functions to help the reader. Anything unexpected or special however warrants an explanation as a comment.
Example Code Documentation
The plugin example should be documented in markdown following the same pattern as the existing examples.
Generally this involves explaining what the example does and displaying the source. The explanation of how the code works is usually deferred to guide documentation.
Guide Documentation
Ideally, guide documentation should be created. This should be based on example code.
The purpose of the guide is to:
- Show how different parts of the API can be used together
- Highlight usage patterns and limitations that may not be obvious from API reference
- Provide code fragments that can easily be reused