ModuleBase and ModuleLoader but are otherwise unrelated code with different lifecycles, different loading strategies, and different event models:
| core service | gamepad / ws_proxy service | |
|---|---|---|
| Runtime | ROS2 node (drone_node) under supervisord | FastAPI service (:5001) |
| Loader | docker/core/src/core/module_loader.py | docker/gamepad/src/core/module_loader.py |
| How modules are found | MODULE_REGISTRY + importlib, gated by env vars | Constructed by hand in main.py, register()-ed |
| Module contract | export a Module class taking (node, config) | any ModuleBase subclass with a bespoke constructor |
| Config injection | auto-parsed MODULE_NAME_* env → config dict | passed explicitly at construction |
| Event delivery | loader dispatch_* fan-out to base-class hooks | explicit set_*_callback + MessageRouter |
Core module system (ROS2 env-registry)
The core loader (docker/core/src/core/module_loader.py) is a dynamic registry. Every entry in MODULE_REGISTRY maps a module name to its import path, the env var that enables it, and a default:
docker/core/src/core/module_loader.py
| Module | Import path | Enable var | Default | Class version |
|---|---|---|---|---|
video_stream | src.modules.video_stream.module | VIDEO_STREAM_ENABLED | on | Module v2.0.0 |
aruco_landing | src.modules.aruco_landing.module | ARUCO_LANDING_ENABLED | off | Module v4.0.0 |
battery | src.modules.battery.module | BATTERY_INDICATOR_ENABLED | on | Module v1.2.0 |
Video Streaming
the video_stream pipeline
ArUco Landing & Docking
aruco_landing detection
Loading flow
DroneNode.__init__ (docker/core/main.py:90) builds the loader, then loads, wires, and starts modules in a fixed order:
discover_modules()
Each
MODULE_REGISTRY entry’s enable var is read from the environment. A value of true, 1, yes, or on (case-insensitive) means load; anything else is skipped (module_loader.py:65).load_module() via importlib
The registry’s import path is
importlib.import_module-ed. The module file must export a class literally named Module (the loader errors out if hasattr(mod, "Module") is false), then instantiates mod.Module(self.node, config) (module_loader.py:82).Frame-callback detection
If the instance overrides
on_frame (i.e. module.__class__.on_frame is not ModuleBase.on_frame), it is added to _frame_subscribers (module_loader.py:117). This is a dormant path today: dispatch_frame (like dispatch_position) is defined on the loader but is never called by the core service — only dispatch_drone_state, dispatch_state_change, and dispatch_command are actually fired (from main.py:170-187), so no frames are fed through the loader. aruco_landing overrides on_frame and thus registers here, yet still captures RTSP independently — consistent with the CANVAS_APROACH decision below.setup_video_overlay()
Called before start. It is a deliberate no-op — ArUco tracking is sent to the frontend as a Redis canvas overlay, never burned into the video (CANVAS_APROACH,
module_loader.py:304). Do not re-add server-side compositing here.Config convention
Per-module config is auto-collected from the environment by prefix._get_module_config (module_loader.py:133) takes every os.environ key beginning with MODULE_NAME.upper() + "_", strips the prefix, lowercases the remainder, and type-parses the value (bool → int → float → str):
self.get_config("marker_size", default).
The ModuleBase contract
Core modules subclass ModuleBase (docker/core/src/core/module_base.py). It provides metadata, a DualLogger (writes to both the ROS2 console and the Python file handler), a get_config helper, and the lifecycle/event surface:
| Member | Kind | Purpose |
|---|---|---|
MODULE_NAME / MODULE_VERSION / MODULE_DESCRIPTION | class attrs | metadata shown in get_status() |
start() / stop() | abstract | must return bool; must be implemented |
shutdown() | concrete | default calls stop() if running |
on_state_change(old, new) | hook | derived drone-state dict changed |
on_drone_state(state) | hook | raw mavros_msgs/State message |
on_frame(frame, camera_id) | hook | opt-in video frame (registers as subscriber) |
on_position(position) / on_attitude(attitude) | hook | position / attitude updates |
on_command(command, params) | hook | named command (e.g. video_room_state) |
Event dispatch
Unlike the gamepad system, core’s base-class hooks are actually invoked by the loader.DroneNode translates ROS2 subscriptions into dispatch_* calls, which fan out to every module that is both enabled and running:
/mavros/stateis compared against the previous state; only on change aredispatch_drone_state+dispatch_state_changefired (main.py:154). Thedronefield is derived fromREQUIRED_STATE(ARMED→armed/disarmed,CONNECTED→connected/disconnected).- The three
video_room_*String topics becomedispatch_command("video_room_source"|"video_room_details"|"video_room_state", …)(main.py:173), which is how the Gateway’s Janus room details reachvideo_stream.
Adding a core module
Create the package
docker/core/src/modules/<name>/module.py exporting a class named exactly Module that subclasses ModuleBase and calls super().__init__(node, config).Set metadata and lifecycle
Define
MODULE_NAME/MODULE_VERSION/MODULE_DESCRIPTION and implement start()/stop() returning bool. Override only the on_* hooks you need.Register it
Add an entry to
MODULE_REGISTRY in module_loader.py: ("src.modules.<name>.module", "<NAME>_ENABLED", <default>).Gamepad module system (explicit registration)
The gamepad loader (docker/gamepad/src/core/module_loader.py) does no discovery and no env-gating. It is a thin ordered lifecycle manager: modules are constructed by hand in Gamepad._initialize_modules (docker/gamepad/main.py:112) with bespoke constructor arguments, then register()-ed. Registration order is preserved in _load_order and drives start_all() (forward) and stop_all() (reverse).
docker/gamepad/main.py
| Module | Constructor (real signature) | Gating |
|---|---|---|
mavlink | MAVLinkModule(config) | always |
redis | RedisModule(config, ip_address) | always (needs wg0 IP / IP_OVERRIDE) |
gimbal | GimbalModule(config) | always |
charging | ChargingModule(config, gpio_available, gpio_module) | always (GPIO no-ops off-Jetson) |
video | VideoModule(upload_timeout, segment_duration) | always |
siyi | SiyiModule(camera_ip) | only if CAMERA_IP set |
guided_control | GuidedControlModule(config, on_state_change=…) | always |
siyi demonstrates — it is skipped when CAMERA_IP is unset in favour of a MockCameraProvider for SITL) or to set module.enabled = False before start_all(), which the loader honours by skipping.
The constructor signatures are all different — some take the whole
GamepadConfig, some take a single value, some take injected callbacks. They are not the uniform (node, config) shape of the core system. All still call super().__init__() on the gamepad ModuleBase.Events flow through callbacks, not base-class hooks
The gamepadModuleBase (docker/gamepad/src/core/module_base.py) declares hooks on_armed_state_change, on_message, and on_command — but nothing dispatches them. The loader has no dispatch_* methods, and main.py never calls the hooks. They are effectively vestigial. Real events flow two ways:
- Explicit callback registration in
_setup_module_dependencies(main.py:211), e.g.mavlink_module.set_armed_state_callback(...),redis_module.set_message_callback(...),set_aruco_tracking_callback(...),set_video_stream_state_callback(...). MessageRouter— the real command dispatcher. Inbound JSON (from the/gamepadWebSocket or the{ip}:gamepad_inputRedis channel) is routed by message type to the mavlink/gimbal/charging/guided-control modules. See.Redis Message Bus & WebSocket
the message-bus reference
Inter-module dependency wiring
Some modules cannot be fully started at registration time because they need the live MAVLink connection, which only exists aftermavlink_module.start(). So start() runs twice-phased:
Concretely (main.py:211): once mavlink_module.connection is available, the coordinator injects it into gimbal_module and guided_control_module (the latter also gets a mode_getter so it can read flight mode), (re)starts them, builds the LogDownloader, and registers all the runtime callbacks. guided_control reading flight mode is what enforces the GUIDED-only safety guard — see Guided Velocity Control & Safety
safe control
Adding a gamepad module
Create the module
docker/gamepad/src/modules/<name>/module.py with a ModuleBase subclass (any constructor you like), MODULE_NAME/MODULE_VERSION, and start()/stop() returning bool.Construct and register it
In
Gamepad._initialize_modules (main.py:112), instantiate it with the args it needs and call self.module_loader.register(self.<name>_module). Registration order matters — place it relative to its dependencies (e.g. after mavlink).Wire its events
If it needs the MAVLink connection or must react to arm/disarm/Redis events, inject and start it in
_setup_module_dependencies (main.py:211), and register the relevant set_*_callback. If it accepts operator commands, add a branch in MessageRouter.Gotchas to preserve
- Two systems, one name.
ModuleBase/ModuleLoaderindocker/core/src/coreanddocker/gamepad/src/coreare unrelated. Keep them separate. - Core requires a
Moduleclass literally namedModule.importlibload fails otherwise. - Core config prefix leaks the enable flag into the config dict (
BATTERY_INDICATOR_ENABLED→config["indicator_enabled"]). Avoid colliding key names. setup_video_overlay()is an intentional no-op (CANVAS_APROACH). ArUco tracking goes to Redis for a frontend canvas overlay, never into the stream.- Gamepad base-class hooks are not dispatched. Events flow via
set_*_callbackandMessageRouter, noton_*hooks. - Gamepad
gimbal/guided_controlneed the MAVLink connection, so they are (re)started in_setup_module_dependenciesafterstart_all(), not purely by the loader.
Drone OS Overview
Where these two services sit in the stack.
Microservices & Profiles
Container profiles, supervisord, startup order.
Message Bus & WebSocket
MessageRouter, Redis channels,
/gamepad.Local Dev with SITL
Running modules with mocked IPs and TEST camera.

