diff --git a/CMakeLists.txt b/CMakeLists.txt
index f249164aa63a..b6eb3a891dd1 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -169,6 +169,7 @@ define_property(GLOBAL PROPERTY PX4_SRC_FILES
#
include(px4_add_module)
+include(px4_add_external_mavlink_dialect)
set(config_module_list)
set(config_kernel_list)
@@ -452,6 +453,26 @@ if (NOT EXTERNAL_MODULES_LOCATION STREQUAL "")
add_subdirectory(${EXTERNAL_MODULES_LOCATION}/src/${external_module} external_modules/${external_module})
list(APPEND external_module_paths ${EXTERNAL_MODULES_LOCATION}/src/${external_module})
endforeach()
+
+ # External MAVLink dialects (registered via px4_add_external_mavlink_dialect)
+ get_property(_ext_dialects GLOBAL PROPERTY PX4_EXTERNAL_MAVLINK_DIALECTS)
+ if(_ext_dialects)
+ list(LENGTH _ext_dialects _n_dialects)
+ list(GET _ext_dialects 0 _primary_ext_dialect)
+
+ if(CONFIG_MAVLINK_DIALECT STREQUAL "common")
+ set(CONFIG_MAVLINK_DIALECT "${_primary_ext_dialect}"
+ CACHE STRING "MAVLink dialect (external: ${_primary_ext_dialect})" FORCE)
+ message(STATUS "External MAVLink dialect: ${_primary_ext_dialect} (auto-set from common)")
+ else()
+ message(STATUS "External MAVLink dialect: ${_primary_ext_dialect} "
+ "(CONFIG_MAVLINK_DIALECT=${CONFIG_MAVLINK_DIALECT} kept as-is)")
+ endif()
+
+ if(_n_dialects GREATER 1)
+ message(STATUS " Additional external dialects: ${_ext_dialects}")
+ endif()
+ endif()
endif()
#=============================================================================
diff --git a/ROMFS/CMakeLists.txt b/ROMFS/CMakeLists.txt
index 42e5d4293e52..58ca715de962 100644
--- a/ROMFS/CMakeLists.txt
+++ b/ROMFS/CMakeLists.txt
@@ -226,6 +226,31 @@ foreach(board_rc_file ${OPTIONAL_BOARD_RC})
endforeach()
+# External module init scripts
+if(NOT "${EXTERNAL_MODULES_LOCATION}" STREQUAL "")
+ set(_ext_init "${EXTERNAL_MODULES_LOCATION}/init/rc.ext_modules")
+ if(EXISTS "${_ext_init}")
+ file(RELATIVE_PATH _ext_init_relative ${PX4_SOURCE_DIR} ${_ext_init})
+ message(STATUS "ROMFS: Adding ${_ext_init_relative} -> /etc/init.d/rc.ext_modules")
+
+ add_custom_command(
+ OUTPUT
+ ${romfs_gen_root_dir}/init.d/rc.ext_modules
+ rc.ext_modules.stamp
+ COMMAND ${CMAKE_COMMAND} -E copy_if_different
+ ${_ext_init} ${romfs_gen_root_dir}/init.d/rc.ext_modules
+ COMMAND ${CMAKE_COMMAND} -E touch rc.ext_modules.stamp
+ DEPENDS
+ ${_ext_init}
+ romfs_copy.stamp
+ COMMENT "ROMFS: copying rc.ext_modules"
+ )
+
+ list(APPEND extras_dependencies rc.ext_modules.stamp)
+ endif()
+endif()
+
+
if(config_additional_init)
if(EXISTS "${PX4_BOARD_DIR}/init/${config_additional_init}")
file(RELATIVE_PATH rc_file_relative ${PX4_SOURCE_DIR} ${PX4_BOARD_DIR}/init/${config_additional_init})
diff --git a/ROMFS/px4fmu_common/init.d/rcS b/ROMFS/px4fmu_common/init.d/rcS
index b6d517b45c2e..c0235bf19d31 100644
--- a/ROMFS/px4fmu_common/init.d/rcS
+++ b/ROMFS/px4fmu_common/init.d/rcS
@@ -686,6 +686,17 @@ else
fi
unset RC_LOGGING
+ #
+ # Optional external module auto-start: rc.ext_modules
+ #
+ set EXT_MODULE_RC ${R}etc/init.d/rc.ext_modules
+ if [ -f $EXT_MODULE_RC ]
+ then
+ echo "External modules: ${EXT_MODULE_RC}"
+ . $EXT_MODULE_RC
+ fi
+ unset EXT_MODULE_RC
+
#
# Start the VTX services.
#
diff --git a/cmake/px4_add_external_mavlink_dialect.cmake b/cmake/px4_add_external_mavlink_dialect.cmake
new file mode 100644
index 000000000000..dc42a78a73d1
--- /dev/null
+++ b/cmake/px4_add_external_mavlink_dialect.cmake
@@ -0,0 +1,77 @@
+############################################################################
+#
+# Copyright (c) 2026 PX4 Development Team. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+#
+# 1. Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# 2. Redistributions in binary form must reproduce the above copyright
+# notice, this list of conditions and the following disclaimer in
+# the documentation and/or other materials provided with the
+# distribution.
+# 3. Neither the name PX4 nor the names of its contributors may be
+# used to endorse or promote products derived from this software
+# without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+# COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
+# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS
+# OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
+# AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+############################################################################
+
+#=============================================================================
+#
+# px4_add_external_mavlink_dialect
+#
+# Registers an external MAVLink dialect XML for mavgen code generation.
+# The dialect XML should common.xml (or another base
+# dialect) so that all standard MAVLink messages remain available.
+#
+# Multiple external dialects are supported. The first registered dialect
+# becomes the primary dialect (overrides CONFIG_MAVLINK_DIALECT from
+# "common" if applicable).
+#
+# Usage:
+# px4_add_external_mavlink_dialect(
+# XML ${CMAKE_CURRENT_SOURCE_DIR}/../../mavlink/my_dialect.xml
+# )
+#
+# Effects:
+# 1. Copies the XML into mavgen's message_definitions/v1.0/ search path
+# 2. Appends the dialect name to PX4_EXTERNAL_MAVLINK_DIALECTS global property
+# 3. Dialect override happens in root CMakeLists.txt after all add_subdirectory()
+# calls have completed
+#
+function(px4_add_external_mavlink_dialect)
+ px4_parse_function_args(
+ NAME px4_add_external_mavlink_dialect
+ ONE_VALUE XML
+ REQUIRED XML
+ ARGN ${ARGN}
+ )
+
+ if(NOT EXISTS "${XML}")
+ message(FATAL_ERROR "px4_add_external_mavlink_dialect: XML not found: ${XML}")
+ endif()
+
+ get_filename_component(_dialect_name "${XML}" NAME_WE)
+ set(_mavlink_defs "${PX4_SOURCE_DIR}/src/modules/mavlink/mavlink/message_definitions/v1.0")
+
+ configure_file("${XML}" "${_mavlink_defs}/${_dialect_name}.xml" COPYONLY)
+
+ set_property(GLOBAL APPEND PROPERTY PX4_EXTERNAL_MAVLINK_DIALECTS "${_dialect_name}")
+
+ message(STATUS "External MAVLink dialect registered: ${_dialect_name} (from ${XML})")
+endfunction()
diff --git a/docs/en/advanced/out_of_tree_modules.md b/docs/en/advanced/out_of_tree_modules.md
index 7763e5478416..909ca7b599f7 100644
--- a/docs/en/advanced/out_of_tree_modules.md
+++ b/docs/en/advanced/out_of_tree_modules.md
@@ -82,3 +82,123 @@ Any other build target can be used, but the build directory must not yet exist.
If it already exists, you can also just set the _cmake_ variable in the build folder.
For subsequent incremental builds `EXTERNAL_MODULES_LOCATION` does not need to be specified.
+
+## Out-of-Tree MAVLink Dialect Definitions
+
+External modules can register custom MAVLink dialect XML files for mavgen code generation without modifying PX4 source.
+
+### Registering a Dialect
+
+Call `px4_add_external_mavlink_dialect()` from your module's `CMakeLists.txt`:
+
+```cmake
+px4_add_external_mavlink_dialect(
+ XML ${CMAKE_CURRENT_SOURCE_DIR}/../../mavlink/my_dialect.xml
+)
+```
+
+The dialect XML must `common.xml` so that all standard MAVLink messages remain available.
+The function copies the XML into mavgen's search path and, if `CONFIG_MAVLINK_DIALECT` is `common`, automatically overrides it with your dialect name.
+
+Multiple external dialects from different modules are supported.
+
+### Directory Layout
+
+```
+my_external_module/
+├── mavlink/
+│ └── my_dialect.xml # Custom MAVLink dialect
+├── src/
+│ ├── CMakeLists.txt # Calls px4_add_external_mavlink_dialect()
+│ └── modules/
+│ └── my_module/
+```
+
+## External MAVLink Message Handlers and Streams
+
+External modules can register callbacks for custom inbound and outbound MAVLink messages at runtime, without patching `mavlink_receiver.cpp` or `mavlink_main.cpp`.
+
+### Inbound Message Handlers
+
+Register a handler for a custom message ID from your module's `init` or `task_spawn`:
+
+```cpp
+#include
+
+static bool handle_my_message(const mavlink_message_t *msg, void *user_data)
+{
+ // Decode and process message
+ return true;
+}
+
+// Registration (typically in module init)
+mavlink_ext_handler_register(MAVLINK_MSG_ID_MY_MESSAGE, handle_my_message, this);
+
+// Cleanup (module stop)
+mavlink_ext_handler_unregister(MAVLINK_MSG_ID_MY_MESSAGE);
+```
+
+Registered handlers are invoked from the MAVLink receiver thread's `default` switch case.
+Registration is mutex-protected; dispatch is lock-free.
+
+### Outbound Streams
+
+Register a stream callback to periodically emit custom messages:
+
+```cpp
+#include
+
+static bool emit_my_message(uint8_t channel, void *user_data)
+{
+ mavlink_my_message_t msg{};
+ // Fill message fields...
+ mavlink_msg_my_message_send_struct((mavlink_channel_t)channel, &msg);
+ return true;
+}
+
+// Register with rate limiting (500000 = 2 Hz)
+mavlink_ext_stream_register(MAVLINK_MSG_ID_MY_MESSAGE, "MY_MESSAGE",
+ emit_my_message, this, 500000);
+```
+
+The `interval_us` parameter controls rate limiting:
+- `-1`: unlimited (fire every iteration)
+- `0`: disabled
+- `>0`: minimum microseconds between sends
+
+External stream rates can also be controlled at runtime via the standard MAVLink `SET_MESSAGE_INTERVAL` command from QGC or pymavlink:
+
+```cpp
+// Programmatic rate change
+mavlink_ext_stream_set_interval(MAVLINK_MSG_ID_MY_MESSAGE, 1000000); // 1 Hz
+```
+
+## Boot-Time Auto-Start
+
+External modules can declare startup commands that are baked into the firmware ROMFS image at build time, eliminating the need for manual SD card `extras.txt` files.
+
+### Setup
+
+Create `init/rc.ext_modules` in your external module directory:
+
+```sh
+#!/bin/sh
+my_driver start
+my_mavlink_bridge start
+```
+
+When building with `EXTERNAL_MODULES_LOCATION`, PX4's build system automatically copies this file into the ROMFS.
+At boot, `rcS` sources it after `rc.board_extras` and before the SD card `extras.txt`.
+
+### Boot Order
+
+```
+rcS boot sequence:
+├── rc.board_extras # Board-specific init
+├── extras.txt # SD card overrides (runtime)
+├── rc.logging # Logger start
+└── rc.ext_modules # External module auto-start (ROMFS, build-time)
+```
+
+External modules run after the logger, ensuring that any slow hardware initialization (e.g. I2C secure elements) doesn't delay flight logging in a brownout recovery scenario.
+The SD card `extras.txt` remains available as a runtime override for development and testing without reflashing.
diff --git a/docs/en/mavlink/custom_messages.md b/docs/en/mavlink/custom_messages.md
index 5bd8534cffdb..12f2b92ecc51 100644
--- a/docs/en/mavlink/custom_messages.md
+++ b/docs/en/mavlink/custom_messages.md
@@ -13,6 +13,11 @@ Custom definitions can be added in a new dialect file in the same directory as [
For example, create `PX4-Autopilot/src/modules/mavlink/mavlink/message_definitions/v1.0/custom_messages.xml`, and set `CONFIG_MAVLINK_DIALECT` to build the new file for SITL.
This dialect file should include `development.xml` so that all the standard definitions are also included.
+:::tip
+If you are building an [external (out-of-tree) module](../advanced/out_of_tree_modules.md), use `px4_add_external_mavlink_dialect()` in your `CMakeLists.txt` instead of manually placing files in the PX4 source tree.
+See [Out-of-Tree MAVLink Dialect Definitions](../advanced/out_of_tree_modules.md#out-of-tree-mavlink-dialect-definitions).
+:::
+
For initial prototyping, or if you intend your message to be "standard", you can also add your messages to `common.xml` (or `development.xml`).
This simplifies building, because you don't need to modify the dialect that is built.
diff --git a/src/modules/mavlink/CMakeLists.txt b/src/modules/mavlink/CMakeLists.txt
index 9c2540686d84..40a75f926eb0 100644
--- a/src/modules/mavlink/CMakeLists.txt
+++ b/src/modules/mavlink/CMakeLists.txt
@@ -124,6 +124,8 @@ px4_add_module(
MavlinkStatustextHandler.cpp
open_drone_id_translations.cpp
tune_publisher.cpp
+ mavlink_ext_handler.cpp
+ mavlink_ext_stream.cpp
MODULE_CONFIG
module.yaml
mavlink_params.yaml
diff --git a/src/modules/mavlink/mavlink_ext_handler.cpp b/src/modules/mavlink/mavlink_ext_handler.cpp
new file mode 100644
index 000000000000..74bd463b7dba
--- /dev/null
+++ b/src/modules/mavlink/mavlink_ext_handler.cpp
@@ -0,0 +1,129 @@
+/****************************************************************************
+ *
+ * Copyright (c) 2026 PX4 Development Team. All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in
+ * the documentation and/or other materials provided with the
+ * distribution.
+ * 3. Neither the name PX4 nor the names of its contributors may be
+ * used to endorse or promote products derived from this software
+ * without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+ * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+ * COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+ * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
+ * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS
+ * OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
+ * AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+ * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+ * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ *
+ ****************************************************************************/
+
+/**
+ * @file mavlink_ext_handler.cpp
+ */
+
+#include "mavlink_bridge_header.h" // Full mavlink_message_t definition (for ->msgid)
+#include "mavlink_ext_handler.h"
+
+#include
+#include
+#include
+#include
+
+struct mavlink_ext_handler_entry_t {
+ uint32_t msg_id;
+ mavlink_ext_handler_fn handler;
+ void *user_data;
+};
+
+static mavlink_ext_handler_entry_t _handlers[MAVLINK_EXT_HANDLER_MAX] {};
+static px4::atomic _handler_count {0};
+static pthread_mutex_t _handler_mutex = PTHREAD_MUTEX_INITIALIZER;
+
+int mavlink_ext_handler_register(uint32_t msg_id, mavlink_ext_handler_fn handler, void *user_data)
+{
+ if (!handler) {
+ return -1;
+ }
+
+ pthread_mutex_lock(&_handler_mutex);
+
+ unsigned count = _handler_count.load();
+
+ for (unsigned i = 0; i < count; i++) {
+ if (_handlers[i].msg_id == msg_id) {
+ pthread_mutex_unlock(&_handler_mutex);
+ return -1;
+ }
+ }
+
+ if (count >= MAVLINK_EXT_HANDLER_MAX) {
+ pthread_mutex_unlock(&_handler_mutex);
+ return -1;
+ }
+
+ _handlers[count].msg_id = msg_id;
+ _handlers[count].handler = handler;
+ _handlers[count].user_data = user_data;
+ _handler_count.store(count + 1);
+
+ pthread_mutex_unlock(&_handler_mutex);
+
+ PX4_DEBUG("ext_handler: registered msgid %lu (count=%u)", (unsigned long)msg_id, count + 1);
+
+ return 0;
+}
+
+int mavlink_ext_handler_unregister(uint32_t msg_id)
+{
+ pthread_mutex_lock(&_handler_mutex);
+
+ unsigned count = _handler_count.load();
+
+ for (unsigned i = 0; i < count; i++) {
+ if (_handlers[i].msg_id == msg_id) {
+ if (i < count - 1) {
+ memmove(&_handlers[i], &_handlers[i + 1],
+ (count - i - 1) * sizeof(mavlink_ext_handler_entry_t));
+ }
+
+ _handler_count.store(count - 1);
+ pthread_mutex_unlock(&_handler_mutex);
+ return 0;
+ }
+ }
+
+ pthread_mutex_unlock(&_handler_mutex);
+ return -1;
+}
+
+bool mavlink_ext_handler_dispatch(const mavlink_message_t *msg)
+{
+ if (!msg) {
+ return false;
+ }
+
+ unsigned count = _handler_count.load();
+
+ for (unsigned i = 0; i < count; i++) {
+ if (_handlers[i].msg_id == msg->msgid) {
+ PX4_DEBUG("ext_handler: dispatching msgid %lu", (unsigned long)msg->msgid);
+ return _handlers[i].handler(msg, _handlers[i].user_data);
+ }
+ }
+
+ return false;
+}
diff --git a/src/modules/mavlink/mavlink_ext_handler.h b/src/modules/mavlink/mavlink_ext_handler.h
new file mode 100644
index 000000000000..c3076fee4e1a
--- /dev/null
+++ b/src/modules/mavlink/mavlink_ext_handler.h
@@ -0,0 +1,91 @@
+/****************************************************************************
+ *
+ * Copyright (c) 2026 PX4 Development Team. All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in
+ * the documentation and/or other materials provided with the
+ * distribution.
+ * 3. Neither the name PX4 nor the names of its contributors may be
+ * used to endorse or promote products derived from this software
+ * without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+ * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+ * COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+ * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
+ * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS
+ * OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
+ * AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+ * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+ * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ *
+ ****************************************************************************/
+
+/**
+ * @file mavlink_ext_handler.h
+ *
+ * Generic external MAVLink message handler registration.
+ *
+ * Allows out-of-tree / external modules to register callbacks for
+ * custom MAVLink message IDs without modifying mavlink_receiver.cpp.
+ * Callbacks are invoked from the receiver's default switch case.
+ */
+
+#pragma once
+
+#include
+
+// Forward declaration — the full definition comes from the dialect headers
+struct __mavlink_message;
+typedef struct __mavlink_message mavlink_message_t;
+
+/**
+ * Callback signature for external MAVLink message handlers.
+ * Receives the raw mavlink_message_t; the handler is responsible for
+ * decoding (e.g. mavlink_msg_*_decode) and publishing to uORB.
+ *
+ * @param msg Parsed MAVLink message (CRC already validated)
+ * @param user_data Opaque pointer passed at registration time
+ * @return true if the message was handled
+ */
+typedef bool (*mavlink_ext_handler_fn)(const mavlink_message_t *msg, void *user_data);
+
+/** Maximum number of concurrently registered external handlers */
+static constexpr unsigned MAVLINK_EXT_HANDLER_MAX = 8;
+
+/**
+ * Register a handler for a custom MAVLink message ID.
+ *
+ * @param msg_id MAVLink message ID to handle
+ * @param handler Callback function
+ * @param user_data Opaque context pointer (e.g. module instance)
+ * @return 0 on success, -1 if table full or msg_id already registered
+ */
+int mavlink_ext_handler_register(uint32_t msg_id, mavlink_ext_handler_fn handler, void *user_data);
+
+/**
+ * Unregister a previously registered handler.
+ *
+ * @param msg_id MAVLink message ID to unregister
+ * @return 0 on success, -1 if msg_id not found
+ */
+int mavlink_ext_handler_unregister(uint32_t msg_id);
+
+/**
+ * Dispatch a message to registered external handlers.
+ * Called from MavlinkReceiver::handle_message() default case.
+ *
+ * @param msg Parsed MAVLink message
+ * @return true if a handler was found and invoked
+ */
+bool mavlink_ext_handler_dispatch(const mavlink_message_t *msg);
diff --git a/src/modules/mavlink/mavlink_ext_stream.cpp b/src/modules/mavlink/mavlink_ext_stream.cpp
new file mode 100644
index 000000000000..40a3506ac0d4
--- /dev/null
+++ b/src/modules/mavlink/mavlink_ext_stream.cpp
@@ -0,0 +1,152 @@
+/****************************************************************************
+ *
+ * Copyright (c) 2026 PX4 Development Team. All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in
+ * the documentation and/or other materials provided with the
+ * distribution.
+ * 3. Neither the name PX4 nor the names of its contributors may be
+ * used to endorse or promote products derived from this software
+ * without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+ * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+ * COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+ * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
+ * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS
+ * OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
+ * AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+ * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+ * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ *
+ ****************************************************************************/
+
+/**
+ * @file mavlink_ext_stream.cpp
+ */
+
+#include "mavlink_ext_stream.h"
+
+#include
+#include
+#include
+#include
+
+struct mavlink_ext_stream_entry_t {
+ uint32_t msg_id;
+ const char *name;
+ mavlink_ext_stream_fn fn;
+ void *user_data;
+ int interval_us; // -1 = unlimited, 0 = disabled
+ hrt_abstime last_sent;
+};
+
+static mavlink_ext_stream_entry_t _streams[MAVLINK_EXT_STREAM_MAX] {};
+static px4::atomic _stream_count {0};
+static pthread_mutex_t _stream_mutex = PTHREAD_MUTEX_INITIALIZER;
+
+int mavlink_ext_stream_register(uint32_t msg_id, const char *name,
+ mavlink_ext_stream_fn fn, void *user_data,
+ int interval_us)
+{
+ if (!fn) {
+ return -1;
+ }
+
+ pthread_mutex_lock(&_stream_mutex);
+
+ unsigned count = _stream_count.load();
+
+ for (unsigned i = 0; i < count; i++) {
+ if (_streams[i].msg_id == msg_id) {
+ pthread_mutex_unlock(&_stream_mutex);
+ return -1;
+ }
+ }
+
+ if (count >= MAVLINK_EXT_STREAM_MAX) {
+ pthread_mutex_unlock(&_stream_mutex);
+ return -1;
+ }
+
+ _streams[count].msg_id = msg_id;
+ _streams[count].name = name;
+ _streams[count].fn = fn;
+ _streams[count].user_data = user_data;
+ _streams[count].interval_us = interval_us;
+ _streams[count].last_sent = 0;
+ _stream_count.store(count + 1);
+
+ pthread_mutex_unlock(&_stream_mutex);
+
+ return 0;
+}
+
+int mavlink_ext_stream_unregister(uint32_t msg_id)
+{
+ pthread_mutex_lock(&_stream_mutex);
+
+ unsigned count = _stream_count.load();
+
+ for (unsigned i = 0; i < count; i++) {
+ if (_streams[i].msg_id == msg_id) {
+ if (i < count - 1) {
+ memmove(&_streams[i], &_streams[i + 1],
+ (count - i - 1) * sizeof(mavlink_ext_stream_entry_t));
+ }
+
+ _stream_count.store(count - 1);
+ pthread_mutex_unlock(&_stream_mutex);
+ return 0;
+ }
+ }
+
+ pthread_mutex_unlock(&_stream_mutex);
+ return -1;
+}
+
+void mavlink_ext_stream_dispatch(uint8_t channel)
+{
+ unsigned count = _stream_count.load();
+ hrt_abstime now = hrt_absolute_time();
+
+ for (unsigned i = 0; i < count; i++) {
+ if (_streams[i].interval_us == 0) {
+ continue;
+ }
+
+ if (_streams[i].interval_us > 0) {
+ if (now - _streams[i].last_sent < (hrt_abstime)_streams[i].interval_us) {
+ continue;
+ }
+ }
+
+ if (_streams[i].fn(channel, _streams[i].user_data)) {
+ _streams[i].last_sent = now;
+ }
+ }
+}
+
+int mavlink_ext_stream_set_interval(uint32_t msg_id, int interval_us)
+{
+ unsigned count = _stream_count.load();
+
+ for (unsigned i = 0; i < count; i++) {
+ if (_streams[i].msg_id == msg_id) {
+ _streams[i].interval_us = interval_us;
+ return 0;
+ }
+ }
+
+ return -1;
+}
diff --git a/src/modules/mavlink/mavlink_ext_stream.h b/src/modules/mavlink/mavlink_ext_stream.h
new file mode 100644
index 000000000000..4e9119c35a60
--- /dev/null
+++ b/src/modules/mavlink/mavlink_ext_stream.h
@@ -0,0 +1,106 @@
+/****************************************************************************
+ *
+ * Copyright (c) 2026 PX4 Development Team. All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in
+ * the documentation and/or other materials provided with the
+ * distribution.
+ * 3. Neither the name PX4 nor the names of its contributors may be
+ * used to endorse or promote products derived from this software
+ * without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+ * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+ * COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+ * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
+ * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS
+ * OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
+ * AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+ * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+ * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ *
+ ****************************************************************************/
+
+/**
+ * @file mavlink_ext_stream.h
+ *
+ * Generic external MAVLink outbound stream registration.
+ *
+ * Allows out-of-tree / external modules to register callbacks that
+ * emit custom MAVLink messages on all active channels. The callbacks
+ * are invoked from the mavlink module's stream update loop.
+ *
+ * The callback receives a mavlink_channel_t and should call the
+ * appropriate mavlink_msg_*_send_struct() to emit the message.
+ */
+
+#pragma once
+
+#include
+
+/**
+ * Callback signature for external outbound streams.
+ *
+ * Called from the mavlink main loop for each active mavlink instance.
+ * The callback should check for new data (e.g. uORB subscription) and
+ * send a MAVLink message via mavlink_msg_*_send_struct(channel, &msg).
+ *
+ * @param channel MAVLink channel index (cast to mavlink_channel_t in callback)
+ * @param user_data Opaque pointer passed at registration time
+ * @return true if a message was sent
+ */
+typedef bool (*mavlink_ext_stream_fn)(uint8_t channel, void *user_data);
+
+/** Maximum number of concurrently registered external streams */
+static constexpr unsigned MAVLINK_EXT_STREAM_MAX = 8;
+
+/**
+ * Register an external outbound stream.
+ *
+ * @param msg_id MAVLink message ID (for identification/logging)
+ * @param name Human-readable stream name (for `mavlink stream` command)
+ * @param fn Callback function invoked each iteration
+ * @param user_data Opaque context pointer
+ * @return 0 on success, -1 if table full or msg_id already registered
+ */
+int mavlink_ext_stream_register(uint32_t msg_id, const char *name,
+ mavlink_ext_stream_fn fn, void *user_data,
+ int interval_us = -1);
+
+/**
+ * Unregister a previously registered external stream.
+ *
+ * @param msg_id MAVLink message ID to unregister
+ * @return 0 on success, -1 if not found
+ */
+int mavlink_ext_stream_unregister(uint32_t msg_id);
+
+/**
+ * Dispatch all registered external streams on a given channel.
+ * Called from Mavlink::task_main() after the regular stream update loop.
+ *
+ * @param chan MAVLink channel to emit on
+ */
+void mavlink_ext_stream_dispatch(uint8_t channel);
+
+/**
+ * Set the send interval for a registered external stream.
+ *
+ * Allows integration with SET_MESSAGE_INTERVAL so that QGC/pymavlink
+ * can control OOT stream rates the same way as built-in streams.
+ *
+ * @param msg_id MAVLink message ID
+ * @param interval_us Interval in microseconds (-1 = unlimited, 0 = disabled)
+ * @return 0 on success, -1 if not found
+ */
+int mavlink_ext_stream_set_interval(uint32_t msg_id, int interval_us);
diff --git a/src/modules/mavlink/mavlink_main.cpp b/src/modules/mavlink/mavlink_main.cpp
index f638099c77cd..9685dbbdc5a9 100644
--- a/src/modules/mavlink/mavlink_main.cpp
+++ b/src/modules/mavlink/mavlink_main.cpp
@@ -60,6 +60,7 @@
#include
#include "mavlink_receiver.h"
#include "mavlink_main.h"
+#include "mavlink_ext_stream.h"
#ifdef MAVLINK_UDP
#include
@@ -2540,6 +2541,9 @@ Mavlink::task_main(int argc, char *argv[])
}
}
+ /* dispatch registered external outbound streams */
+ mavlink_ext_stream_dispatch(static_cast(get_channel()));
+
/* check for ulog streaming messages */
if (_mavlink_ulog) {
const int ret = _mavlink_ulog->handle_update(get_channel());
diff --git a/src/modules/mavlink/mavlink_receiver.cpp b/src/modules/mavlink/mavlink_receiver.cpp
index 3f4a8525b8a5..a55eb701c655 100644
--- a/src/modules/mavlink/mavlink_receiver.cpp
+++ b/src/modules/mavlink/mavlink_receiver.cpp
@@ -60,6 +60,8 @@
#include "mavlink_command_sender.h"
#include "mavlink_main.h"
#include "mavlink_receiver.h"
+#include "mavlink_ext_handler.h"
+#include "mavlink_ext_stream.h"
#include // For DeviceId union
#include
@@ -351,6 +353,7 @@ MavlinkReceiver::handle_message(mavlink_message_t *msg)
#endif
default:
+ mavlink_ext_handler_dispatch(msg);
break;
}
@@ -1345,6 +1348,8 @@ MavlinkReceiver::handle_message_esc_eeprom(mavlink_message_t *msg)
}
#endif // MAVLINK_MSG_ID_ESC_EEPROM
+
+
void
MavlinkReceiver::handle_message_vision_position_estimate(mavlink_message_t *msg)
{
@@ -2330,6 +2335,18 @@ MavlinkReceiver::set_message_interval(int msgId, float interval, float param3, f
if (stream_name != nullptr) {
_mavlink.configure_stream_threadsafe(stream_name, rate);
found_id = true;
+
+ } else {
+ // Fallback: check external (OOT) streams
+ int ext_interval_us = (interval > 0.00001f) ? (int)interval : -1;
+
+ if (interval < -0.00001f) {
+ ext_interval_us = 0; // stop
+ }
+
+ if (mavlink_ext_stream_set_interval((uint32_t)msgId, ext_interval_us) == 0) {
+ found_id = true;
+ }
}
}