ros2_client
A type-safe, streaming ROS 2 client for Dart and Flutter.
Talks to a robot through rosbridge_suite
over a single WebSocket, so it runs everywhere Dart runs — Android, iOS, Linux,
macOS, Windows and the browser — with no ROS installation on the client.
final ros = Ros2Client(Uri.parse('ws://192.168.1.10:9090'));
await ros.connect();
// Typed streams, not Map<String, dynamic>.
ros.subscribe<LaserScan>('/scan', qos: QosProfile.sensorData)
.listen((scan) => print('${scan.ranges.length} beams'));
final cmdVel = ros.advertise<Twist>('/cmd_vel');
cmdVel.publish(Twist.drive(forward: 0.2, turn: 0.1));
Why another ROS package
Every ROS package on pub.flutter-io.cn is unmaintained, ROS 1 only, or passes untyped maps around. This one aims at the gaps that actually bite in production:
| Existing packages | ros2_client |
|
|---|---|---|
| Messages | Map<String, dynamic> |
Generated value types with == and toString |
| ROS 2 actions | ✗ | Goal → feedback → result → cancel |
| QoS | ✗ | Full profiles, incl. sensorData preset |
| Reconnection | ✗ | Backoff + jitter, auto re-subscribe |
| Binary payloads | base64 JSON | CBOR typed arrays, zero per-element copy |
| Introspection | partial | Topics, nodes, services, actions, params |
| API style | callbacks | Streams and Futures |
Setup
On the robot:
sudo apt install ros-humble-rosbridge-suite
ros2 launch rosbridge_server rosbridge_websocket_launch.xml
In your app:
import 'package:ros2_client/ros2_client.dart';
void main() {
registerStandardMessages(); // once, before the first subscribe
...
}
The two things that trip everyone up
1. QoS mismatch. A reliable subscriber never matches a best-effort publisher, and you receive nothing — no error, no warning. Sensor topics (camera, lidar, IMU) are almost always best-effort:
ros.subscribe<LaserScan>('/scan', qos: QosProfile.sensorData);
Latched topics like /map and /robot_description need
QosProfile.transientLocal.
2. JSON silently destroys inf and nan. rosbridge serialises every
non-finite float as null, so over JSON an out-of-range lidar beam and an
invalid one become indistinguishable. Measured on the same publisher:
| Encoding | inf | nan | finite |
|---|---|---|---|
none (JSON) |
0 | 26 | 289 |
cbor |
16 | 10 | 289 |
JSON also base64-encodes uint8[], costing 26% more bytes on the wire
(measured). So for sensor data, ask for CBOR — it is a correctness choice, not
just a performance one:
ros.subscribe<LaserScan>('/scan',
qos: QosProfile.sensorData, compression: Compression.cbor);
Typed arrays then decode straight into Uint8List / Float32List.
Point clouds
PointCloud2 is a binary blob plus a description of its layout. Nothing
decodes it into objects — a 100k-point cloud at 10 Hz would be a million
allocations a second, which is why point cloud rendering in a naive client is
unusable. reader() reads values in place, out of the same buffer the socket
delivered:
final reader = cloud.reader();
// Interleaved [x0,y0,z0, x1,y1,z1, ...], the layout a vertex buffer wants.
// stride subsamples: a 200k-point cloud at stride 8 is 25k points, usually
// indistinguishable on screen and eight times cheaper to draw.
final points = reader.xyz(stride: 8);
// Any field by name, whatever its declared datatype.
final power = reader.readFloat('intensity', 0);
Non-finite points are skipped by xyz(): a cloud that is not is_dense uses
NaN to mean "no return", and plotting those puts garbage at the origin.
Integer fields are widened on read, so intensity behaves the same whether a
driver declares it float32 or uint16. A cloud whose data is shorter than
its header claims — a truncated message — reports the points that actually
arrived rather than throwing, since a partial cloud is still worth drawing.
CBOR has a hard size ceiling, and crossing it is silent
A CBOR message larger than the bridge's max_message_size is never
delivered. This is not opt-in and there is no client-side workaround.
Protocol.__init__ sets self.fragment_size = self.max_message_size, so
fragmentation is always armed. Anything bigger is handed to
Fragmentation.fragment, which re-serialises the already-encoded CBOR payload
with json.dumps, fails (reject_bytes is on and '...' is bytes), and sends
nothing — logging only on the robot's own console. The subscription is
accepted and simply never produces a message. Verified against rosbridge 2.0.7:
with max_message_size:=200000, a 640x480 rgb8 image arrives fine over
Compression.none and never arrives at all over Compression.cbor.
The ceiling is 1 MB for a bare rosbridge_websocket node and 10 MB
under the shipped launch file. A 640x480 rgb8 image is 900 KB of CBOR; 1080p
is ~6 MB; 4K and full-resolution point clouds exceed both. So the exact topics
CBOR exists for are the ones that hit it.
Raise the limit on the bridge:
ros2 launch rosbridge_server rosbridge_websocket_launch.xml \
max_message_size:=50000000
Or drop to Compression.none for that one topic — JSON fragments correctly, so
it works at any size, just larger on the wire and slower to decode. Lowering
fragment_size does not help: min(requested, max_message_size) caps it, so
it only moves the cliff closer.
Fragmentation and CBOR do not mix
fragment_size works over JSON — a 1.2 MB image at 64 KB a fragment
reassembles byte-exact — but rosbridge cannot fragment a CBOR message. Its
Fragmentation.fragment re-serialises the already-encoded payload with
json.dumps, which refuses it (reject_bytes is on and '...' is bytes), and
the bridge then delivers nothing while logging the failure only on its own
console. Verified against rosbridge 2.0.7: the subscription is accepted and
simply never produces a message.
subscribe throws an ArgumentError for that combination rather than letting
you wait forever. In practice you rarely want both anyway — a CBOR payload is
already far smaller than the JSON it replaces.
Performance
benchmark/wire_benchmark.dart measures decode throughput on realistically
sized messages. Dart VM, JIT, one core:
| Message | JSON | CBOR | Wire size |
|---|---|---|---|
| 640x480 rgb8 image | 62 msg/s | 160 msg/s | 1200 KB -> 900 KB |
| 1920x1080 rgb8 image | 20 msg/s | 77 msg/s | 8100 KB -> 6075 KB |
| 1080-beam LaserScan | 8464 msg/s | 28395 msg/s | 17 KB -> 4 KB |
| 64k-point cloud | 88 msg/s | 240 msg/s | 2731 KB -> 2048 KB |
Run it yourself with dart run benchmark/wire_benchmark.dart.
The gap is entirely about avoiding copies. A CBOR byte string is handed to you
as a Uint8List view over the frame the socket delivered — decoding a 1080p
image touches no pixel — and an RFC 8746 typed array becomes a Float32List
the same way. Getting there took reading the frame as a CborValue tree rather
than calling toObject(), which turns a byte string into boxed integers; that
one difference was making CBOR five times slower than the base64 JSON path it
is supposed to beat.
Verify it against your own robot with example/real_sensor_check.dart, which
checks that a real bridge's images and point clouds arrive byte-exact and
uncopied.
Features
Topics
// Typed.
final sub = ros.subscribe<Odometry>('/odom', throttleRate: 200).listen(print);
await sub.cancel(); // unsubscribes from the bridge
// Untyped, for topics discovered at runtime.
ros.subscribeJson('/some/topic', type: 'pkg/msg/Type').listen(print);
// Publishing.
final pub = ros.advertise<Twist>('/cmd_vel');
pub.publish(Twist.stop);
pub.close();
ros.publishOnce<StringMsg>('/chatter', const StringMsg('hi'));
Services
final result = await ros.callServiceJson('/add_two_ints', {'a': 2, 'b': 3});
print(result['sum']);
Actions
Requires rosbridge_suite >= 2.0.0.
final goal = ros.sendGoal<NavigateToPoseGoal, NavFeedback, NavResult>(
'/navigate_to_pose', myGoal, codec: navCodec);
goal.feedback.listen((f) => print('${f.distanceRemaining} m to go'));
try {
await goal.result;
} on ActionFailedException catch (e) {
print('goal ${e.status.name}');
}
goal.cancel();
Introspection
for (final topic in await ros.listTopics()) print(topic);
print(await ros.listNodes());
print(await ros.listActionServers());
// ROS 2 parameters are node-scoped: "<node>:<param>".
await ros.setParam('/turtlesim:background_r', 255);
Connection state
ros.states.listen((s) => print(s.name)); // connecting/connected/reconnecting
ros.status.listen((s) => print(s)); // diagnostics from the bridge
Reconnection is automatic and re-issues every subscription and advertisement. Commands published while offline are buffered and replayed.
Code generation
Generate Dart classes for any ROS package's .msg, .srv and .action
definitions, including your own:
source /opt/ros/humble/setup.bash
dart run ros2_client:generate -o lib/msgs sensor_msgs nav2_msgs my_robot_msgs
Then register them once at startup:
import 'msgs/generated.dart';
void main() {
registerStandardMessages();
registerGeneratedMessages();
...
}
Transitive dependencies are pulled in automatically — asking for action_msgs
also generates unique_identifier_msgs, because otherwise the output would not
compile. Packages are found in your sourced ROS install, or pass --search for
a workspace:
dart run ros2_client:generate -s ~/ws/install -o lib/msgs my_robot_msgs
Services and actions become fully typed, with no hand-written codecs:
final sum = await ros.callService<AddTwoIntsRequest, AddTwoIntsResponse>(
'/add_two_ints', const AddTwoIntsRequest(a: 20, b: 22));
final goal = ros.sendGoal<NavigateToPoseGoal, NavigateToPoseFeedback,
NavigateToPoseResult>('/navigate_to_pose', myGoal);
goal.feedback.listen((f) => print('${f.distanceRemaining} m to go'));
await goal.result;
The codec is resolved from the request or goal type alone, so there are no type strings at the call site.
Generated code handles bounded/fixed arrays, constants, nested messages,
and the naming hazards: sensor_msgs/Image becomes RosImage to avoid
material.dart, and a constant clashing with a field (int32 POINTS=8
alongside Point[] points in Marker) becomes pointsConst.
The barrel registers everything but deliberately re-exports nothing: ROS
reuses type names across packages (geometry_msgs/Pose and
turtlesim/Pose), so import the specific library you need.
Verified on 136 messages across 12 real ROS packages, plus nav2_msgs' 11 services and 15 actions.
Bundled types are not regenerated
ros2_client already ships std_msgs, geometry_msgs, sensor_msgs and
nav_msgs core types. Generating a package that references them — and almost
every package references std_msgs/Header — emits a reference to the bundled
class rather than a second copy:
import 'package:ros2_client/codegen_support.dart';
import 'package:ros2_client/ros2_client.dart' as ros2;
final class Costmap implements RosMessage {
final ros2.Header header; // the class you already had
...
}
This is not just tidiness. Two classes registering a codec for one ROS type
name shadow each other — MessageRegistry.byRosType resolves to whichever
registered last — and the widgets in ros2_flutter are typed against the
bundled classes, so they cannot accept a generated LaserScan. A package whose
referenced types are entirely bundled is not generated at all.
The barrel is imported under a prefix, because it exports plenty of
non-message names (Ros2Client, TfBuffer, Compression) that a custom ROS
package is free to collide with.
Pass --no-bundled for a fully self-contained set that depends on nothing but
codegen_support.dart.
Generating from a live robot
With --from-robot the definitions come from the robot's own rosapi node
over rosbridge, so no ROS install and no source tree are needed on the machine
you develop on:
# Exactly the types this robot is using, on its live topics and services.
dart run ros2_client:generate -o lib/msgs -r ws://robot.local:9090
# Or whole packages, including ones you have no source for.
dart run ros2_client:generate -o lib/msgs -r ws://robot.local:9090 my_robot_msgs
This is the mode for custom interfaces you did not write and cannot easily check out. Messages, services and actions all come through, and nested types are resolved recursively across package boundaries.
rosapi cannot describe a type with a bounded array (T[<=N]) or a bounded
string (string<=N). Its own parser mangles sequence<T, N> into the type
name "T, N" and then either raises internally or reports that as the type —
shape_msgs/SolidPrimitive and rcl_interfaces/ParameterDescriptor both hit
this. The generator refuses to emit a class named after a bound, names the
offending field, and lists any type that was referenced but not generated, so
a broken build is reported rather than shipped. Generate those packages from
source with --search.
Two other things the online path has to correct for, both verified against rosapi 2.0.7 on Humble:
rosapireports the IDL spelling of primitives —double,float,boolean,octet— where.msgfiles sayfloat64,float32,bool,byte. Taken literally, everyfloat64field would generate a reference to a nested class nameddouble.- its constant list is not a constant list. It walks
inspect.getmembers, so it returns every field name with its default value, plusSLOT_TYPES, whose value is a Python repr containing a memory address. Only names that are not fields survive.
Hand-written messages
You can also register a codec by hand, which is all the generator does:
final class Waypoint implements RosMessage {
const Waypoint(this.name, this.x, this.y);
factory Waypoint.fromJson(Map<String, Object?> j) =>
Waypoint(Field.asString(j['name']), Field.asDouble(j['x']),
Field.asDouble(j['y']));
final String name;
final double x, y;
@override
String get rosType => 'my_msgs/msg/Waypoint';
@override
Map<String, Object?> toJson() => {'name': name, 'x': x, 'y': y};
}
MessageRegistry.register(const MessageCodec<Waypoint>(
rosType: 'my_msgs/msg/Waypoint',
fromJson: Waypoint.fromJson,
toJson: _waypointToJson,
));
Naming
Image, Path, Transform and ConnectionState collide with
package:flutter/material.dart, so they ship as RosImage, RosPath,
RosTransform and RosConnectionState. Everything else keeps its ROS name.
Testing
dart test # unit tests, no ROS needed
dart test -t integration # end-to-end against a local WebSocket server
Transforms (tf2)
Frame lookups, with time interpolation and the same tree walk tf2 does:
final tf = TfListener(ros)..start();
await tf.waitForTransform('map', 'base_link');
// Where is the robot on the map?
final pose = tf.buffer.lookupOrThrow('map', 'base_link');
print('${pose.translation.x}, ${pose.translation.y}, yaw ${pose.rotation.yaw}');
// Move a lidar hit into the map frame.
final inMap = tf.buffer.transformPoint(hit, 'map', 'laser');
/tf_static is latched, so TfListener subscribes to it with
transientLocal durability — with the default profile a late-joining app
receives no static transforms at all and every fixed frame appears missing.
lookup returns null on failure; lookupOrThrow explains why (unknown
frame, disconnected trees, or a timestamp outside the buffered window), and
buffer.describe() prints the tree:
map
odom (37 samples)
base_link (37 samples)
laser (static)
Transform maths is available on the message types directly —
Quaternion.fromYaw, .fromRpy, .yaw, .rpy, .slerp, .rotate(v), and
RosTransform.compose, .inverse, .transformPoint, .transformPose.
For rendering, RosTransform.toMatrix4() gives the 4x4 homogeneous matrix as
a column-major Float64List — the layout Matrix4.fromFloat64List and
OpenGL expect, so nothing needs transposing. It returns a Float64List rather
than a Matrix4 to keep this package free of a vector_math dependency.
Flutter apps should reach for TfFrameBuilder in package:ros2_flutter
instead of driving a TfListener by hand: it shares one listener across the
whole widget tree and rebuilds only when the transform actually changes.
Backpressure
A robot publishes on its own schedule. When a subscription's consumer falls behind, Dart buffers the backlog and delivers all of it later — which for sensor data is wrong twice over: the app pays to decode messages whose moment has passed, then renders them late.
ros.subscribe<LaserScan>('/scan',
qos: QosProfile.sensorData,
backpressure: Backpressure.latest); // keep only the newest
ros.subscribe<Odometry>('/odom',
backpressure: Backpressure.dropOldest(20)); // a bounded tail
ros.subscribe<StringMsg>('/events'); // buffer, the default
The strategy applies only to undelivered messages: a consumer that keeps up
never loses anything, and nothing is dropped while it is idle. The signal is
the subscription being paused, which is what await for and StreamBuilder
both produce.
Messages dropped this way are never decoded — they are held as raw bodies and discarded before the codec runs. That is where the saving is; conflating after decode would already have paid for every frame.
RosTopicBuilder in package:ros2_flutter defaults to Backpressure.latest,
because a widget draws the newest value and nothing else. Pass
Backpressure.buffer if you are accumulating rather than displaying.
Compression.cborRaw is refused for a typed subscription. It does not
compress the message, it replaces it: rosbridge substitutes msg with
{secs, nsecs, bytes} holding raw CDR, which this client has no decoder for.
Every field would read back as its type default, forever, with no error. Use
subscribeJson if you want those bytes.
rosbridge merges options across every listener on a topic, rather than
honouring them per subscription: throttle_rate and queue_length become the
min(), and compression the strongest requested. So a second, unthrottled
subscriber turns throttling off for the widget that asked for it. The client
warns on status when a new subscription would do that, since the effect is
otherwise invisible.
Buffering, latching and other things that only look like they work
Publishes are buffered across a reconnect — unless you say they perish. The outbox keeps up to 256 commands while offline and replays them on reconnect, which is right for a goal pose or a mode change and dangerous for a velocity. Advertise command topics accordingly:
final cmd = ros.advertise<Twist>('/cmd_vel', perishable: true);
Without that, a stalled link fills the buffer with motion commands and the
robot is handed seconds of stale motion the moment it recovers — after the
operator has let go. The buffer now drops the oldest on overflow, so the
most recent command always survives, and warns once on status.
latch: true now actually latches. rosbridge documents its latch flag as
"ignored if qos is provided", and this client always provides qos — so the
flag alone never did anything, and late joiners to /map or
/robot_description received nothing, forever. Latching in ROS 2 is
transient-local durability, so that is what gets sent. An explicitly
transient-local profile is left alone.
setParam reads the value back. rosapi/set_param has an empty response
section — there is no successful field — and rosapi_node swallows every
error, so the service answers identically whether the node exists, the
parameter exists, the type matches, or none of the above. setParam therefore
verifies by reading back, and returns what actually happened. Measured cost
against a live node: 31 ms. It is slow (~5 s) only when the node does not
exist, because rosapi waits for a reply that never comes. Pass verify: false
for the old fire-and-forget behaviour, which always returns true.
publishOnce while offline is discarded, loudly. The advertise and
unadvertise around it are rebuilt from live state on reconnect rather than
buffered, so replaying the publish alone would send it to a topic this client
never advertised. Hold a publisher from advertise() if the message must
survive a reconnect.
Fragmented messages all share the id "0". rosbridge's
fragmentation_seed increments an instance attribute on an object rebuilt per
send, so the counter is read as 0 every time. Two topics fragmenting at once
interleave under one id; the client detects the repeated index, discards the
partial message rather than splicing a corrupt one, and warns once.
Parameters
ROS 2 parameters belong to a node, so names are <node>:<param>:
await ros.setParam('/turtlesim:background_r', 255);
final value = await ros.getParam('/turtlesim:background_r'); // 255
listParams() is a different story: rosapi queries every node and takes 20+
seconds, and returns an empty list unless the bridge was launched with a glob:
ros2 launch rosbridge_server rosbridge_websocket_launch.xml params_glob:="[*]"
Prefer getParam/setParam when you know the name — they respond in
milliseconds and need no glob.
Verified against a real robot
Tested against rosbridge_suite 2.0.7 on ROS 2 Humble with turtlesim:
16/16 checks pass, covering introspection, typed and custom messages,
publishing, services, actions with feedback, parameters, QoS and throttling.
dart run example/real_bridge_check.dart
Status
Pre-1.0; the API may change. See the roadmap.
Libraries
- codegen_support
- The minimal surface generated message libraries depend on.
- ros2_client
- A type-safe, streaming ROS 2 client for Dart and Flutter.