Skip to main content
STUB DOCUMENT: Expanded technical reference aligned with cyberwave-python; replace the video placeholder when your walkthrough URL is ready.

What BaseROS2Driver is

BaseROS2Driver (cyberwave.driver.ros2) combines:
  • BaseDriver — Cyberwave API/MQTT, twin binding, interface registry, lifecycle alerts, driver_info telemetry
  • rclpy.lifecycle.Node — standard ROS 2 lifecycle transitions
Import:
Typical goal: stream ROS topics (joint states, odometry, custom msgs) into Cyberwave MQTT with minimal driver code — often under 100 lines for a forward-only bridge. Reference: ursim_driver.py.
Video walkthrough — Add your hosted recording when available:
[UR Sim → Cyberwave: build a ROS 2 driver in minutes](VIDEO_URL_HERE)

Process architecture (two event loops)

BaseROS2Driver.run() is not a plain BaseDriver.run(): ROS callbacks (e.g. /joint_states) run on the executor thread; they asyncio.run_coroutine_threadsafe into the driver loop to publish MQTT without blocking rclpy.

The ROS 2 lifecycle (managed) node

BaseROS2Driver is a ROS 2 managed node — it subclasses rclpy.lifecycle.Node (a LifecycleNode) rather than a plain rclpy.node.Node. A managed node does not start doing work the moment it is constructed; it advances through an explicit, tool-controllable state machine so an orchestrator (or, here, the Cyberwave lifecycle) can bring hardware up and down deterministically. The four primary states (ROS 2 design REP-2007): Transitions (the verbs that move between states) are where your code runs. rclpy calls a on_<transition>(state) method for each; returning SUCCESS advances the state, FAILURE/ERROR does not:
Key consequences for driver authors:
  • create_publisher on a LifecycleNode returns a lifecycle publisher that only actually transmits while the node is Active. Publishing in Inactive is silently dropped — this is by design, not a bug.
  • rclpy owns the on_configure(state) / on_activate(state) / on_deactivate(state) / on_shutdown(state) methods. You must not redefine them with a different signature — that is exactly why Cyberwave exposes the prefix-dropped configure() / activate() / … hooks (see Why hook names drop the on_ prefix below).
  • Who triggers transitions? Either an external lifecycle manager (ros2 lifecycle set <node> configure) or, more commonly here, the driver itself when CW_ROS2_AUTO_ACTIVATE=true (it calls its own ~/change_state service after the cloud connect). Without auto-activate the node sits in Unconfigured until something activates it.
  • on_cleanup is treated as fatal in this base — recovering an edge device in-process is unreliable, so the container is expected to restart to reconfigure (see the transition table below).
This managed-node machine is the ROS half of the picture. It runs in parallel with the Cyberwave lifecycle (DriverLifecycleState), and the two are coordinated — that coordination is the next section.

Two lifecycles — who calls what

You maintain two parallel state machines. They are ordered: Cyberwave connect and on_configure run before the driver waits for ROS ACTIVE; MQTT + from_ros wiring runs after ROS activate signals ready.

Cyberwave side (run_async — do not override)

ROS side (rclpy — do not override on_configure(state), etc.)


Why hook names drop the on_ prefix

rclpy.lifecycle.Node already defines on_configure(state), on_activate(state), … with a state argument. A no-arg def on_configure(self) in your subclass would break ROS lifecycle. Cyberwave on_configure, on_connect_to_device, … still exist on the class for BaseDriver’s ABC, but BaseROS2Driver routes them through _BaseDriverAsyncHooks — you normally do not implement them for forward-only drivers. Event hooks keep on_ where there is no conflict: on_topic_name_changed(topic_entry, new_name).

Required vs optional (checklist)

Minimum for ROS → Cyberwave streaming (UR Sim style)

You do not need:
  • Manual MQTT publish loops for from_ros entries
  • Custom JointState → dict conversion (SDK default)
  • Overriding rclpy on_activate(state)
  • Implementing Cyberwave abstract on_configure async methods (mixin provides them)

When you need more


define_interface and from_ros

add_publisher with ROS source

What wire_ros_publishers() does at runtime

  1. Lists registry entries with from_ros for current operation_mode
  2. resolve_ros_message_class — polls ROS graph (or uses msg_type= override)
  3. Creates create_subscription on the ROS topic
  4. On each message:
    • If no user callback and type is sensor_msgs/msg/JointStateros_joint_state_to_transport_payload
    • Else if user callback → must return dict
    • Else → ros_message_to_transport_payload (generic field flattening + timestamp, source_type)
  5. run_coroutine_threadsafe → publish to resolved MQTT slug (and Zenoh if dual spec)
Logs (throttled ~5s):
  • ROS forward: /joint_states (sensor_msgs/msg/JointState) -> Cyberwave MQTT
  • ROS RX /joint_states: N message(s) — publishing to Cyberwave MQTT (6 joints)

Joint /update payload shape (default)

Flat keys for Vector / twin telemetry compatibility:
Nested positions: { name: value } is available via ros_joint_state_to_transport_payload(..., aggregated=True) if you supply a custom callback.

Source-type convention

Notice the "source_type": "edge" field above. This is the platform-wide convention every driver follows (full rules on the BaseDriver page):
  • from_ros publishers stamp edge automatically (physical feedback). When the driver runs against a simulator instead of hardware, publish sim instead.
  • Command listeners (add_listener + ros_publish) should accept teleop sources — tele, edit, sim_tele — and must never act on edge* messages, or the driver re-ingests its own joint feedback and fights itself. Declare the accepted set with ProtocolArgs(source_types=[...]).
  • Inbound messages without a source_type are treated leniently (accepted as commands) — not every producer stamps the field — but the edge* self-echo guard always applies.
