From 6c63a816fa0067b28e65f438bf13d5ed5acaa0fa 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/3] 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 subscription.js - isContentFilteringEnabled() - setContentFilter() - clearContentFilter() subscription.d.ts - updated with content-filter api 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 test/blocklist.json - added test-subscription-content-filter.js for Windows and Mac OS examples: - publisher-content-filtering-example.js - subscription-content-filtering-example.js package.json - added build/rebuild scripts for convenience --- .../windows-build-and-test-compatibility.yml | 2 +- docs/EFFICIENCY.md | 48 +- example/publisher-content-filter-example.js | 53 +++ .../subscription-content-filter-example.js | 95 ++++ index.js | 4 + lib/distro.js | 2 +- lib/node.js | 43 +- lib/rmw.js | 29 ++ lib/subscription.js | 56 ++- package.json | 10 +- src/rcl_bindings.cpp | 139 +++++- test.js | 62 +++ test/blocklist.json | 27 +- test/test-subscription-content-filter.js | 412 ++++++++++++++++++ test/types/main.ts | 25 ++ types/node.d.ts | 52 ++- types/subscription.d.ts | 28 ++ 17 files changed, 1064 insertions(+), 23 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.js create mode 100644 test/test-subscription-content-filter.js diff --git a/.github/workflows/windows-build-and-test-compatibility.yml b/.github/workflows/windows-build-and-test-compatibility.yml index a62a0efb..a2e63c36 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: [14.21.2, 16.19.0, 18.14.2, 19.X] + node-version: [14.21.2, 16.19.0, 18.14.1, 19.X] ros_distribution: - foxy - humble diff --git a/docs/EFFICIENCY.md b/docs/EFFICIENCY.md index b69f6561..4c8095f1 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,54 @@ 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. While the application of the a content-filter +is specific to the DDS/RMW vendor, the general approach is to apply +filtering on the publisher side. This can reduce network bandwidth +for pub-sub communications and message processing and memory +overhead of rclnodejs nodes. + +Note: Be sure to confirm that your RMW implementation supports +content-filter before attempting to use it. In cases where content-filtering +is not supported your Subscription will simply ignore your filter and +continue operating with no filtering. + +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..39326070 --- /dev/null +++ b/example/subscription-content-filter-example.js @@ -0,0 +1,95 @@ +// 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'); + + let param = 50; + + // create a content-filter to limit incoming messages to + // only those with temperature > paramC. + const options = rclnodejs.Node.getDefaultOptions(); + options.contentFilter = { + expression: 'temperature > %0', + parameters: [param], + }; + + 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`); + if (count % 5 === 0) { + if (subscription.isContentFilteringEnabled()) { + console.log('Clearing filter'); + subscription.clearContentFilter(); + } else { + param += 10; + console.log('Update topic content-filter, temperature > ', param); + const contentFilter = { + expression: 'temperature > %0', + parameters: [param], + }; + subscription.setContentFilter(contentFilter); + } + console.log( + 'Content-filtering enabled: ', + subscription.isContentFilteringEnabled() + ); + } + } + ); + + if (!subscription.isContentFilteringEnabled()) { + console.log('Content-filtering is not enabled on subscription.'); + } + } 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..890518f9 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,18 @@ 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. + * Confirm that your RMW supports content-filtered topics before use. + * @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|Content-filter details at DDS 1.4 specification, Annex B} */ createSubscription(typeClass, topic, options, callback) { if (typeof typeClass === 'string' || typeof typeClass === 'object') { @@ -1645,4 +1653,25 @@ class Node extends rclnodejs.ShadowNode { } } +/** + * Create an Options instance initialized with default values. + * @returns {Options} - The new initialized instance. + * @static + * @example + * { + * enableTypedArray: true, + * isRaw: false, + * qos: QoS.profileDefault, + * contentFilter: undefined, + * } + */ +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..1312aac5 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,45 @@ class Subscription extends Entity { get isRaw() { return this._isRaw; } + + /** + * Test if the RMW supports content-filtered topics and that this subscription + * 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); + } + + /** + * If the RMW supports content-filtered topics set this subscription's content-filter. + * @param {object} contentFilter - The content-filter description. + * @param {string} contentFilter.expression - Specifies the criteria to select messages of interest. + * It is similar to the WHERE part of an SQL clause. Clear the current contentFilter if + * the expression is undefined or an empty string. + * @param {object[]} [contentFilter.parameters=undefined] - Array of objects 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. + * @returns {boolean} - True if successful; false otherwise + * @see {@link https://www.omg.org/spec/DDS/1.4/PDF|DDS 1.4 specification, Annex B} + */ + setContentFilter(contentFilter) { + if (!contentFilter) return false; + + return contentFilter.expression + ? rclnodejs.setContentFilter(this.handle, contentFilter) + : this.clearContentFilter(); + } + + /** + * Clear the current content-filter. No filtering is to be applied. + * @returns {boolean} - True if successful; false otherwise + */ + clearContentFilter() { + return this.isContentFilteringEnabled() + ? rclnodejs.setContentFilter(this.handle, { expression: '' }) + : true; + } } 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..968211f2 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+ + 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,83 @@ NAN_METHOD(CreateSubscription) { } } +NAN_METHOD(IsContentFilteringEnabled) { +#if ROS_VERSION < 2205 // 2205 => Humble+ + info.GetReturnValue().Set(Nan::False()); + return; +#else + + RclHandle* subscription_handle = RclHandle::Unwrap( + Nan::To(info[0]).ToLocalChecked()); + rcl_subscription_t* subscription = + reinterpret_cast(subscription_handle->ptr()); + + bool is_valid = rcl_subscription_is_cft_enabled(subscription); + info.GetReturnValue().Set(Nan::New(is_valid)); +#endif +} + +NAN_METHOD(SetContentFilter) { +#if ROS_VERSION < 2205 // 2205 => Humble+ + info.GetReturnValue().Set(Nan::False()); + return; +#else + v8::Local currentContent = Nan::GetCurrentContext(); + RclHandle* subscription_handle = RclHandle::Unwrap( + Nan::To(info[0]).ToLocalChecked()); + rcl_subscription_t* subscription = + reinterpret_cast(subscription_handle->ptr()); + + v8::Local contentFilter = + info[1]->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); + } + } + } + + // create ctf options + rcl_subscription_content_filter_options_t options = + rcl_get_zero_initialized_subscription_content_filter_options(); + + THROW_ERROR_IF_NOT_EQUAL( + RCL_RET_OK, + rcl_subscription_content_filter_options_init( + subscription, expression.c_str(), argc, (const char**)argv, &options), + rcl_get_error_string().str); + + THROW_ERROR_IF_NOT_EQUAL( + RCL_RET_OK, rcl_subscription_set_content_filter(subscription, &options), + rcl_get_error_string().str); + + info.GetReturnValue().Set(Nan::True()); +#endif +} + NAN_METHOD(CreatePublisher) { v8::Local currentContent = Nan::GetCurrentContext(); // Extract arguments @@ -1782,6 +1917,8 @@ std::vector binding_methods = { {"getRosTimeOverrideIsEnabled", GetRosTimeOverrideIsEnabled}, {"rclTake", RclTake}, {"createSubscription", CreateSubscription}, + {"isContentFilteringEnabled", IsContentFilteringEnabled}, + {"setContentFilter", SetContentFilter}, {"createPublisher", CreatePublisher}, {"publish", Publish}, {"getTopic", GetTopic}, diff --git a/test.js b/test.js new file mode 100644 index 00000000..d64f9b96 --- /dev/null +++ b/test.js @@ -0,0 +1,62 @@ +// 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'); + + let param = 50; + + // create a content-filter to limit incoming messages to + // only those with temperature > paramC. + const options = rclnodejs.Node.getDefaultOptions(); + options.contentFilter = { + expression: "data = 'foo'", + }; + + let subscription = node.createSubscription( + 'std_msgs/msg/String', + 'temperature', + options, + (msg) => {} + ); + + console.log('filtering enabled: ', subscription.isContentFilteringEnabled()); + + rclnodejs.shutdown(); +} + +main(); diff --git a/test/blocklist.json b/test/blocklist.json index 197ff941..2b29c9f5 100644 --- a/test/blocklist.json +++ b/test/blocklist.json @@ -1,5 +1,26 @@ { - "Linux": ["test-raw-pub-sub.js", "test-multi-nodes.js", "test-msg-type-py-node.js", "test-msg-type-cpp-node.js", "test-cross-lang.js"], - "Darwin": ["test-raw-pub-sub.js", "test-multi-nodes.js", "test-msg-type-py-node.js", "test-msg-type-cpp-node.js", "test-cross-lang.js"], - "Windows_NT": ["test-raw-pub-sub.js", "test-multi-nodes.js", "test-msg-type-py-node.js", "test-msg-type-cpp-node.js", "test-cross-lang.js", "test-generate-messages-bin.js"] + "Linux": [ + "test-raw-pub-sub.js", + "test-multi-nodes.js", + "test-msg-type-py-node.js", + "test-msg-type-cpp-node.js", + "test-cross-lang.js" + ], + "Darwin": [ + "test-raw-pub-sub.js", + "test-multi-nodes.js", + "test-msg-type-py-node.js", + "test-msg-type-cpp-node.js", + "test-cross-lang.js", + "test-subscription-content-filter.js" + ], + "Windows_NT": [ + "test-raw-pub-sub.js", + "test-multi-nodes.js", + "test-msg-type-py-node.js", + "test-msg-type-cpp-node.js", + "test-cross-lang.js", + "test-generate-messages-bin.js", + "test-subscription-content-filter.js" + ] } diff --git a/test/test-subscription-content-filter.js b/test/test-subscription-content-filter.js new file mode 100644 index 00000000..a7157323 --- /dev/null +++ b/test/test-subscription-content-filter.js @@ -0,0 +1,412 @@ +'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) { + let node = rclnodejs.createNode('string_subscription'); + let msgString = 'std_msgs/msg/Int16'; + let options = rclnodejs.Node.getDefaultOptions(); + options.contentFilter = { + expression: 'data = 16', + }; + + let subscription = node.createSubscription( + msgString, + '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('setContentFilter', 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', + parameters: [3], + }; + + let msgCnt0 = 0; + let msgCnt5 = 0; + let fail = false; + let subscription = node.createSubscription( + msgString, + 'Int32_channel', + options, + (msg) => { + switch (msg.data) { + case 0: + msgCnt0++; + break; + case 5: + msgCnt5++; + break; + default: + 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', + '5', + ]); + + setTimeout(() => { + const contentFilter5 = { + expression: 'data = 5', + }; + subscription.setContentFilter(contentFilter5); + }, 500); + + setTimeout(() => { + publisher1.kill('SIGINT'); + publisher2.kill('SIGINT'); + assert.ok(!fail && msgCnt5 && !msgCnt0); + done(); + }, 1000); + + rclnodejs.spin(node); + }); + + it('clearContentFilter', 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', + parameters: [5], + }; + + let msgCnt0 = 0; + let msgCnt5 = 0; + let fail = false; + let subscription = node.createSubscription( + msgString, + 'Int32_channel', + options, + (msg) => { + switch (msg.data) { + case 0: + msgCnt0++; + break; + case 5: + msgCnt5++; + break; + default: + 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', + '5', + ]); + + setTimeout(() => { + assert.ok(msgCnt5 && !msgCnt0 && !fail); + subscription.clearContentFilter(); + }, 500); + + setTimeout(() => { + publisher1.kill('SIGINT'); + publisher2.kill('SIGINT'); + assert.ok(!subscription.isContentFilteringEnabled()); + assert.ok(!fail && msgCnt5 && msgCnt0); + done(); + }, 1000); + + rclnodejs.spin(node); + }); + + it('multiple clearContentFilter', 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', + parameters: [5], + }; + + let subscription = node.createSubscription( + msgString, + 'Int32_channel', + options, + (msg) => {} + ); + + assert.ok(subscription.isContentFilteringEnabled()); + assert.ok(subscription.clearContentFilter()); + assert.ok(subscription.clearContentFilter()); + done(); + }); + + 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/test/types/main.ts b/test/types/main.ts index 26de59c4..e9173553 100644 --- a/test/types/main.ts +++ b/test/types/main.ts @@ -110,6 +110,9 @@ node.countPublishers(TOPIC); // $ExpectType number node.countSubscribers(TOPIC); +// $ExpectType Options +rclnodejs.Node.getDefaultOptions(); + // ---- LifecycleNode ---- // $ExpectType LifecycleNode const lifecycleNode = rclnodejs.createLifecycleNode(LIFECYCLE_NODE_NAME); @@ -215,9 +218,31 @@ let subscription = node.createSubscription(TYPE_CLASS, TOPIC, (msg) => {}); // $ExpectType Subscription subscription = node.createSubscription(TYPE_CLASS, TOPIC, {}, (msg) => {}); +const contentFilter: rclnodejs.SubscriptionContentFilter = { + expression: 'data < %0', + parameters: [5], +}; + +// $ExpectType Subscription +subscription = node.createSubscription( + TYPE_CLASS, + TOPIC, + { contentFilter }, + (msg) => {} +); + // $ExpectType string subscription.topic; +// $ExpectType boolean +subscription.setContentFilter(contentFilter); + +// $ExpectType boolean +subscription.clearContentFilter(); + +// $ExpectType boolean +subscription.isContentFilteringEnabled(); + // ---- Service ---- // $ExpectType AddTwoIntsConstructor let service = node.createService( diff --git a/types/node.d.ts b/types/node.d.ts index 2cd1c547..0a9cd130 100644 --- a/types/node.d.ts +++ b/types/node.d.ts @@ -13,6 +13,30 @@ declare module 'rclnodejs' { name: string; }; + /** + * A filter description similar to a SQL WHERE clause that limits + * the data wanted 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. + */ + readonly expression: string; + + /** + * The values for the ‘parameters’ (i.e., "%n" tokens) in + * the filter_expression string. There must be a 1-1 match + * between values and expression parameters. The maximum + * number of parameters is 100. + */ + readonly parameters?: Array; + } + /** * Configuration options when creating new Publishers, Subscribers, * Clients and Services. @@ -37,16 +61,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 * } * ``` */ @@ -133,6 +164,21 @@ declare module 'rclnodejs' { options?: NodeOptions ); + /** + * Create an Options instance initialized with default values. + * @returns {Options} - The new initialized instance. + * @example + * ``` + * { + * enableTypedArray: true, + * isRaw: false, + * qos: QoS.profileDefault, + * contentFilter: undefined, + * } + * ``` + */ + static getDefaultOptions(): Options; + /** * Get the name of the node. * @@ -271,6 +317,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|Content-filter details at DDS 1.4 specification, Annex B} */ createSubscription>( typeClass: T, diff --git a/types/subscription.d.ts b/types/subscription.d.ts index a8d891f5..0393f3bd 100644 --- a/types/subscription.d.ts +++ b/types/subscription.d.ts @@ -6,6 +6,7 @@ declare module 'rclnodejs' { * * @remarks * See {@link Node#createSubscription | Node.createSubscription} + * See {@link SubscriptionContentFilter} * See {@link Node#createPublisher | Node.createPublisher} * See {@link Publisher} * See {@link Subscription} @@ -22,5 +23,32 @@ declare module 'rclnodejs' { * Topic to listen for messages on. */ readonly topic: string; + + /** + * Specifies if messages are in raw (binary) format + */ + readonly isRaw: boolean; + + /** + * Test if the RMW supports content-filtered topics and that this subscription + * has been configured with a wellformed content-filter. + * @returns {boolean} True if content-filtering will be applied; otherwise false. + */ + isContentFilteringEnabled(): boolean; + + /** + * Set a content-filter if the RMW supports content-filtered topics. + * @param contentFilter - The content-filter description to apply. + * @returns True if successful; false otherwise + * @remarks + * @see {@link https://www.omg.org/spec/DDS/1.4/PDF|DDS 1.4 specification, Annex B} + */ + setContentFilter(filter: SubscriptionContentFilter): boolean; + + /** + * Clear the current content-filter. No filtering is to be applied. + * @returns True if successful; false otherwise + */ + clearContentFilter(): boolean; } } From e8a2194430f4769a297167e88a316854bcf53810 Mon Sep 17 00:00:00 2001 From: Wayne Parrott <5588978+wayneparrott@users.noreply.github.com> Date: Thu, 2 Mar 2023 23:34:32 -0600 Subject: [PATCH 2/3] Delete obsolete ./test.js --- test.js | 62 --------------------------------------------------------- 1 file changed, 62 deletions(-) delete mode 100644 test.js diff --git a/test.js b/test.js deleted file mode 100644 index d64f9b96..00000000 --- a/test.js +++ /dev/null @@ -1,62 +0,0 @@ -// 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'); - - let param = 50; - - // create a content-filter to limit incoming messages to - // only those with temperature > paramC. - const options = rclnodejs.Node.getDefaultOptions(); - options.contentFilter = { - expression: "data = 'foo'", - }; - - let subscription = node.createSubscription( - 'std_msgs/msg/String', - 'temperature', - options, - (msg) => {} - ); - - console.log('filtering enabled: ', subscription.isContentFilteringEnabled()); - - rclnodejs.shutdown(); -} - -main(); From 74ec3342fe6b89e0545140143469d24756921c84 Mon Sep 17 00:00:00 2001 From: Wayne Parrott <5588978+wayneparrott@users.noreply.github.com> Date: Mon, 6 Mar 2023 17:01:35 -0600 Subject: [PATCH 3/3] implements recommended PR feedback --- example/publisher-content-filter-example.js | 2 +- .../subscription-content-filter-example.js | 6 +- lib/node.js | 4 - lib/subscription.js | 12 ++- src/rcl_bindings.cpp | 18 +++- test/test-subscription-content-filter.js | 84 ++++++++++++++++--- test/types/main.ts | 2 +- types/subscription.d.ts | 4 +- 8 files changed, 101 insertions(+), 31 deletions(-) diff --git a/example/publisher-content-filter-example.js b/example/publisher-content-filter-example.js index c9e07795..79595dbd 100644 --- a/example/publisher-content-filter-example.js +++ b/example/publisher-content-filter-example.js @@ -1,4 +1,4 @@ -// Copyright (c) 2017 Intel Corporation. All rights reserved. +// 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. diff --git a/example/subscription-content-filter-example.js b/example/subscription-content-filter-example.js index 39326070..0dfc0e91 100644 --- a/example/subscription-content-filter-example.js +++ b/example/subscription-content-filter-example.js @@ -59,7 +59,7 @@ async function main() { console.log(`Received temperature message-${++count}: ${temperatureMsg.temperature}C`); if (count % 5 === 0) { - if (subscription.isContentFilteringEnabled()) { + if (subscription.hasContentFilter()) { console.log('Clearing filter'); subscription.clearContentFilter(); } else { @@ -73,13 +73,13 @@ ${temperatureMsg.temperature}C`); } console.log( 'Content-filtering enabled: ', - subscription.isContentFilteringEnabled() + subscription.hasContentFilter() ); } } ); - if (!subscription.isContentFilteringEnabled()) { + if (!subscription.hasContentFilter()) { console.log('Content-filtering is not enabled on subscription.'); } } catch (error) { diff --git a/lib/node.js b/lib/node.js index 890518f9..697c9bcc 100644 --- a/lib/node.js +++ b/lib/node.js @@ -479,10 +479,6 @@ class Node extends rclnodejs.ShadowNode { options = Object.assign(options, { isRaw: false }); } - if (options.contentFilter === undefined) { - options = Object.assign(options, { contentFilter: undefined }); - } - return options; } diff --git a/lib/subscription.js b/lib/subscription.js index 1312aac5..b200a087 100644 --- a/lib/subscription.js +++ b/lib/subscription.js @@ -82,11 +82,11 @@ class Subscription extends Entity { /** * Test if the RMW supports content-filtered topics and that this subscription - * has been configured with a wellformed content-filter. + * has an active wellformed content-filter. * @returns {boolean} True if content-filtering will be applied; otherwise false. */ - isContentFilteringEnabled() { - return rclnodejs.isContentFilteringEnabled(this.handle); + hasContentFilter() { + return rclnodejs.hasContentFilter(this.handle); } /** @@ -102,9 +102,7 @@ class Subscription extends Entity { * @see {@link https://www.omg.org/spec/DDS/1.4/PDF|DDS 1.4 specification, Annex B} */ setContentFilter(contentFilter) { - if (!contentFilter) return false; - - return contentFilter.expression + return contentFilter?.expression ? rclnodejs.setContentFilter(this.handle, contentFilter) : this.clearContentFilter(); } @@ -114,7 +112,7 @@ class Subscription extends Entity { * @returns {boolean} - True if successful; false otherwise */ clearContentFilter() { - return this.isContentFilteringEnabled() + return this.hasContentFilter() ? rclnodejs.setContentFilter(this.handle, { expression: '' }) : true; } diff --git a/src/rcl_bindings.cpp b/src/rcl_bindings.cpp index 968211f2..59e78b0d 100644 --- a/src/rcl_bindings.cpp +++ b/src/rcl_bindings.cpp @@ -718,6 +718,13 @@ NAN_METHOD(CreateSubscription) { Nan::ThrowError(rcl_get_error_string().str); rcl_reset_error(); } + + if (argc) { + for (int i = 0; i < argc; i++) { + free(argv[i]); + } + free(argv); + } } } @@ -747,7 +754,7 @@ NAN_METHOD(CreateSubscription) { } } -NAN_METHOD(IsContentFilteringEnabled) { +NAN_METHOD(HasContentFilter) { #if ROS_VERSION < 2205 // 2205 => Humble+ info.GetReturnValue().Set(Nan::False()); return; @@ -820,6 +827,13 @@ NAN_METHOD(SetContentFilter) { RCL_RET_OK, rcl_subscription_set_content_filter(subscription, &options), rcl_get_error_string().str); + if (argc) { + for (int i = 0; i < argc; i++) { + free(argv[i]); + } + free(argv); + } + info.GetReturnValue().Set(Nan::True()); #endif } @@ -1917,7 +1931,7 @@ std::vector binding_methods = { {"getRosTimeOverrideIsEnabled", GetRosTimeOverrideIsEnabled}, {"rclTake", RclTake}, {"createSubscription", CreateSubscription}, - {"isContentFilteringEnabled", IsContentFilteringEnabled}, + {"hasContentFilter", HasContentFilter}, {"setContentFilter", SetContentFilter}, {"createPublisher", CreatePublisher}, {"publish", Publish}, diff --git a/test/test-subscription-content-filter.js b/test/test-subscription-content-filter.js index a7157323..9961e632 100644 --- a/test/test-subscription-content-filter.js +++ b/test/test-subscription-content-filter.js @@ -40,7 +40,7 @@ describe('subscription content-filtering', function () { ); assert.ok( - subscription.isContentFilteringEnabled() === isContentFilteringSupported() + subscription.hasContentFilter() === isContentFilteringSupported() ); node.destroySubscription(subscription); @@ -49,7 +49,7 @@ describe('subscription content-filtering', function () { 'String_channel', (msg) => {} ); - assert.ok(!subscription.isContentFilteringEnabled()); + assert.ok(!subscription.hasContentFilter()); done(); }); @@ -80,7 +80,7 @@ describe('subscription content-filtering', function () { } ); - assert.ok(subscription.isContentFilteringEnabled()); + assert.ok(subscription.hasContentFilter()); let publisher1 = childProcess.fork(`${__dirname}/publisher_msg.js`, [ 'String', @@ -129,7 +129,7 @@ describe('subscription content-filtering', function () { } ); - assert.ok(subscription.isContentFilteringEnabled()); + assert.ok(subscription.hasContentFilter()); let publisher1 = childProcess.fork(`${__dirname}/publisher_msg.js`, [ 'String', @@ -178,7 +178,7 @@ describe('subscription content-filtering', function () { } ); - assert.ok(subscription.isContentFilteringEnabled()); + assert.ok(subscription.hasContentFilter()); let publisher1 = childProcess.fork(`${__dirname}/publisher_msg.js`, [ 'Int32', @@ -234,7 +234,7 @@ describe('subscription content-filtering', function () { } ); - assert.ok(subscription.isContentFilteringEnabled()); + assert.ok(subscription.hasContentFilter()); let publisher1 = childProcess.fork(`${__dirname}/publisher_msg.js`, [ 'Int32', @@ -263,6 +263,68 @@ describe('subscription content-filtering', function () { rclnodejs.spin(node); }); + it('setContentFilter(undefined)', 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', + parameters: [5], + }; + + let msgCnt0 = 0; + let msgCnt5 = 0; + let fail = false; + let subscription = node.createSubscription( + msgString, + 'Int32_channel', + options, + (msg) => { + switch (msg.data) { + case 0: + msgCnt0++; + break; + case 5: + msgCnt5++; + break; + default: + fail = true; + } + } + ); + + assert.ok(subscription.hasContentFilter()); + + let publisher1 = childProcess.fork(`${__dirname}/publisher_msg.js`, [ + 'Int32', + '0', + ]); + + let publisher2 = childProcess.fork(`${__dirname}/publisher_msg.js`, [ + 'Int32', + '5', + ]); + + setTimeout(() => { + assert.ok(msgCnt5 && !msgCnt0 && !fail); + subscription.setContentFilter(); + }, 500); + + setTimeout(() => { + publisher1.kill('SIGINT'); + publisher2.kill('SIGINT'); + assert.ok(!subscription.hasContentFilter()); + assert.ok(!fail && msgCnt5 && msgCnt0); + done(); + }, 1000); + + rclnodejs.spin(node); + }); + it('clearContentFilter', function (done) { if (!isContentFilteringSupported()) { this.skip(); @@ -297,7 +359,7 @@ describe('subscription content-filtering', function () { } ); - assert.ok(subscription.isContentFilteringEnabled()); + assert.ok(subscription.hasContentFilter()); let publisher1 = childProcess.fork(`${__dirname}/publisher_msg.js`, [ 'Int32', @@ -317,7 +379,7 @@ describe('subscription content-filtering', function () { setTimeout(() => { publisher1.kill('SIGINT'); publisher2.kill('SIGINT'); - assert.ok(!subscription.isContentFilteringEnabled()); + assert.ok(!subscription.hasContentFilter()); assert.ok(!fail && msgCnt5 && msgCnt0); done(); }, 1000); @@ -345,7 +407,7 @@ describe('subscription content-filtering', function () { (msg) => {} ); - assert.ok(subscription.isContentFilteringEnabled()); + assert.ok(subscription.hasContentFilter()); assert.ok(subscription.clearContentFilter()); assert.ok(subscription.clearContentFilter()); done(); @@ -368,7 +430,7 @@ describe('subscription content-filtering', function () { } ); - assert.ok(!subscription.isContentFilteringEnabled()); + assert.ok(!subscription.hasContentFilter()); let publisher = childProcess.fork(`${__dirname}/publisher_msg.js`, [ 'String', @@ -406,7 +468,7 @@ describe('subscription content-filtering', function () { ); } catch (e) {} - assert.ok(!subscription || !subscription.isContentFilteringEnabled()); + assert.ok(!subscription || !subscription.hasContentFilter()); done(); }); }); diff --git a/test/types/main.ts b/test/types/main.ts index e9173553..eba8635f 100644 --- a/test/types/main.ts +++ b/test/types/main.ts @@ -241,7 +241,7 @@ subscription.setContentFilter(contentFilter); subscription.clearContentFilter(); // $ExpectType boolean -subscription.isContentFilteringEnabled(); +subscription.hasContentFilter(); // ---- Service ---- // $ExpectType AddTwoIntsConstructor diff --git a/types/subscription.d.ts b/types/subscription.d.ts index 0393f3bd..9e93be2c 100644 --- a/types/subscription.d.ts +++ b/types/subscription.d.ts @@ -31,10 +31,10 @@ declare module 'rclnodejs' { /** * Test if the RMW supports content-filtered topics and that this subscription - * has been configured with a wellformed content-filter. + * is configured with a well formed content-filter. * @returns {boolean} True if content-filtering will be applied; otherwise false. */ - isContentFilteringEnabled(): boolean; + hasContentFilter(): boolean; /** * Set a content-filter if the RMW supports content-filtered topics.