From e63564e9adda5ee7a99d4a48ec7ad0a2d2049d14 Mon Sep 17 00:00:00 2001 From: Wayne Parrott <5588978+wayneparrott@users.noreply.github.com> Date: Tue, 21 Feb 2023 09:43:01 -0600 Subject: [PATCH 1/4] ROS Humble introduced the content-filtering topics feature. This PR makes makes this feature available to rclnodejs developers. node.js - added contentFilter to Options - added static getDefaultOptions() - updated createSubscription() to support contentFilter node.d.ts - added content-filter types rcl_bindings.cpp - added content-filtering to CreateSubscription() rmw.js - new class for identifying the current ROS middleware test-subscription-content-filter.js - test cases for content-filters examples: - publisher-content-filtering-example.js - subscription-content-filtering-example.js package.json - added build/rebuild scripts for convenience --- docs/EFFICIENCY.md | 42 ++- example/publisher-content-filter-example.js | 53 ++++ .../subscription-content-filter-example.js | 71 +++++ index.js | 4 + lib/distro.js | 2 +- lib/node.js | 30 +- lib/rmw.js | 29 ++ lib/subscription.js | 26 +- package.json | 10 +- src/rcl_bindings.cpp | 74 ++++- test/test-subscription-content-filter.js | 262 ++++++++++++++++++ types/node.d.ts | 36 ++- types/subscription.d.ts | 10 + 13 files changed, 630 insertions(+), 19 deletions(-) create mode 100644 example/publisher-content-filter-example.js create mode 100644 example/subscription-content-filter-example.js create mode 100644 lib/rmw.js create mode 100644 test/test-subscription-content-filter.js diff --git a/docs/EFFICIENCY.md b/docs/EFFICIENCY.md index b69f6561..3ed1395d 100644 --- a/docs/EFFICIENCY.md +++ b/docs/EFFICIENCY.md @@ -1,7 +1,9 @@ # Tips for efficent use of rclnodejs -While our benchmarks place rclnodejs performance at or above that of [rclpy](https://github.com/ros2/rclpy) we recommend appyling efficient coding and configuration practices where applicable. + +While our benchmarks place rclnodejs performance at or above that of [rclpy](https://github.com/ros2/rclpy) we recommend appyling efficient coding and configuration practices where applicable. ## Tip-1: Disable Parameter Services + The typical ROS 2 node creation process includes creating an internal parameter service who's job is to fulfill requests for parameter meta-data and to set and update node parameters. If your ROS 2 node does not support public parameters then you can save the resources consumed by the parameter service. Disable the node parameter service by setting the `NodeOption.startParameterServices` property to false as shown below: ``` @@ -13,16 +15,48 @@ let node = new Node(nodeName, namespace, Context.defaultContext(), options); ``` ## Tip-2: Disable LifecycleNode Lifecycle Services + The LifecycleNode constructor creates 5 life-cycle services to support the ROS 2 lifecycle specification. If your LifecycleNode instance will not be operating in a managed-node context consider disabling the lifecycle services via the LifecycleNode constructor as shown: ``` let enableLifecycleCommInterface = false; let node = new LifecycleNode( - nodeName, + nodeName, namespace, - Context.defaultContext, + Context.defaultContext, NodeOptions.defaultOptions, - enableLifecycleCommInterface + enableLifecycleCommInterface ); ``` + +## Tip-3: Use Content-filtering Subscriptions + +The ROS Humble release introduced content-filtering topics +which enable a subscription to limit the messages it receives +to a subset of interest. Because content-filtering is performed +at the RMW level, rclnodejs message-processing will never receive +unwanted messages. This feature can greatly reduce the message +processing and memory overhead of your nodes. + +Example: + +``` + // create a content-filter to limit incoming messages to + // only those with temperature > 75C. + const options = rclnodejs.Node.getDefaultOptions(); + options.contentFilter = { + expression: 'temperature > %0', + parameters: [75], + }; + + node.createSubscription( + 'sensor_msgs/msg/Temperature', + 'temperature', + options, + (temperatureMsg) => { + console.log(`EMERGENCY temperature detected: ${temperatureMsg.temperature}`); + } + ); + +``` diff --git a/example/publisher-content-filter-example.js b/example/publisher-content-filter-example.js new file mode 100644 index 00000000..c9e07795 --- /dev/null +++ b/example/publisher-content-filter-example.js @@ -0,0 +1,53 @@ +// Copyright (c) 2017 Intel Corporation. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +'use strict'; + +/* eslint-disable camelcase */ + +const rclnodejs = require('../index.js'); + +async function main() { + await rclnodejs.init(); + const node = new rclnodejs.Node('publisher_content_filter_example_node'); + const publisher = node.createPublisher( + 'sensor_msgs/msg/Temperature', + 'temperature' + ); + + let count = 0; + setInterval(function () { + let temperature = (Math.random() * 100).toFixed(2); + + publisher.publish({ + header: { + stamp: { + sec: 123456, + nanosec: 789, + }, + frame_id: 'main frame', + }, + temperature: temperature, + variance: 0, + }); + + console.log( + `Publish temerature message-${++count}: ${temperature} degrees` + ); + }, 750); + + node.spin(); +} + +main(); diff --git a/example/subscription-content-filter-example.js b/example/subscription-content-filter-example.js new file mode 100644 index 00000000..03bc0aeb --- /dev/null +++ b/example/subscription-content-filter-example.js @@ -0,0 +1,71 @@ +// Copyright (c) 2023 Wayne Parrott. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +'use strict'; + +const { assertDefined } = require('dtslint/bin/util.js'); +const rclnodejs = require('../index.js'); + +/** + * This example demonstrates the use of content-filtering + * topics (subscriptions) that were introduced in ROS 2 Humble. + * See the following resources for content-filtering in ROS: + * @see {@link Node#options} + * @see {@link Node#createSubscription} + * @see {@link https://www.omg.org/spec/DDS/1.4/PDF|DDS 1.4 specification, Annex B} + * + * Use publisher-content-filter-example.js to generate example messages. + * + * To see all published messages (filterd + unfiltered) run this + * from commandline: + * + * ros2 topic echo temperature + * + * @return {undefined} + */ +async function main() { + await rclnodejs.init(); + const node = new rclnodejs.Node('subscription_message_example_node'); + + // create a content-filter to limit incoming messages to + // only those with temperature > 75C. + const options = rclnodejs.Node.getDefaultOptions(); + options.contentFilter = { + expression: 'temperature > %0', + parameters: [75], + }; + + let count = 0; + let subscription; + try { + subscription = node.createSubscription( + 'sensor_msgs/msg/Temperature', + 'temperature', + options, + (temperatureMsg) => { + console.log(`Received temperature message-${++count}: + ${temperatureMsg.temperature}C`); + } + ); + } catch (error) { + console.error('Unable to create content-filtering subscription.'); + console.error( + 'Please ensure your content-filter expression and parameters are well-formed.' + ); + } + + node.spin(); +} + +main(); diff --git a/index.js b/index.js index 2dd532d6..803d5b99 100644 --- a/index.js +++ b/index.js @@ -15,6 +15,7 @@ 'use strict'; const DistroUtils = require('./lib/distro.js'); +const RMWUtils = require('./lib/rmw.js'); const { Clock, ROSClock } = require('./lib/clock.js'); const ClockType = require('./lib/clock_type.js'); const compareVersions = require('compare-versions'); @@ -136,6 +137,9 @@ let rcl = { /** {@link QoS} class */ QoS: QoS, + /** {@link RMWUtils} */ + RMWUtils: RMWUtils, + /** {@link ROSClock} class */ ROSClock: ROSClock, diff --git a/lib/distro.js b/lib/distro.js index 3122331c..d36ea946 100644 --- a/lib/distro.js +++ b/lib/distro.js @@ -42,7 +42,7 @@ const DistroUtils = { * @return {number} Return the rclnodejs distro identifier */ getDistroId: function (distroName) { - const dname = distroName ? distroName : this.getDistroName(); + const dname = distroName ? distroName.toLowerCase() : this.getDistroName(); return DistroNameIdMap.has(dname) ? DistroNameIdMap.get(dname) diff --git a/lib/node.js b/lib/node.js index d4a6af74..3dbc61a4 100644 --- a/lib/node.js +++ b/lib/node.js @@ -464,12 +464,7 @@ class Node extends rclnodejs.ShadowNode { } if (options === undefined) { - options = { - enableTypedArray: true, - isRaw: false, - qos: QoS.profileDefault, - }; - return options; + return Node.getDefaultOptions(); } if (options.enableTypedArray === undefined) { @@ -484,6 +479,10 @@ class Node extends rclnodejs.ShadowNode { options = Object.assign(options, { isRaw: false }); } + if (options.contentFilter === undefined) { + options = Object.assign(options, { contentFilter: undefined }); + } + return options; } @@ -608,7 +607,7 @@ class Node extends rclnodejs.ShadowNode { */ /** - * Create a Subscription. + * Create a Subscription with optional content-filtering. * @param {function|string|object} typeClass - The ROS message class, OR a string representing the message class, e.g. 'std_msgs/msg/String', OR an object representing the message class, e.g. {package: 'std_msgs', type: 'msg', name: 'String'} @@ -617,9 +616,17 @@ class Node extends rclnodejs.ShadowNode { * @param {boolean} options.enableTypedArray - The topic will use TypedArray if necessary, default: true. * @param {QoS} options.qos - ROS Middleware "quality of service" settings for the subscription, default: QoS.profileDefault. * @param {boolean} options.isRaw - The topic is serialized when true, default: false. + * @param {object} [options.contentFilter=undefined] - The content-filter, default: undefined. + * @param {string} options.contentFilter.expression - Specifies the criteria to select the data samples of + * interest. It is similar to the WHERE part of an SQL clause. + * @param {string[]} [options.contentFilter.parameters=undefined] - Array of strings that give values to + * the ‘parameters’ (i.e., "%n" tokens) in the filter_expression. The number of supplied parameters must + * fit with the requested values in the filter_expression (i.e., the number of %n tokens). default: undefined. * @param {SubscriptionCallback} callback - The callback to be call when receiving the topic subscribed. The topic will be an instance of null-terminated Buffer when options.isRaw is true. * @return {Subscription} - An instance of Subscription. + * @throws {ERROR} - May throw an RMW error if content-filter is malformed. * @see {@link SubscriptionCallback} + * @see {@link https://www.omg.org/spec/DDS/1.4/PDF|DDS 1.4 specification, Annex B} */ createSubscription(typeClass, topic, options, callback) { if (typeof typeClass === 'string' || typeof typeClass === 'object') { @@ -1645,4 +1652,13 @@ class Node extends rclnodejs.ShadowNode { } } +Node.getDefaultOptions = function () { + return { + enableTypedArray: true, + isRaw: false, + qos: QoS.profileDefault, + contentFilter: undefined, + }; +}; + module.exports = Node; diff --git a/lib/rmw.js b/lib/rmw.js new file mode 100644 index 00000000..c34374b6 --- /dev/null +++ b/lib/rmw.js @@ -0,0 +1,29 @@ +'use strict'; + +const DistroUtils = require('./distro'); + +const RMWNames = { + FASTRTPS: 'rmw_fastrtps_cpp', + CONNEXT: 'rmw_connext_cpp', + CYCLONEDDS: 'rmw_cyclonedds_cpp', + GURUMDDS: 'rmw_gurumdds_cpp', +}; + +const DefaultRosRMWNameMap = new Map(); +DefaultRosRMWNameMap.set('eloquent', RMWNames.FASTRTPS); +DefaultRosRMWNameMap.set('foxy', RMWNames.FASTRTPS); +DefaultRosRMWNameMap.set('galactic', RMWNames.CYCLONEDDS); +DefaultRosRMWNameMap.set('humble', RMWNames.FASTRTPS); +DefaultRosRMWNameMap.set('rolling', RMWNames.FASTRTPS); + +const RMWUtils = { + RMWNames: RMWNames, + + getRMWName: function () { + return process.env.RMW_IMPLEMENTATION + ? process.env.RMW_IMPLEMENTATION + : DefaultRosRMWNameMap.get(DistroUtils.getDistroName()); + }, +}; + +module.exports = RMWUtils; diff --git a/lib/subscription.js b/lib/subscription.js index 96a7f261..2c6998a9 100644 --- a/lib/subscription.js +++ b/lib/subscription.js @@ -19,8 +19,13 @@ const Entity = require('./entity.js'); const debug = require('debug')('rclnodejs:subscription'); /** - * @class - Class representing a Subscription in ROS + * @class - Class representing a ROS 2 Subscription * @hideconstructor + * Includes support for content-filtering topics beginning with the + * ROS Humble release. To learn more about content-filtering + * @see {@link Node#options} + * @see {@link Node#createSubscription} + * @see {@link https://www.omg.org/spec/DDS/1.4/PDF|DDS 1.4 specification, Annex B} */ class Subscription extends Entity { @@ -42,13 +47,21 @@ class Subscription extends Entity { static createSubscription(nodeHandle, typeClass, topic, options, callback) { let type = typeClass.type(); + + // convert contentFilter.parameters to a string[] + if (options.contentFilter && options.contentFilter.parameters) { + options.contentFilter.parameters = options.contentFilter.parameters.map( + (param) => param.toString() + ); + } + let handle = rclnodejs.createSubscription( nodeHandle, type.pkgName, type.subFolder, type.interfaceName, topic, - options.qos + options ); return new Subscription(handle, typeClass, topic, options, callback); } @@ -66,6 +79,15 @@ class Subscription extends Entity { get isRaw() { return this._isRaw; } + + /** + * Test if this Subscription supports content-filtering and that it has + * been configured with a wellformed content-filter. + * @returns {boolean} True if content-filtering will be applied; otherwise false. + */ + isContentFilteringEnabled() { + return rclnodejs.isContentFilteringEnabled(this.handle); + } } module.exports = Subscription; diff --git a/package.json b/package.json index 8dc56d57..b50fdacc 100644 --- a/package.json +++ b/package.json @@ -13,12 +13,18 @@ "typescript" ], "scripts": { - "install": "node-gyp rebuild", + "build": "node-gyp -j 16 build", + "build:dev": "node-gyp -j 16 build --debug", + "rebuild": "npm run clean && node-gyp -j 16 rebuild", + "rebuild:dev": "npm run clean && node-gyp -j 16 rebuild --debug", + "generate-messages": "node scripts/generate_messages.js", + "clean": "node-gyp clean && rimraf ./generated", + "install": "npm run rebuild", + "postinstall": "npm run generate-messages", "docs": "cd docs && make", "test": "node --expose-gc ./scripts/run_test.js && npm run dtslint", "dtslint": "node scripts/generate_tsd.js && dtslint test/types", "lint": "eslint --max-warnings=0 --ext js,ts index.js types scripts lib example rosidl_gen rosidl_parser test benchmark/rclnodejs && node ./scripts/cpplint.js", - "postinstall": "node scripts/generate_messages.js", "format": "clang-format -i -style=file ./src/*.cpp ./src/*.hpp && prettier --write \"{lib,rosidl_gen,rostsd_gen,rosidl_parser,types,example,test,scripts,benchmark}/**/*.{js,md,ts}\" ./*.{js,md,ts}" }, "bin": { diff --git a/src/rcl_bindings.cpp b/src/rcl_bindings.cpp index 18b58421..248f7f1b 100644 --- a/src/rcl_bindings.cpp +++ b/src/rcl_bindings.cpp @@ -652,6 +652,8 @@ NAN_METHOD(CreateSubscription) { *Nan::Utf8String(info[3]->ToString(currentContent).ToLocalChecked())); std::string topic( *Nan::Utf8String(info[4]->ToString(currentContent).ToLocalChecked())); + v8::Local options = + info[5]->ToObject(currentContent).ToLocalChecked(); rcl_subscription_t* subscription = reinterpret_cast(malloc(sizeof(rcl_subscription_t))); @@ -659,12 +661,68 @@ NAN_METHOD(CreateSubscription) { rcl_subscription_options_t subscription_ops = rcl_subscription_get_default_options(); - auto qos_profile = GetQoSProfile(info[5]); + v8::Local qos = + Nan::Get(options, Nan::New("qos").ToLocalChecked()).ToLocalChecked(); + auto qos_profile = GetQoSProfile(qos); if (qos_profile) { subscription_ops.qos = *qos_profile; } +#if ROS_VERSION >= 2205 // 2205 => Humble disto + if (Nan::Has(options, Nan::New("contentFilter").ToLocalChecked()) + .FromMaybe(false)) { + // configure content-filter + v8::MaybeLocal contentFilterVal = + Nan::Get(options, Nan::New("contentFilter").ToLocalChecked()); + + if (!Nan::Equals(contentFilterVal.ToLocalChecked(), Nan::Undefined()) + .ToChecked()) { + v8::Local contentFilter = contentFilterVal.ToLocalChecked() + ->ToObject(currentContent) + .ToLocalChecked(); + + // expression property is required + std::string expression(*Nan::Utf8String( + Nan::Get(contentFilter, Nan::New("expression").ToLocalChecked()) + .ToLocalChecked() + ->ToString(currentContent) + .ToLocalChecked())); + + // parameters property (string[]) is optional + int argc = 0; + char** argv = nullptr; + + if (Nan::Has(contentFilter, Nan::New("parameters").ToLocalChecked()) + .FromMaybe(false)) { + v8::Local jsArgv = v8::Local::Cast( + Nan::Get(contentFilter, Nan::New("parameters").ToLocalChecked()) + .ToLocalChecked()); + argc = jsArgv->Length(); + if (argc > 0) { + argv = reinterpret_cast(malloc(argc * sizeof(char*))); + for (int i = 0; i < argc; i++) { + Nan::MaybeLocal jsElement = Nan::Get(jsArgv, i); + Nan::Utf8String utf8_arg(jsElement.ToLocalChecked()); + int len = utf8_arg.length() + 1; + argv[i] = reinterpret_cast(malloc(len * sizeof(char*))); + snprintf(argv[i], len, "%s", *utf8_arg); + } + } + } + + rcl_ret_t ret = rcl_subscription_options_set_content_filter_options( + expression.c_str(), argc, (const char**)argv, &subscription_ops); + + if (ret != RCL_RET_OK) { + Nan::ThrowError(rcl_get_error_string().str); + rcl_reset_error(); + } + } + } + +#endif + const rosidl_message_type_support_t* ts = GetMessageTypeSupport(package_name, message_sub_folder, message_name); @@ -689,6 +747,19 @@ NAN_METHOD(CreateSubscription) { } } +NAN_METHOD(IsContentFilteringEnabled) { + RclHandle* subscription_handle = RclHandle::Unwrap( + Nan::To(info[0]).ToLocalChecked()); + rcl_subscription_t* subscription = + reinterpret_cast(subscription_handle->ptr()); + // #if ROS_VERSION >= 2205 + bool is_valid = rcl_subscription_is_cft_enabled(subscription); + // #else + // bool is_valid = false; + // #endif + info.GetReturnValue().Set(Nan::New(is_valid)); +} + NAN_METHOD(CreatePublisher) { v8::Local currentContent = Nan::GetCurrentContext(); // Extract arguments @@ -1782,6 +1853,7 @@ std::vector binding_methods = { {"getRosTimeOverrideIsEnabled", GetRosTimeOverrideIsEnabled}, {"rclTake", RclTake}, {"createSubscription", CreateSubscription}, + {"isContentFilteringEnabled", IsContentFilteringEnabled}, {"createPublisher", CreatePublisher}, {"publish", Publish}, {"getTopic", GetTopic}, diff --git a/test/test-subscription-content-filter.js b/test/test-subscription-content-filter.js new file mode 100644 index 00000000..1326a217 --- /dev/null +++ b/test/test-subscription-content-filter.js @@ -0,0 +1,262 @@ +'use strict'; + +const childProcess = require('child_process'); +const assert = require('assert'); +const rclnodejs = require('../index.js'); +const DistroUtils = rclnodejs.DistroUtils; +const RMWUtils = rclnodejs.RMWUtils; + +function isContentFilteringSupported() { + return ( + DistroUtils.getDistroId() >= DistroUtils.getDistroId('humble') && + RMWUtils.getRMWName() != RMWUtils.RMWNames.CYCLONEDDS + ); +} + +describe('subscription content-filtering', function () { + this.timeout(10 * 1000); + + beforeEach(function () { + return rclnodejs.init(); + }); + + afterEach(function () { + rclnodejs.shutdown(); + }); + + it('isContentFilteringEnabled', function (done) { + console.log('ctsupported: ', isContentFilteringSupported()); + + let node = rclnodejs.createNode('string_subscription'); + let msgString = 'std_msgs/msg/String'; + let options = rclnodejs.Node.getDefaultOptions(); + options.contentFilter = { + expression: "data = 'FilteredData'", + }; + + let subscription = node.createSubscription( + msgString, + 'String_channel', + options, + (msg) => {} + ); + assert.ok( + subscription.isContentFilteringEnabled() === isContentFilteringSupported() + ); + + node.destroySubscription(subscription); + subscription = node.createSubscription( + msgString, + 'String_channel', + (msg) => {} + ); + assert.ok(!subscription.isContentFilteringEnabled()); + + done(); + }); + + it('no parameters', function (done) { + if (!isContentFilteringSupported()) { + this.skip(); + } + + let node = rclnodejs.createNode('string_subscription'); + let msgString = 'std_msgs/msg/String'; + let options = rclnodejs.Node.getDefaultOptions(); + options.contentFilter = { + expression: "data = 'FilteredData'", + }; + + let msgCnt = 0; + let fail = false; + let subscription = node.createSubscription( + msgString, + 'String_channel', + options, + (msg) => { + msgCnt++; + if (msg.data != 'FilteredData') { + fail = true; + } + } + ); + + assert.ok(subscription.isContentFilteringEnabled()); + + let publisher1 = childProcess.fork(`${__dirname}/publisher_msg.js`, [ + 'String', + "'FilteredData'", + ]); + + let publisher2 = childProcess.fork(`${__dirname}/publisher_msg.js`, [ + 'String', + "'Data'", + ]); + + setTimeout(() => { + publisher1.kill('SIGINT'); + publisher2.kill('SIGINT'); + assert.ok(msgCnt && !fail); + done(); + }, 1000); + + rclnodejs.spin(node); + }); + + it('single parameter', function (done) { + if (!isContentFilteringSupported()) { + this.skip(); + } + + let node = rclnodejs.createNode('string_subscription'); + let msgString = 'std_msgs/msg/String'; + let options = rclnodejs.Node.getDefaultOptions(); + options.contentFilter = { + expression: 'data = %0', + parameters: ["'FilteredData'"], + }; + + let msgCnt = 0; + let fail = false; + let subscription = node.createSubscription( + msgString, + 'String_channel', + options, + (msg) => { + msgCnt++; + if (msg.data != 'FilteredData') { + fail = true; + } + } + ); + + assert.ok(subscription.isContentFilteringEnabled()); + + let publisher1 = childProcess.fork(`${__dirname}/publisher_msg.js`, [ + 'String', + "'FilteredData'", + ]); + + let publisher2 = childProcess.fork(`${__dirname}/publisher_msg.js`, [ + 'String', + "'Data'", + ]); + + setTimeout(() => { + publisher1.kill('SIGINT'); + publisher2.kill('SIGINT'); + assert.ok(msgCnt && !fail); + done(); + }, 1000); + + rclnodejs.spin(node); + }); + + it('multiple parameters', function (done) { + if (!isContentFilteringSupported()) { + this.skip(); + } + + let node = rclnodejs.createNode('int32_subscription'); + let msgString = 'std_msgs/msg/Int32'; + let options = rclnodejs.Node.getDefaultOptions(); + options.contentFilter = { + expression: 'data >= %0 AND data <= %1', + parameters: [5, 10], + }; + + let msgCnt = 0; + let fail = false; + let subscription = node.createSubscription( + msgString, + 'Int32_channel', + options, + (msg) => { + msgCnt++; + if (msg.data === 0) { + fail = true; + } + } + ); + + assert.ok(subscription.isContentFilteringEnabled()); + + let publisher1 = childProcess.fork(`${__dirname}/publisher_msg.js`, [ + 'Int32', + '0', + ]); + + let publisher2 = childProcess.fork(`${__dirname}/publisher_msg.js`, [ + 'Int32', + '7', + ]); + + setTimeout(() => { + publisher1.kill('SIGINT'); + publisher2.kill('SIGINT'); + assert.ok(msgCnt && !fail); + done(); + }, 1000); + + rclnodejs.spin(node); + }); + + it('no content-filter', function (done) { + if (!isContentFilteringSupported()) { + this.skip(); + } + + let node = rclnodejs.createNode('string_subscription'); + let msgString = 'std_msgs/msg/String'; + + let msgCnt = 0; + let subscription = node.createSubscription( + msgString, + 'String_channel', + (msg) => { + msgCnt++; + } + ); + + assert.ok(!subscription.isContentFilteringEnabled()); + + let publisher = childProcess.fork(`${__dirname}/publisher_msg.js`, [ + 'String', + "'Data'", + ]); + + setTimeout(() => { + publisher.kill('SIGINT'); + assert.ok(msgCnt > 0); + done(); + }, 1000); + + rclnodejs.spin(node); + }); + + it('bad expression', function (done) { + if (!isContentFilteringSupported()) { + this.skip(); + } + + let node = rclnodejs.createNode('string_subscription'); + let msgString = 'std_msgs/msg/String'; + let options = rclnodejs.Node.getDefaultOptions(); + options.contentFilter = { + expression: 'this will fail', + }; + + let subscription; + try { + subscription = subscription = node.createSubscription( + msgString, + 'String_channel', + options, + (msg) => {} + ); + } catch (e) {} + + assert.ok(!subscription || !subscription.isContentFilteringEnabled()); + done(); + }); +}); diff --git a/types/node.d.ts b/types/node.d.ts index 2cd1c547..6a8c5b6b 100644 --- a/types/node.d.ts +++ b/types/node.d.ts @@ -13,6 +13,29 @@ declare module 'rclnodejs' { name: string; }; + /** + * A filter description similar to a SQL WHERE clause that limits + * the data accepted by a Subscription. + * + * The `expression` grammar is defined in the DDS 1.4 specification, Annex B. + * https://www.omg.org/spec/DDS/1.4/PDF + */ + interface SubscriptionContentFilter { + /** + * Specifies the criteria to select the data samples of + * interest. It is similar to the WHERE part of an SQL clause. + * Must be a valid query clause. + */ + expression: string; + + /** + * Array of strings that give values to the ‘parameters’ (i.e., "%n" tokens) in + * the filter_expression. The number of supplied parameters must fit with the + * requested values in the filter_expression (i.e., the number of %n tokens). + */ + parameters?: Array; + } + /** * Configuration options when creating new Publishers, Subscribers, * Clients and Services. @@ -37,16 +60,23 @@ declare module 'rclnodejs' { * ROS Middleware "quality of service" setting, default: QoS.profileDefault. */ qos?: T; + + /** + * An optional filter descriptions similar to a SQL WHERE clause used by a Subscription to + * inspect and limit messages that it accepts. + */ + contentFilter?: SubscriptionContentFilter; } /** - * Default options when creating a Node, Publisher, Subscription, Client or Service + * Default options when creating a Publisher, Subscription, Client or Service * * ```ts * { * enableTypedArray: true, * qos: QoS.profileDefault, - * isRaw: false + * isRaw: false, + * contentFilter: undefined * } * ``` */ @@ -271,6 +301,8 @@ declare module 'rclnodejs' { * @param options - Configuration options, see DEFAULT_OPTIONS * @param callback - Called when a new message is received. The serialized message will be null-terminated. * @returns New instance of Subscription. + * @throws Error - May throw an RMW error if options.content-filter is malformed. + * @see {@link https://www.omg.org/spec/DDS/1.4/PDF|DDS 1.4 specification, Annex B} */ createSubscription>( typeClass: T, diff --git a/types/subscription.d.ts b/types/subscription.d.ts index a8d891f5..e698355f 100644 --- a/types/subscription.d.ts +++ b/types/subscription.d.ts @@ -22,5 +22,15 @@ declare module 'rclnodejs' { * Topic to listen for messages on. */ readonly topic: string; + + /** + * Specifies if messages are in raw (binary) format + */ + readonly isRaw: boolean; + + /** + * Specifies if RMW-level content-filtering support is available. + */ + readonly isContentFilteringEnabled: boolean; } } From 0152c802b7582ca60bce193d459a59ebfd24ffc3 Mon Sep 17 00:00:00 2001 From: Virgile MATHIEU Date: Fri, 10 Feb 2023 21:04:28 +0100 Subject: [PATCH 2/4] Fixes error undefined, throw error instead --- lib/client.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/client.js b/lib/client.js index 360c57b3..f2194825 100644 --- a/lib/client.js +++ b/lib/client.js @@ -70,8 +70,9 @@ class Client extends Entity { this._sequenceNumberToCallbackMap.delete(sequenceNumber); callback(response.toPlainObject(this.typedArrayEnabled)); } else { - error(`Client has received an unexpected ${this._serviceName} - response with sequence number ${sequenceNumber}.`); + throw new Error( + `Client has received an unexpected ${this._serviceName} with sequence number ${sequenceNumber}.` + ); } } From f217199691694c6df4e26ba7f331171b7e4acc4d Mon Sep 17 00:00:00 2001 From: Wayne Parrott <5588978+wayneparrott@users.noreply.github.com> Date: Fri, 24 Feb 2023 00:58:53 -0800 Subject: [PATCH 3/4] Added RosPackageFilters & blocklist.json (#891) On windows workflow use node 18.12.0 inplace of 18.13.0. There is a repeatable issue with node-gyp configuration on node 18.13. It seems to be related to the node cache. Switching to 18.12 avoids using a cached version of node 18 and the issue no longer occurs. Fix #890 --- .../windows-build-and-test-compatibility.yml | 2 +- .github/workflows/windows-build-and-test.yml | 2 +- rosidl_gen/blocklist.json | 5 + rosidl_gen/filter.js | 104 ++++++++++++++++++ rosidl_gen/index.js | 3 + rosidl_gen/packages.js | 78 ++++++------- rostsd_gen/index.js | 10 +- 7 files changed, 162 insertions(+), 42 deletions(-) create mode 100644 rosidl_gen/blocklist.json create mode 100644 rosidl_gen/filter.js diff --git a/.github/workflows/windows-build-and-test-compatibility.yml b/.github/workflows/windows-build-and-test-compatibility.yml index 6f0104a8..773ad0a3 100644 --- a/.github/workflows/windows-build-and-test-compatibility.yml +++ b/.github/workflows/windows-build-and-test-compatibility.yml @@ -10,7 +10,7 @@ jobs: strategy: fail-fast: false matrix: - node-version: [10.X, 12.X, 14.X, 16.11.X, 17.X, 18.X, 19.X] + node-version: [10.X, 12.X, 14.X, 16.11.X, 17.X, 18.12.X, 19.X] ros_distribution: # - foxy - galactic diff --git a/.github/workflows/windows-build-and-test.yml b/.github/workflows/windows-build-and-test.yml index aeaebd5e..186695ed 100644 --- a/.github/workflows/windows-build-and-test.yml +++ b/.github/workflows/windows-build-and-test.yml @@ -26,7 +26,7 @@ jobs: strategy: fail-fast: false matrix: - node-version: [10.X, 12.X, 14.X, 16.11.X, 17.X, 18.X, 19.X] + node-version: [10.X, 12.X, 14.X, 16.11.X, 17.X, 18.12.0, 19.X] steps: - name: Setup Node.js ${{ matrix.node-version }} uses: actions/setup-node@v2 diff --git a/rosidl_gen/blocklist.json b/rosidl_gen/blocklist.json new file mode 100644 index 00000000..b2a1ac19 --- /dev/null +++ b/rosidl_gen/blocklist.json @@ -0,0 +1,5 @@ +[ + { + "pkgName": "rosbag2_storage_mcap_testdata" + } +] \ No newline at end of file diff --git a/rosidl_gen/filter.js b/rosidl_gen/filter.js new file mode 100644 index 00000000..a683a373 --- /dev/null +++ b/rosidl_gen/filter.js @@ -0,0 +1,104 @@ +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +// blocklist.json format +// [ +// { +// pkgName: RegExString, +// interfaceName: RegExString, +// os: RegExString +// }, +// ... +// ] +// +// examples +// [ +// { +// "pkgName": "action*" +// }, +// { +// "pkgName": "std_msgs", +// }, +// { +// "pkgName": "std_msgs", +// "interfaceName": "String" +// }, +// { +// "os": "Linux" +// }, +// ] + +const RosPackageFilters = { + filters: [], + _loaded: false, + + addFilter: function (pkgName, interfaceName, os) { + this.filters.push({ + pkgName: pkgName, + interfaceName: interfaceName, + os: os, + }); + }, + + _matches: function (filter, pkgInfo) { + if (filter.os && filter.os.test(os.type())) { + return true; + } + + if (filter.pkgName) { + if (filter.pkgName.test(pkgInfo.pkgName)) { + if (!filter.interfaceName) { + return true; + } + } else { + return false; + } + } + + if ( + filter.interfaceName && + filter.interfaceName.test(pkgInfo.interfaceName) + ) { + return true; + } + + return false; + }, + + load: function ( + blocklistPath = path.join(__dirname, '../rosidl_gen/blocklist.json') + ) { + this._loaded = true; + + if (!fs.existsSync(blocklistPath)) return; + + // eslint-disable-next-line + let blocklistData = JSON.parse(fs.readFileSync(blocklistPath, 'utf8')); + + let filters = blocklistData.map((pkgFilterData) => { + let filter = {}; + if (pkgFilterData['pkgName']) { + filter.pkgName = new RegExp(pkgFilterData.pkgName); + } + if (pkgFilterData['interfaceName']) { + filter.interfaceName = new RegExp(pkgFilterData.interfaceName); + } + if (pkgFilterData['os']) { + filter.os = new RegExp(pkgFilterData.os); + } + return filter; + }); + + this.filters = filters.filter( + (filter) => !filter.os || filter.os.test(os.type()) + ); + }, + + matchesAny: function (pkgInfo) { + if (!this._loaded) this.load(); + return this.filters.some((filter) => this._matches(filter, pkgInfo)); + }, +}; + +module.exports = RosPackageFilters; diff --git a/rosidl_gen/index.js b/rosidl_gen/index.js index bab0b6cb..118e4a17 100644 --- a/rosidl_gen/index.js +++ b/rosidl_gen/index.js @@ -26,7 +26,9 @@ const installedPackagePaths = process.env.AMENT_PREFIX_PATH.split( async function generateInPath(path) { const pkgs = await packages.findPackagesInDirectory(path); + const pkgsInfo = Array.from(pkgs.values()); + await Promise.all( pkgsInfo.map((pkgInfo) => generateJSStructFromIDL(pkgInfo, generatedRoot)) ); @@ -42,6 +44,7 @@ async function generateAll(forcedGenerating) { path.join(__dirname, 'generator.json'), path.join(generatedRoot, 'generator.json') ); + await Promise.all( installedPackagePaths.map((path) => generateInPath(path)) ); diff --git a/rosidl_gen/packages.js b/rosidl_gen/packages.js index d9490d24..a87d091c 100644 --- a/rosidl_gen/packages.js +++ b/rosidl_gen/packages.js @@ -21,6 +21,7 @@ const path = require('path'); const walk = require('walk'); const os = require('os'); const flat = require('array.prototype.flat'); +const pkgFilters = require('../rosidl_gen/filter.js'); const fsp = fs.promises; @@ -140,28 +141,30 @@ async function findAmentPackagesInDirectory(dir) { pkgs.map((pkg) => getPackageDefinitionsFiles(pkg, dir)) ); - // Support flat() methond for nodejs < 11. + // Support flat() method for nodejs < 11. const rosFiles = Array.prototype.flat ? files.flat() : flat(files); const pkgMap = new Map(); return new Promise((resolve, reject) => { rosFiles.forEach((filePath) => { - if (path.extname(filePath) === '.msg') { - // Some .msg files were generated prior to 0.3.2 for .action files, - // which has been disabled. So these files should be ignored here. - if (path.dirname(dir).split(path.sep).pop() !== 'action') { - addInterfaceInfo( - grabInterfaceInfo(filePath, true), - 'messages', - pkgMap - ); - } - } else if (path.extname(filePath) === '.srv') { - addInterfaceInfo(grabInterfaceInfo(filePath, true), 'services', pkgMap); - } else if (path.extname(filePath) === '.action') { - addInterfaceInfo(grabInterfaceInfo(filePath, true), 'actions', pkgMap); + const interfaceInfo = grabInterfaceInfo(filePath, true); + const ignore = pkgFilters.matchesAny(interfaceInfo); + if (ignore) { + console.log('Omitting filtered interface: ', interfaceInfo); } else { - // we ignore all other files + if (path.extname(filePath) === '.msg') { + // Some .msg files were generated prior to 0.3.2 for .action files, + // which has been disabled. So these files should be ignored here. + if (path.dirname(dir).split(path.sep).pop() !== 'action') { + addInterfaceInfo(interfaceInfo, 'messages', pkgMap); + } + } else if (path.extname(filePath) === '.srv') { + addInterfaceInfo(interfaceInfo, 'services', pkgMap); + } else if (path.extname(filePath) === '.action') { + addInterfaceInfo(interfaceInfo, 'actions', pkgMap); + } else { + // we ignore all other files + } } }); resolve(pkgMap); @@ -191,30 +194,27 @@ async function findPackagesInDirectory(dir) { let walker = walk.walk(dir, { followLinks: true }); let pkgMap = new Map(); walker.on('file', (root, file, next) => { - if (path.extname(file.name) === '.msg') { - // Some .msg files were generated prior to 0.3.2 for .action files, - // which has been disabled. So these files should be ignored here. - if (path.dirname(root).split(path.sep).pop() !== 'action') { - addInterfaceInfo( - grabInterfaceInfo(path.join(root, file.name), amentExecuted), - 'messages', - pkgMap - ); - } - } else if (path.extname(file.name) === '.srv') { - addInterfaceInfo( - grabInterfaceInfo(path.join(root, file.name), amentExecuted), - 'services', - pkgMap - ); - } else if (path.extname(file.name) === '.action') { - addInterfaceInfo( - grabInterfaceInfo(path.join(root, file.name), amentExecuted), - 'actions', - pkgMap - ); + const interfaceInfo = grabInterfaceInfo( + path.join(root, file.name), + amentExecuted + ); + const ignore = pkgFilters.matchesAny(interfaceInfo); + if (ignore) { + console.log('Omitting filtered interface: ', interfaceInfo); } else { - // we ignore all other files + if (path.extname(file.name) === '.msg') { + // Some .msg files were generated prior to 0.3.2 for .action files, + // which has been disabled. So these files should be ignored here. + if (path.dirname(root).split(path.sep).pop() !== 'action') { + addInterfaceInfo(interfaceInfo, 'messages', pkgMap); + } + } else if (path.extname(file.name) === '.srv') { + addInterfaceInfo(interfaceInfo, 'services', pkgMap); + } else if (path.extname(file.name) === '.action') { + addInterfaceInfo(interfaceInfo, 'actions', pkgMap); + } else { + // we ignore all other files + } } next(); }); diff --git a/rostsd_gen/index.js b/rostsd_gen/index.js index cf05c18f..97365ade 100644 --- a/rostsd_gen/index.js +++ b/rostsd_gen/index.js @@ -31,6 +31,7 @@ declare module "rclnodejs" { const path = require('path'); const fs = require('fs'); const loader = require('../lib/interface_loader.js'); +const pkgFilters = require('../rosidl_gen/filter.js'); async function generateAll() { // load pkg and interface info (msgs and srvs) @@ -63,7 +64,14 @@ function getPkgInfos(rootDir) { for (let filename of files) { const typeClass = fileName2Typeclass(filename); - if (!typeClass.type) continue; + if ( + !typeClass.type || + pkgFilters.matchesAny({ + pkgName: typeClass.package, + interfaceName: typeClass.name, + }) + ) + continue; const rosInterface = loader.loadInterface(typeClass); From 1a25267a0fba6a61433334fe694f5db0c5e6e223 Mon Sep 17 00:00:00 2001 From: Wayne Parrott <5588978+wayneparrott@users.noreply.github.com> Date: Fri, 24 Feb 2023 01:00:23 -0800 Subject: [PATCH 4/4] initial dockerfile commit (#893) --- Dockerfile | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..adb6892f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,51 @@ +# Create an image configured with ROS2 including colcon, Nodejs and rclnodejs source +# Supported ARGS: +# ROS_DISTRO = [foxy, galactic, humble, rolling], default=rolling +# NODE_MAJOR_VER = [12, 14, 16, 18, 19], default=19 +# BRANCH = rclnodejs git branch, default=develop +# +# examples: +# +# Build image named 'rclnodejs' and run it with the rclnode test suite +# +# docker build -t rclnodejs . +# docker run -it rclnodejs npm test +# +# +# Build an image for a specific branch of rclnodejs, version of ROS2 and Nodejs use: +# +# docker build -t --build-arg DISTRO=galactic . +# docker build -t \ +# --build-arg ROS_DISTRO=humble \ +# --build-arg BRANCH=humble-hawksbill \ +# --build-arg NODE_MAJOR_VER=18 . +# +# +# Build and run: +# docker run -it --rm $(docker build -q .) +# + +# use +ARG ROS_DISTRO=rolling +FROM ros:${ROS_DISTRO} + +# Install dependencies, including Nodejs +ARG NODE_MAJOR_VER=19 +RUN apt-get update -y \ + && apt-get install -y curl sudo \ + && curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR_VER}.x | sudo -E bash - \ + && apt-get install -y nodejs + +# clone a branch of the rclnodejs repo, build addon libs, generate corresponding JS msgs +ARG BRANCH=develop +WORKDIR /rosdev +SHELL ["/bin/bash", "-c"] +RUN source /opt/ros/${ROS_DISTRO}/setup.bash \ + && apt install ros-${ROS_DISTRO}-test-msgs \ + && apt install ros-${ROS_DISTRO}-example-interfaces \ + && git clone -b ${BRANCH} --single-branch https://github.com/RobotWebTools/rclnodejs.git \ + && cd /rosdev/rclnodejs \ + && npm i + +WORKDIR /rosdev/rclnodejs +CMD [ "bash" ]