Skip to content

Commit f03d66b

Browse files
committed
Support feedback subscription content filter for action client
1 parent 6e46c4e commit f03d66b

5 files changed

Lines changed: 286 additions & 4 deletions

File tree

lib/action/client.js

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,12 @@ class ActionClient extends Entity {
5151
* @param {QoS} options.qos.feedbackSubQosProfile - Quality of service option for the feedback subscription,
5252
* default: new QoS(QoS.HistoryPolicy.RMW_QOS_POLICY_HISTORY_SYSTEM_DEFAULT, 10).
5353
* @param {QoS} options.qos.statusSubQosProfile - Quality of service option for the status subscription, default: QoS.profileActionStatusDefault.
54+
* @param {boolean} options.enableFeedbackMsgOptimization - Enable feedback subscription content filter to
55+
* optimize the handling of feedback messages. When enabled, the content filter is used to configure
56+
* the goal ID for the subscription, which helps avoid the reception of irrelevant feedback messages.
57+
* An action client can handle up to 6 goals simultaneously with this optimization. If the number
58+
* of goals exceeds the limit or the RMW doesn't support content filter, optimization is automatically
59+
* disabled. Default: false.
5460
*/
5561
constructor(node, typeClass, actionName, options) {
5662
super(null, null, options);
@@ -87,6 +93,14 @@ class ActionClient extends Entity {
8793
checkTypes: true,
8894
};
8995

96+
// Enable feedback subscription content filter optimization.
97+
// Only supported on ROS2 Rolling and only effective when the native
98+
// binding provides the required functions.
99+
this._enableFeedbackMsgOptimization =
100+
this._options.enableFeedbackMsgOptimization === true &&
101+
DistroUtils.getDistroId() >= DistroUtils.DistroId.ROLLING &&
102+
typeof rclnodejs.actionConfigureFeedbackSubFilterAddGoalId === 'function';
103+
90104
let type = this.typeClass.type();
91105

92106
this._handle = rclnodejs.actionCreateClient(
@@ -126,6 +140,7 @@ class ActionClient extends Entity {
126140
}
127141

128142
this._goalHandles.set(uuid, goalHandle);
143+
this._feedbackSubFilterAddGoalId(goalHandle.goalId);
129144
} else {
130145
// Clean up feedback callback for rejected goals
131146
let uuid = ActionUuid.fromMessage(
@@ -205,6 +220,9 @@ class ActionClient extends Entity {
205220
status === ActionInterfaces.GoalStatus.STATUS_ABORTED
206221
) {
207222
this._goalHandles.delete(uuid);
223+
this._feedbackSubFilterRemoveGoalId(
224+
statusMessage.goal_info.goal_id
225+
);
208226
}
209227
}
210228
} else {
@@ -393,6 +411,8 @@ class ActionClient extends Entity {
393411
this._removePendingCancelRequest(sequenceNumber)
394412
);
395413

414+
this._feedbackSubFilterRemoveGoalId(goalHandle.goalId);
415+
396416
return deferred.promise;
397417
}
398418

@@ -442,9 +462,10 @@ class ActionClient extends Entity {
442462
goalHandle.status = result.status;
443463
return result.result;
444464
});
445-
deferred.setDoneCallback(() =>
446-
this._removePendingResultRequest(sequenceNumber)
447-
);
465+
deferred.setDoneCallback(() => {
466+
this._removePendingResultRequest(sequenceNumber);
467+
this._feedbackSubFilterRemoveGoalId(goalHandle.goalId);
468+
});
448469

449470
this._pendingResultRequests.set(sequenceNumber, deferred);
450471

@@ -464,6 +485,50 @@ class ActionClient extends Entity {
464485
this._pendingCancelRequests.delete(sequenceNumber);
465486
}
466487

488+
/**
489+
* Add a goal ID to the feedback subscription content filter.
490+
* @ignore
491+
* @param {object} goalId - The goal UUID message.
492+
*/
493+
_feedbackSubFilterAddGoalId(goalId) {
494+
if (!this._enableFeedbackMsgOptimization) return;
495+
try {
496+
rclnodejs.actionConfigureFeedbackSubFilterAddGoalId(
497+
this.handle,
498+
Buffer.from(goalId.uuid)
499+
);
500+
} catch (e) {
501+
this._enableFeedbackMsgOptimization = false;
502+
this._node
503+
.getLogger()
504+
.warn(
505+
`${e.message}\nFeedback message optimization is automatically disabled.`
506+
);
507+
}
508+
}
509+
510+
/**
511+
* Remove a goal ID from the feedback subscription content filter.
512+
* @ignore
513+
* @param {object} goalId - The goal UUID message.
514+
*/
515+
_feedbackSubFilterRemoveGoalId(goalId) {
516+
if (!this._enableFeedbackMsgOptimization) return;
517+
try {
518+
rclnodejs.actionConfigureFeedbackSubFilterRemoveGoalId(
519+
this.handle,
520+
Buffer.from(goalId.uuid)
521+
);
522+
} catch (e) {
523+
this._enableFeedbackMsgOptimization = false;
524+
this._node
525+
.getLogger()
526+
.warn(
527+
`${e.message}\nFeedback message optimization is automatically disabled.`
528+
);
529+
}
530+
}
531+
467532
/**
468533
* Destroy the underlying action client handle.
469534
* @return {undefined}

src/rcl_action_client_bindings.cpp

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,67 @@ Napi::Value ActionSendCancelRequest(const Napi::CallbackInfo& info) {
250250
return Napi::Number::New(env, static_cast<double>(sequence_number));
251251
}
252252

253+
#if ROS_VERSION >= 5000 // ROS2 Rolling
254+
Napi::Value ActionConfigureFeedbackSubFilterAddGoalId(
255+
const Napi::CallbackInfo& info) {
256+
Napi::Env env = info.Env();
257+
258+
RclHandle* action_client_handle =
259+
RclHandle::Unwrap(info[0].As<Napi::Object>());
260+
rcl_action_client_t* action_client =
261+
reinterpret_cast<rcl_action_client_t*>(action_client_handle->ptr());
262+
263+
auto goal_id_buffer = info[1].As<Napi::Buffer<uint8_t>>();
264+
const uint8_t* goal_id_array = goal_id_buffer.Data();
265+
size_t goal_id_size = goal_id_buffer.Length();
266+
267+
rcl_ret_t ret =
268+
rcl_action_client_configure_feedback_subscription_filter_add_goal_id(
269+
action_client, goal_id_array, goal_id_size);
270+
271+
if (RCL_RET_OK != ret) {
272+
std::string error_text{
273+
"Failed to add goal id to feedback subscription content filter: "};
274+
error_text += rcl_get_error_string().str;
275+
rcl_reset_error();
276+
Napi::Error::New(env, error_text).ThrowAsJavaScriptException();
277+
return Napi::Boolean::New(env, false);
278+
}
279+
280+
return Napi::Boolean::New(env, true);
281+
}
282+
283+
Napi::Value ActionConfigureFeedbackSubFilterRemoveGoalId(
284+
const Napi::CallbackInfo& info) {
285+
Napi::Env env = info.Env();
286+
287+
RclHandle* action_client_handle =
288+
RclHandle::Unwrap(info[0].As<Napi::Object>());
289+
rcl_action_client_t* action_client =
290+
reinterpret_cast<rcl_action_client_t*>(action_client_handle->ptr());
291+
292+
auto goal_id_buffer = info[1].As<Napi::Buffer<uint8_t>>();
293+
const uint8_t* goal_id_array = goal_id_buffer.Data();
294+
size_t goal_id_size = goal_id_buffer.Length();
295+
296+
rcl_ret_t ret =
297+
rcl_action_client_configure_feedback_subscription_filter_remove_goal_id(
298+
action_client, goal_id_array, goal_id_size);
299+
300+
if (RCL_RET_OK != ret) {
301+
std::string error_text{
302+
"Failed to remove goal id from feedback subscription content "
303+
"filter: "};
304+
error_text += rcl_get_error_string().str;
305+
rcl_reset_error();
306+
Napi::Error::New(env, error_text).ThrowAsJavaScriptException();
307+
return Napi::Boolean::New(env, false);
308+
}
309+
310+
return Napi::Boolean::New(env, true);
311+
}
312+
#endif // ROS_VERSION >= 5000
313+
253314
#if ROS_VERSION >= 2505 // ROS2 >= Kilted
254315
Napi::Value ConfigureActionClientIntrospection(const Napi::CallbackInfo& info) {
255316
Napi::Env env = info.Env();
@@ -307,7 +368,15 @@ Napi::Object InitActionClientBindings(Napi::Env env, Napi::Object exports) {
307368
#if ROS_VERSION >= 2505 // ROS2 >= Kilted
308369
exports.Set("configureActionClientIntrospection",
309370
Napi::Function::New(env, ConfigureActionClientIntrospection));
310-
#endif // ROS_VERSION >= 2505
371+
#endif // ROS_VERSION >= 2505
372+
#if ROS_VERSION >= 5000 // ROS2 Rolling
373+
exports.Set(
374+
"actionConfigureFeedbackSubFilterAddGoalId",
375+
Napi::Function::New(env, ActionConfigureFeedbackSubFilterAddGoalId));
376+
exports.Set(
377+
"actionConfigureFeedbackSubFilterRemoveGoalId",
378+
Napi::Function::New(env, ActionConfigureFeedbackSubFilterRemoveGoalId));
379+
#endif // ROS_VERSION >= 5000
311380
return exports;
312381
}
313382

test/test-action-client.js

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,4 +310,111 @@ describe('rclnodejs action client', function () {
310310
ServiceIntrospectionStates.CONTENTS
311311
);
312312
});
313+
314+
describe('enableFeedbackMsgOptimization', function () {
315+
const nativeLoader = require('../lib/native_loader.js');
316+
const isFeedbackFilterSupported = () =>
317+
typeof nativeLoader.actionConfigureFeedbackSubFilterAddGoalId ===
318+
'function';
319+
320+
it('Test option defaults to false', function () {
321+
let client = new rclnodejs.ActionClient(node, fibonacci, 'fibonacci');
322+
assert.strictEqual(client._enableFeedbackMsgOptimization, false);
323+
client.destroy();
324+
});
325+
326+
it('Test option can be set to true', function () {
327+
let client = new rclnodejs.ActionClient(node, fibonacci, 'fibonacci', {
328+
enableFeedbackMsgOptimization: true,
329+
});
330+
// If native API is available, it should be enabled; otherwise disabled
331+
if (isFeedbackFilterSupported()) {
332+
assert.strictEqual(client._enableFeedbackMsgOptimization, true);
333+
} else {
334+
assert.strictEqual(client._enableFeedbackMsgOptimization, false);
335+
}
336+
client.destroy();
337+
});
338+
339+
it('Test send goal with optimization enabled', async function () {
340+
let client = new rclnodejs.ActionClient(node, fibonacci, 'fibonacci', {
341+
enableFeedbackMsgOptimization: true,
342+
});
343+
344+
let feedbackCallback = sinon.spy(function (feedback) {
345+
assert.ok(feedback);
346+
});
347+
348+
let goalUuid = ActionUuid.randomMessage();
349+
publishFeedback = goalUuid;
350+
351+
let result = await client.waitForServer(2000);
352+
assert.ok(result);
353+
354+
let goalHandle = await client.sendGoal(
355+
new Fibonacci.Goal(),
356+
feedbackCallback,
357+
goalUuid
358+
);
359+
assert.ok(goalHandle.isAccepted());
360+
361+
await goalHandle.getResult();
362+
assert.ok(goalHandle.isSucceeded());
363+
364+
client.destroy();
365+
});
366+
367+
it('Test send multiple goals with optimization enabled', async function () {
368+
let client = new rclnodejs.ActionClient(node, fibonacci, 'fibonacci', {
369+
enableFeedbackMsgOptimization: true,
370+
});
371+
372+
let result = await client.waitForServer(2000);
373+
assert.ok(result);
374+
375+
const [goal1Handle, goal2Handle, goal3Handle] = await Promise.all([
376+
client.sendGoal(new Fibonacci.Goal()),
377+
client.sendGoal(new Fibonacci.Goal()),
378+
client.sendGoal(new Fibonacci.Goal()),
379+
]);
380+
381+
assert.ok(goal1Handle.accepted);
382+
assert.ok(goal2Handle.accepted);
383+
assert.ok(goal3Handle.accepted);
384+
385+
const [result1, result2, result3] = await Promise.all([
386+
goal1Handle.getResult(),
387+
goal2Handle.getResult(),
388+
goal3Handle.getResult(),
389+
]);
390+
391+
assert.ok(result1);
392+
assert.ok(result2);
393+
assert.ok(result3);
394+
395+
client.destroy();
396+
});
397+
398+
it('Test cancel goal with optimization enabled', async function () {
399+
let client = new rclnodejs.ActionClient(node, fibonacci, 'fibonacci', {
400+
enableFeedbackMsgOptimization: true,
401+
});
402+
403+
let result = await client.waitForServer(2000);
404+
assert.ok(result);
405+
406+
let goalHandle = await client.sendGoal(new Fibonacci.Goal());
407+
assert.ok(goalHandle.isAccepted());
408+
409+
result = await goalHandle.cancelGoal();
410+
assert.ok(result);
411+
412+
assert.strictEqual(
413+
ActionUuid.fromMessage(result.goals_canceling[0].goal_id).toString(),
414+
ActionUuid.fromMessage(goalHandle.goalId).toString()
415+
);
416+
417+
client.destroy();
418+
});
419+
});
313420
});

test/test-subscription-content-filter.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,3 +480,36 @@ describe('subscription content-filtering', function () {
480480
done();
481481
});
482482
});
483+
484+
describe('subscription isContentFilterSupported', function () {
485+
this.timeout(30 * 1000);
486+
487+
beforeEach(async function () {
488+
await rclnodejs.init();
489+
this.node = new Node('cft_support_test_node');
490+
});
491+
492+
afterEach(function () {
493+
this.node.destroy();
494+
rclnodejs.shutdown();
495+
});
496+
497+
it('isContentFilterSupported returns boolean matching RMW capability', function (done) {
498+
const typeclass = 'std_msgs/msg/Int16';
499+
const subscription = this.node.createSubscription(
500+
typeclass,
501+
TOPIC,
502+
(msg) => {}
503+
);
504+
505+
const supported = subscription.isContentFilterSupported();
506+
assert.strictEqual(typeof supported, 'boolean');
507+
508+
// isContentFilterSupported requires rolling; on older distros it returns false
509+
const isRolling = DistroUtils.getDistroId() >= DistroUtils.DistroId.ROLLING;
510+
const expectedSupported = isRolling && isContentFilteringSupported();
511+
assert.strictEqual(supported, expectedSupported);
512+
513+
done();
514+
});
515+
});

types/action_client.d.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,14 @@ declare module 'rclnodejs' {
142142
options?: Options<ActionQoS> & {
143143
validateGoals?: boolean;
144144
validationOptions?: MessageValidationOptions;
145+
/**
146+
* Enable feedback subscription content filter to optimize the handling
147+
* of feedback messages. When enabled, the content filter is used to
148+
* configure the goal ID for the subscription, avoiding reception of
149+
* irrelevant feedback messages. An action client can handle up to 6
150+
* goals simultaneously with this optimization. Default: false.
151+
*/
152+
enableFeedbackMsgOptimization?: boolean;
145153
}
146154
);
147155

0 commit comments

Comments
 (0)