SkyCore ships two separate module systems that happen to share the class names ModuleBase and ModuleLoader but are otherwise unrelated code with different lifecycles, different loading strategies, and different event models:
core servicegamepad / ws_proxy service
RuntimeROS2 node (drone_node) under supervisordFastAPI service (:5001)
Loaderdocker/core/src/core/module_loader.pydocker/gamepad/src/core/module_loader.py
How modules are foundMODULE_REGISTRY + importlib, gated by env varsConstructed by hand in main.py, register()-ed
Module contractexport a Module class taking (node, config)any ModuleBase subclass with a bespoke constructor
Config injectionauto-parsed MODULE_NAME_* env → config dictpassed explicitly at construction
Event deliveryloader dispatch_* fan-out to base-class hooksexplicit set_*_callback + MessageRouter
Do not unify these two systems in a refactor. They look similar by name only. The core loader is a dynamic, env-driven ROS2 registry; the gamepad loader is a thin ordered lifecycle manager over hand-wired objects. Merging them would break either the importlib/env-config convention or the explicit dependency-injection wiring.

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_REGISTRY: dict[str, tuple] = {
    "video_stream":  ("src.modules.video_stream.module",  "VIDEO_STREAM_ENABLED",       True),
    "aruco_landing": ("src.modules.aruco_landing.module", "ARUCO_LANDING_ENABLED",      False),
    "battery":       ("src.modules.battery.module",       "BATTERY_INDICATOR_ENABLED",  True),
}
ModuleImport pathEnable varDefaultClass version
video_streamsrc.modules.video_stream.moduleVIDEO_STREAM_ENABLEDonModule v2.0.0
aruco_landingsrc.modules.aruco_landing.moduleARUCO_LANDING_ENABLEDoffModule v4.0.0
batterysrc.modules.battery.moduleBATTERY_INDICATOR_ENABLEDonModule v1.2.0
The full behaviour of these three is covered elsewhere: see

Video Streaming

the video_stream pipeline
and

ArUco Landing & Docking

aruco_landing detection
. This page is about the loader they plug into.

Loading flow

DroneNode.__init__ (docker/core/main.py:90) builds the loader, then loads, wires, and starts modules in a fixed order:
1

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).
2

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).
3

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.
4

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.
5

start_all()

Every loaded module’s start() is called; failures are logged but do not abort the others (module_loader.py:188).

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):
ARUCO_LANDING_MARKER_SIZE=0.87   ->  config["marker_size"] = 0.87
BATTERY_SOC1_NCELL=6             ->  config["soc1_ncell"] = 6
Inside a module you read it with self.get_config("marker_size", default).
The enable var shares the config prefix, so it leaks into the config dict. Because the prefix for battery is BATTERY_, BATTERY_INDICATOR_ENABLED lands as config["indicator_enabled"]; likewise VIDEO_STREAM_ENABLEDconfig["enabled"] and ARUCO_LANDING_ENABLEDconfig["enabled"]. Harmless today (nothing reads those keys) but a trap if you later name a real config key enabled or indicator_enabled.

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:
MemberKindPurpose
MODULE_NAME / MODULE_VERSION / MODULE_DESCRIPTIONclass attrsmetadata shown in get_status()
start() / stop()abstractmust return bool; must be implemented
shutdown()concretedefault calls stop() if running
on_state_change(old, new)hookderived drone-state dict changed
on_drone_state(state)hookraw mavros_msgs/State message
on_frame(frame, camera_id)hookopt-in video frame (registers as subscriber)
on_position(position) / on_attitude(attitude)hookposition / attitude updates
on_command(command, params)hooknamed 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/state is compared against the previous state; only on change are dispatch_drone_state + dispatch_state_change fired (main.py:154). The drone field is derived from REQUIRED_STATE (ARMED→armed/disarmed, CONNECTED→connected/disconnected).
  • The three video_room_* String topics become dispatch_command("video_room_source"|"video_room_details"|"video_room_state", …) (main.py:173), which is how the Gateway’s Janus room details reach video_stream.

Adding a core module

1

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).
2

Set metadata and lifecycle

Define MODULE_NAME/MODULE_VERSION/MODULE_DESCRIPTION and implement start()/stop() returning bool. Override only the on_* hooks you need.
3

Register it

Add an entry to MODULE_REGISTRY in module_loader.py: ("src.modules.<name>.module", "<NAME>_ENABLED", <default>).
4

Configure it

Any <NAME>_KEY=value env var is auto-injected as config["key"]; document the vars in .env.example. Read them via self.get_config("key", 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
self.mavlink_module = MAVLinkModule(self.config)
self.module_loader.register(self.mavlink_module)

self.redis_module = RedisModule(self.config, self.my_ip)   # needs wg0 IP for channels
self.module_loader.register(self.redis_module)

self.gimbal_module = GimbalModule(self.config)
self.module_loader.register(self.gimbal_module)
# ...charging, video, (siyi if CAMERA_IP), guided_control
ModuleConstructor (real signature)Gating
mavlinkMAVLinkModule(config)always
redisRedisModule(config, ip_address)always (needs wg0 IP / IP_OVERRIDE)
gimbalGimbalModule(config)always
chargingChargingModule(config, gpio_available, gpio_module)always (GPIO no-ops off-Jetson)
videoVideoModule(upload_timeout, segment_duration)always
siyiSiyiModule(camera_ip)only if CAMERA_IP set
guided_controlGuidedControlModule(config, on_state_change=…)always
Because there is no registry, the only way to disable a gamepad 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 core system. All still call super().__init__() on the gamepad ModuleBase.

Events flow through callbacks, not base-class hooks

The gamepad ModuleBase (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:
  1. 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(...).
  2. MessageRouter — the real command dispatcher. Inbound JSON (from the /gamepad WebSocket or the {ip}:gamepad_input Redis channel) is routed by message type to the mavlink/gimbal/charging/guided-control modules. See

    Redis Message Bus & WebSocket

    the message-bus reference
    .
If you add a new hook to the gamepad ModuleBase, remember it will not be called automatically. Wire it explicitly in _setup_module_dependencies or add a route in MessageRouter. Do not assume the core-style dispatch_* fan-out exists here.

Inter-module dependency wiring

Some modules cannot be fully started at registration time because they need the live MAVLink connection, which only exists after mavlink_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

1

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.
2

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).
3

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.
4

Gate it if optional

There is no env registry — guard registration with a config check (as siyi does with CAMERA_IP) or leave it unregistered.

Gotchas to preserve

  • Two systems, one name. ModuleBase/ModuleLoader in docker/core/src/core and docker/gamepad/src/core are unrelated. Keep them separate.
  • Core requires a Module class literally named Module. importlib load fails otherwise.
  • Core config prefix leaks the enable flag into the config dict (BATTERY_INDICATOR_ENABLEDconfig["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_*_callback and MessageRouter, not on_* hooks.
  • Gamepad gimbal/guided_control need the MAVLink connection, so they are (re)started in _setup_module_dependencies after start_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.