ModuleBase and ModuleLoader but are otherwise unrelated code with different lifecycles, different loading strategies, and different event models. Both halves now run under the same supervisord in the same skyhub container; they are separated on disk (ROS half at /, agent at /app) and by sys.path, not by a container boundary:
| ROS half | agent half | |
|---|---|---|
| Runtime | ROS2 node (drone_node) under supervisord | FastAPI app on GAMEPAD_WS_PORT (default 5001), also under supervisord |
| 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 |
ROS-half module system (ROS2 env-registry)
The ROS 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
e-ArUco Precision Landing
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 ROS half — 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 agent half, the ROS half’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 ROS-half 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>).Agent-half module system (explicit registration)
The agent 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’s gating is unchanged, but the controller it drives now comes from the shared package skyhub_common.siyi_controller (docker/shared/skyhub_common, on PYTHONPATH at /opt/skyhub/shared) rather than a per-service copy. That deduplication is also what silently broke SIYI camera control before 0.2.0: message_router kept importing the deleted per-service path behind a warn-only except ImportError, so recording, photo and zoom were dead on every vehicle. HAS_SIYI is True again.
Because there is no registry, the only way to disable an agent module is not to register it (as 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 ROS half. All still call super().__init__() on the agent ModuleBase.Events flow through callbacks, not base-class hooks
The agentModuleBase (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 an agent 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, one container.
ModuleBase/ModuleLoaderindocker/core/src/coreanddocker/gamepad/src/coreare unrelated. They share an image but not asys.path— keep it that way. - The ROS loader requires a class literally named
Module.importlibload fails otherwise. - The ROS-half 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.- Agent base-class hooks are not dispatched. Events flow via
set_*_callbackandMessageRouter, noton_*hooks. - The agent’s
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.