A topic registered as both a from_ros publisher (edge) and a command listener (tele/edit/sim_tele) — e.g. joint/update on an arm — is the canonical case where the edge* rejection matters.

CW_ROS2_AUTO_ACTIVATE

UR Sim example sets default in main():
Without auto-activate you must run a lifecycle manager (ros2 lifecycle set …) while the executor is spinning.

Operation modes drive the ROS lifecycle

BaseROS2Driver ties Cyberwave operation modes to ROS lifecycle transitions. When _set_operation_mode runs, the ROS enter-hooks request a change_state transition on this node: So a twin with no controller assigned starts in NO_OP: control is idle, but the node stays warm. Because _set_operation_mode is a no-op when the mode is unchanged, a forward-only bridge (no controller) never deactivates — it remains in its initial NO_OP and keeps streaming.
Edge ROS→Cyberwave forwarders are independent of operation mode. wire_ros_publishers() runs as soon as ROS reaches ACTIVE, so from_ros joint/telemetry streams flow even in NO_OP. Declare them with operation_modes=frozenset(DriverOperationMode).
Override the enter-hooks (or on_controller_assigned / on_controller_removed) to add hardware enable/disable on top of the lifecycle transition — e.g. powering an arm when a controller attaches.

Managed vendor launch (managed_launch)

A driver can have the SDK spawn and supervise a vendor ros2 launch (e.g. a robot’s stock bringup) as a child process, instead of you starting it separately. Declare it in define_node_manifest():
At ROS activate, ManagedRosLaunch starts the child, waits for readiness, and stops it on shutdown. Setup scripts are sourced into the driver process so vendor message typesupport imports correctly.
The SDK is distro-agnostic: it does not default a ROS distro or guess a workspace path. Setup scripts come only from the manifest (ros_setup / ros_overlay) and the ROS_SETUP / ROS_SETUP_OVERLAY env vars. Declare the distro path in your driver’s manifest (it is vendor-specific), not in the SDK.

ROS stream → Cyberwave rate limiting

ROS sensor topics often exceed 100 Hz. The from_ros forwarder and any custom ROS callback can throttle outbound Cyberwave publishes with a per-topic cap (default 50 Hz):
This wraps BaseDriver.acquire_stream_publish_slot with a ROS-topic key. See BaseDriver → Stream publish rate limiting.

Combined manifest (ROS params + MQTT catalog)

BaseROS2Driver exports a single manifest.yaml that merges the ROS node manifest (params, topics, managed_launch) with the compiled-from-code MQTT cw-driver catalog: Export it without running the driver — typically a CLI subcommand and a Docker build step:
Do not hand-edit the result: change Python (define_node_manifest + define_interface) and re-export. At runtime, point the driver at it with CW_DRIVER_MANIFEST.

Construction and entrypoint

Node name / namespace can be overridden via ROS env helpers (node_name_from_env, node_namespace_from_env).

Environment variables

Cyberwave (inherited from BaseDriver)

Backend lifecycle alerts are always synced after connect (not env-configurable). Set ASSET_KEY on the driver class (not via env).

ROS 2 driver specific

Manifest may declare tick_rate_hz ROS parameter (integer) — drives ROS-side tick() timer. Per-parameter overrides also follow the SDK CW_ROS2_* env convention.
The SDK no longer defaults ROS_SETUP to a specific distro. For managed_launch drivers, set ros_setup in the manifest (recommended) or export ROS_SETUP / ROS_SETUP_OVERLAY.

ROS hooks — what to put in each

configure()

  • Parse non-hardware config, allocate buffers
  • Do not open device connections (that is connect_to_device())
  • rclpy on_configure already declared manifest parameters

connect_to_device()

  • Open robot/sim connection if needed
  • For UR Sim + ur_robot_driver, often empty — another node publishes /joint_states

register_callbacks()

  • Device-native ROS subscriptions not covered by from_ros
  • Example: subscribe to a custom diagnostic topic with a Python callback

activate()

  • Create ROS publishers you control manually
  • Start control loops
  • from_ros wiring happens after this in run_async — do not rely on MQTT forwards during activate() itself

tick()

  • Fast periodic work on ROS timer
  • Exceptions are logged; timer keeps running

shutdown()

  • Destroy publishers/subscriptions you created
  • Complement ROS on_shutdown transition

Cyberwave → ROS (commands)

ros_publish caches publishers per (topic, msg_type).
Thread affinity. MQTT listener callbacks run on the asyncio loop thread, but rclpy node operations (create_publisher, publish, create_subscription, get_clock) belong on the executor thread (two event loops). Calling ros_publish directly from a listener works for one-shot, low-rate commands, but for continuous teleop / high-rate command streams — or any publisher on a lifecycle node — the safe pattern is to queue in the listener and publish from tick() (which runs on the executor thread):
This keeps all rclpy calls on the thread that owns the node and avoids cross-thread access to the publisher cache.

Reference driver: UR Sim (ursim_driver.py)

Full listing (~75 lines) in the monorepo. Structure:
main() sets logging, CW_ROS2_AUTO_ACTIVATE, reads optional manifest/node env, calls driver.run().

Lab setup (UR Sim)

End-to-end Docker, pendant program, ur_robot_driver, and running ursim_driver.py: UR Sim tutorial. Protocol layers (TCP, ROS DDS, MQTT): Custom integrations.

from_ros vs other patterns


Troubleshooting


BaseDriver

Cyberwave-only lifecycle, registry, manifests, alerts.

Writing compatible drivers

cw-driver.yml, env contract, fake-IMU tutorial.

Driver alerts

Alert types during connect and failures.