Architecture Overview#
This document describes the internal architecture of Sugarcoat for developers contributing to or extending the framework.
Package Layout#
Package |
Contents |
|---|---|
|
|
|
|
|
|
|
|
|
The plugin framework: |
|
The UI node, its JSON/WebSocket API, and the optional FastHTML browser front-end |
|
Condition expressions, the standard action factories, service/action client handlers, TF lookup helpers, and the decorators |
Core Module Structure#
The ros_sugar.core package exposes the primary building blocks:
Class |
Base Class |
Role |
|---|---|---|
|
|
Managed lifecycle execution unit |
|
|
Event evaluation and component supervision |
|
(standalone) |
Condition-based trigger on topic data |
|
|
Callable dispatched when an event fires; declaring a success condition, timeout or retry budget makes it verify its own outcome |
|
(standalone) |
Organizing primitive containing a sequence of |
|
(standalone) |
Health status wrapper around |
|
(attrs / standalone) |
Failure recovery actions |
BaseComponent#
BaseComponent extends rclpy.lifecycle.Node and is the primary unit of execution. It wraps lifecycle management, declarative I/O wiring, type-safe configuration via attrs, health status broadcasting, and fallback handling into a single class.
Key constructor parameters:
BaseComponent(
component_name: str,
inputs: Optional[Sequence[Topic]] = None,
outputs: Optional[Sequence[Topic]] = None,
config: Optional[BaseComponentConfig] = None,
config_file: Optional[str] = None,
callback_group: Optional[CallbackGroup] = None,
fallbacks: Optional[ComponentFallbacks] = None,
main_action_type: Optional[type] = None,
main_srv_type: Optional[type] = None,
)
A component is a plain Python object until its ROS node is initialized. The Launcher constructs components in the launcher process, then either initializes the node in a thread of that process or serializes the component’s construction arguments (config, topics, events, fallbacks, plugins) into the command line of a separate process, where executable_main rebuilds it. Recipe-level knobs that are not part of the config live directly on the component, such as launch_prefix, a command prefix (taskset, nice, chrt, a profiler) applied to the component’s process under multiprocess launch.
Monitor#
Monitor extends rclpy.node.Node (a standard, non-lifecycle node). It is responsible for:
Subscribing to all registered
Eventtopics and evaluating conditions via an event blackboard (EventBlackboardEntrycache).Receiving decoded robot-plugin feedback for topics that have no ROS subscription: the plugin HOST registers them with
register_external_topicand injects each decoded message throughfeed_external_topic, so events over plugin telemetry evaluate exactly as over ROS topics.Creating service clients for component reconfiguration and lifecycle transitions, and activating components on start once they are discovered on the graph.
Invoking component methods at runtime via
ExecuteMethodservice clients.Emitting
InternalEventinstances back to theLauncherso the correspondingActioncan be dispatched via the ROS launch event system.Broadcasting the static transforms declared by mounts on
/tf_static.
Health status is not consumed by the Monitor. Each component evaluates its own status and runs its fallbacks on its own timer (see Event-Driven Architecture); process-level crash recovery is the Launcher’s on_process_fail. When using the Launcher, the Monitor is created and configured automatically; users do not need to instantiate it directly.
Launcher#
Launcher (in ros_sugar.launch.launcher) provides a Pythonic alternative to ros2 launch. Its recipe-facing API:
Method / property |
Purpose |
|---|---|
|
Add components from one package; |
|
Attach a robot or sensor plugin; a |
|
Publish a plugin feedback on ROS for nodes outside the recipe; one already on a ROS topic is left as is, with a warning |
|
Bring up external ROS nodes and launch files alongside the components, each in its own process |
|
Register an event/action pair; sugar for |
|
Respawn a multiprocess component that exits unexpectedly |
|
Start the UI node with its JSON/WebSocket API and, optionally, the browser front-end. |
|
Broadcast the robot description and frame names to every component; a robot plugin supplies defaults for |
|
Build the launch description and run it, blocking until shutdown |
bringup() proceeds in this order: validate that every Topic(use_plugin=...) names an attached plugin; hand every plugin to every component; apply the robot plugin’s robot_config and base_frame unless the recipe set its own; route events to their owners; create the UI node and the Monitor; start the shared feedback bus, then for each plugin its declared driver processes and its HOST (transports, decoders and heartbeats); publish mounts as static TF; build one launch action per component (thread or process); run the LaunchService. On exit it closes every plugin HOST, then the shared bus and the shared-memory segments.
Component Lifecycle#
BaseComponent follows the ROS 2 managed lifecycle with four transition callbacks:
[Unconfigured] --on_configure--> [Inactive] --on_activate--> [Active]
^ | |
| |<---on_deactivate---------|
|<------on_cleanup--------------|
on_configure#
Called when the component transitions from Unconfigured to Inactive. It loads the configuration file if one was given, calls custom_on_configure(), and resets the health status to healthy.
on_activate#
Called when the component transitions from Inactive to Active. This is where the ROS resources are created, in this order:
Robot plugin adaptation: inputs and outputs that opted in with
use_pluginare bound to the plugin’s feedback and commands (see Creating a Plugin). This runs first because it may replace entries inself.callbacksandself.publishers_dict.init_variables(), the hook for component state and for declaring which frame each input should be transformed into.Subscriptions for the declared
inputs(eachTopicwired to aGenericCallback), publishers for the declaredoutputs, the built-in services (parameter change, topic replacement, configure-from-file, execute-method), service and action clients, the main action server or service, the subscriptions that feed fallbacks, and finally the execution timer and the fallback-check timer.Event management and external processors are attached, and
custom_on_activate()runs.
on_deactivate#
Transitions from Active back to Inactive. Timers, servers, clients, subscriptions and publishers are destroyed and TF lookups are paused; custom_on_deactivate() runs.
on_cleanup#
Transitions from Inactive back to Unconfigured. Override custom_on_cleanup() to release resources and reset internal state.
IO Module#
The ros_sugar.io package handles typed topic communication.
Topic#
Topic is a descriptor that binds a ROS topic name to a SupportedType. It carries the topic name (a leading / is stripped), message type (a class or its name as a string), QoS profile, data_timeout (how long the event system holds a message before treating it as stale), and use_plugin: True binds the topic to the robot plugin, a string binds it to the plugin with that id, False keeps it a plain ROS topic. Topics are declared on components as inputs and outputs and are wired during activation.
Publisher#
Publisher wraps rclpy.publisher.Publisher and adds the SupportedType.convert() step so that components can publish Python-native data (e.g., numpy arrays) without manually constructing ROS messages. After conversion it stamps the header with the node clock and the frame_id passed to publish(). Pre-processors can be attached to transform data before conversion.
SupportedType and callbacks#
SupportedType is the base class for the type system. Each subclass maps a ROS message type (_ros_type), a callback class that turns messages into Python data (callback), a conversion function that produces the ROS message from Python data (convert), a UI streaming mode (_ui_rate_sampled), and optional shared-memory hooks for large payloads. The sensor callbacks return the containers in ros_sugar.io.datatypes (LaserScanData, PointCloudData, CameraIntrinsics). See Extending the Type System for details on extending it.
Frames#
A component asks for an input in a given frame with transform_input_to(topic_name, goal_frame). The source frame is read from each message header, so nothing about sensor frames is configured; the component’s callback is handed a transform resolver, and the spatial callbacks return their data already transformed. All frame pairs share one TF buffer per component, so the node subscribes to /tf and /tf_static exactly once. Frame names come from config.frames (RobotFrames: robot_base and world), which the Launcher sets from the recipe or from the robot plugin.
Robot Plugins#
A plugin adapts a recipe to specific hardware without changing component code. A RobotPlugin (exactly one per recipe) or SensorPlugin (any number) declares transports (ROS topic, ROS service, UDP, HTTP, vendor SDK), feedbacks (decoders producing ROS messages), commands (encoders producing wire payloads), action and event factories, and optionally the robot description, the placement of its built-in sensors, the driver processes it depends on, and how the robot is mapped.
At bringup the Launcher runs a HOST for each plugin in its own process: it opens the transports, decodes telemetry once, publishes it on a feedback bus, and feeds the Monitor’s blackboard. Under multithreaded launch the bus is in-process and components consume the HOST’s decoded messages directly. Under multiprocess launch the bus is a Unix socket, each component process rebuilds a CLIENT plugin from a serialized spec, and large feedbacks (images, point clouds) cross the boundary through a shared-memory ring rather than CDR over the socket. Components bind inputs and outputs to plugin feedbacks and commands during activation. See Creating a Plugin.
Callback Groups#
BaseComponent uses ROS 2 callback groups to control concurrency:
MutuallyExclusiveCallbackGroup– Default for service callbacks; ensures serial execution.ReentrantCallbackGroup– Used when the component needs concurrent subscription callbacks (e.g., multiple sensor streams processed in parallel).
The callback group can be specified at construction via the callback_group parameter.
Key Decorators#
@component_action#
Defined in ros_sugar.utils.component_action. Marks a method as an action that can be dispatched by the event system. The decorator enforces:
The method belongs to a
LifecycleNodeinstance.The method is annotated to return
Tuple[bool, str](aliased asActionReturnType).If
active=True, the component must be in the Active lifecycle state.
Every action returns (success, message): the bool reports success or failure, the string carries a
result on success or an error on failure. The annotation is checked at decoration time, so a
component that does not follow the contract fails at import.
Can be used bare (@component_action) or with parameters (@component_action(description={...}, active=True)). The optional description parameter accepts an OpenAI-compatible tool/function description dict, used when actions are exposed as tools to an orchestrating LLM.
from ros_sugar.utils import ActionReturnType, component_action
class MyComponent(BaseComponent):
@component_action
def stop_motors(self) -> ActionReturnType:
# ... stop logic ...
return True, "motors stopped"
@component_action(description={
"type": "function",
"function": {
"name": "stop_motors",
"description": "Immediately stop all motors.",
},
})
def stop_motors_with_desc(self) -> ActionReturnType:
...
@component_fallback#
Defined in ros_sugar.utils.component_fallback. Marks a method as a fallback handler. The decorator verifies that rclpy is initialized and the component is at least in the Inactive state (i.e., configured or active). This allows fallbacks to fire even when the component has been deactivated due to an error.
Like @component_action, it can be used bare or with a description parameter for LLM tool descriptions.
from ros_sugar.utils import ActionReturnType, component_fallback
class MyComponent(BaseComponent):
@component_fallback
def restart(self) -> ActionReturnType:
self.trigger_deactivate()
self.trigger_activate()
return True, "component restarted"
@action_handler#
Defined in ros_sugar.utils.action_handler. Used internally to validate that a function returns SomeEntitiesType (the ROS launch entity type). This is primarily for functions that integrate directly with the launch event system.
Monitor Orchestration#
At runtime the Monitor operates a tight evaluation loop:
Receive – Subscription callbacks, and
feed_external_topiccalls from plugin HOSTs, write incoming messages into a sharedDict[str, EventBlackboardEntry](the “blackboard”). Each entry carries a UUID and timestamp for staleness detection.Evaluate – For every registered
Event,Monitorcallsevent.check_condition(blackboard). TheConditiontree is evaluated against the cached topic messages. Composite conditions (AND / OR / NOT viaConditionLogicOp) are resolved recursively.Trigger – If a condition evaluates to
True, the event’s registered actions are submitted to a sharedThreadPoolExecutorfor non-blocking execution.Emit – For actions that must be handled at the launch level (lifecycle transitions, process restarts), the
Monitoremits anInternalEventwhich is caught by anOnInternalEventhandler registered by theLauncher.
Launcher Process Graph#
The Launcher supports two execution modes, chosen per add_pkg call:
Multi-Threaded#
All components run in the same process. Each component gets its own callback group. The Launcher uses a MultiThreadedExecutor to spin all nodes concurrently. This is simpler but shares a single fault domain. Plugin feedback uses an in-process bus, and a launch_prefix set on such a component has no effect (the launcher warns).
Multi-Process#
Each component is launched as a separate ROS 2 process via ExecuteProcess. The Launcher communicates with components through ROS services and the Monitor’s topic subscriptions. This provides process isolation – a crash in one component does not bring down the others, and on_process_fail can respawn it. Plugin feedback uses a socket bus with the shared-memory fast path for large messages, and launch_prefix applies.
External nodes added with add_ros_node, launch files included with include_launch_file, and driver processes declared by plugins always run in their own processes, in either mode.
In both modes, the Monitor node runs in the main launcher process and coordinates lifecycle transitions via LifecycleTransition launch actions.