rclnodejs 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -58,6 +58,9 @@ const {
58
58
  serializeMessage,
59
59
  deserializeMessage,
60
60
  } = require('./lib/serialization.js');
61
+ const ParameterClient = require('./lib/parameter_client.js');
62
+ const errors = require('./lib/errors.js');
63
+ const ParameterWatcher = require('./lib/parameter_watcher.js');
61
64
  const { spawn } = require('child_process');
62
65
 
63
66
  /**
@@ -217,6 +220,12 @@ let rcl = {
217
220
  /** {@link ParameterType} */
218
221
  ParameterType: ParameterType,
219
222
 
223
+ /** {@link ParameterClient} class */
224
+ ParameterClient: ParameterClient,
225
+
226
+ /** {@link ParameterWatcher} class */
227
+ ParameterWatcher: ParameterWatcher,
228
+
220
229
  /** {@link QoS} class */
221
230
  QoS: QoS,
222
231
 
@@ -556,6 +565,56 @@ let rcl = {
556
565
  * @returns {string} The JSON string representation
557
566
  */
558
567
  toJSONString: toJSONString,
568
+
569
+ // Error classes for structured error handling
570
+ /** {@link RclNodeError} - Base error class for all rclnodejs errors */
571
+ RclNodeError: errors.RclNodeError,
572
+
573
+ /** {@link ValidationError} - Error thrown when validation fails */
574
+ ValidationError: errors.ValidationError,
575
+ /** {@link TypeValidationError} - Type validation error */
576
+ TypeValidationError: errors.TypeValidationError,
577
+ /** {@link RangeValidationError} - Range/value validation error */
578
+ RangeValidationError: errors.RangeValidationError,
579
+ /** {@link NameValidationError} - ROS name validation error */
580
+ NameValidationError: errors.NameValidationError,
581
+
582
+ /** {@link OperationError} - Base class for operation/runtime errors */
583
+ OperationError: errors.OperationError,
584
+ /** {@link TimeoutError} - Request timeout error */
585
+ TimeoutError: errors.TimeoutError,
586
+ /** {@link AbortError} - Request abortion error */
587
+ AbortError: errors.AbortError,
588
+ /** {@link ServiceNotFoundError} - Service not available error */
589
+ ServiceNotFoundError: errors.ServiceNotFoundError,
590
+ /** {@link NodeNotFoundError} - Remote node not found error */
591
+ NodeNotFoundError: errors.NodeNotFoundError,
592
+
593
+ /** {@link ParameterError} - Base error for parameter operations */
594
+ ParameterError: errors.ParameterError,
595
+ /** {@link ParameterNotFoundError} - Parameter not found error */
596
+ ParameterNotFoundError: errors.ParameterNotFoundError,
597
+ /** {@link ParameterTypeError} - Parameter type mismatch error */
598
+ ParameterTypeError: errors.ParameterTypeError,
599
+ /** {@link ReadOnlyParameterError} - Read-only parameter modification error */
600
+ ReadOnlyParameterError: errors.ReadOnlyParameterError,
601
+
602
+ /** {@link TopicError} - Base error for topic operations */
603
+ TopicError: errors.TopicError,
604
+ /** {@link PublisherError} - Publisher-specific error */
605
+ PublisherError: errors.PublisherError,
606
+ /** {@link SubscriptionError} - Subscription-specific error */
607
+ SubscriptionError: errors.SubscriptionError,
608
+
609
+ /** {@link ActionError} - Base error for action operations */
610
+ ActionError: errors.ActionError,
611
+ /** {@link GoalRejectedError} - Goal rejected by action server */
612
+ GoalRejectedError: errors.GoalRejectedError,
613
+ /** {@link ActionServerNotFoundError} - Action server not found */
614
+ ActionServerNotFoundError: errors.ActionServerNotFoundError,
615
+
616
+ /** {@link NativeError} - Wraps errors from native C++ layer */
617
+ NativeError: errors.NativeError,
559
618
  };
560
619
 
561
620
  const _sigHandler = () => {
@@ -23,6 +23,11 @@ const DistroUtils = require('../distro.js');
23
23
  const Entity = require('../entity.js');
24
24
  const loader = require('../interface_loader.js');
25
25
  const QoS = require('../qos.js');
26
+ const {
27
+ TypeValidationError,
28
+ ActionError,
29
+ OperationError,
30
+ } = require('../errors.js');
26
31
 
27
32
  /**
28
33
  * @class - ROS Action client.
@@ -103,7 +108,14 @@ class ActionClient extends Entity {
103
108
  if (goalHandle.accepted) {
104
109
  let uuid = ActionUuid.fromMessage(goalHandle.goalId).toString();
105
110
  if (this._goalHandles.has(uuid)) {
106
- throw new Error(`Two goals were accepted with the same ID (${uuid})`);
111
+ throw new ActionError(
112
+ `Two goals were accepted with the same ID (${uuid})`,
113
+ this._actionName,
114
+ {
115
+ code: 'DUPLICATE_GOAL_ID',
116
+ details: { goalId: uuid },
117
+ }
118
+ );
107
119
  }
108
120
 
109
121
  this._goalHandles.set(uuid, goalHandle);
@@ -210,8 +222,14 @@ class ActionClient extends Entity {
210
222
  );
211
223
 
212
224
  if (this._pendingGoalRequests.has(sequenceNumber)) {
213
- throw new Error(
214
- `Sequence (${sequenceNumber}) conflicts with pending goal request`
225
+ throw new OperationError(
226
+ `Sequence (${sequenceNumber}) conflicts with pending goal request`,
227
+ {
228
+ code: 'SEQUENCE_CONFLICT',
229
+ entityType: 'action client',
230
+ entityName: this._actionName,
231
+ details: { sequenceNumber: sequenceNumber, requestType: 'goal' },
232
+ }
215
233
  );
216
234
  }
217
235
 
@@ -274,7 +292,15 @@ class ActionClient extends Entity {
274
292
  */
275
293
  _cancelGoal(goalHandle) {
276
294
  if (!(goalHandle instanceof ClientGoalHandle)) {
277
- throw new TypeError('Invalid argument, must be type of ClientGoalHandle');
295
+ throw new TypeValidationError(
296
+ 'goalHandle',
297
+ goalHandle,
298
+ 'ClientGoalHandle',
299
+ {
300
+ entityType: 'action client',
301
+ entityName: this._actionName,
302
+ }
303
+ );
278
304
  }
279
305
 
280
306
  let request = new ActionInterfaces.CancelGoal.Request();
@@ -290,8 +316,14 @@ class ActionClient extends Entity {
290
316
  request.serialize()
291
317
  );
292
318
  if (this._pendingCancelRequests.has(sequenceNumber)) {
293
- throw new Error(
294
- `Sequence (${sequenceNumber}) conflicts with pending cancel request`
319
+ throw new OperationError(
320
+ `Sequence (${sequenceNumber}) conflicts with pending cancel request`,
321
+ {
322
+ code: 'SEQUENCE_CONFLICT',
323
+ entityType: 'action client',
324
+ entityName: this._actionName,
325
+ details: { sequenceNumber: sequenceNumber, requestType: 'cancel' },
326
+ }
295
327
  );
296
328
  }
297
329
 
@@ -316,7 +348,15 @@ class ActionClient extends Entity {
316
348
  */
317
349
  _getResult(goalHandle) {
318
350
  if (!(goalHandle instanceof ClientGoalHandle)) {
319
- throw new TypeError('Invalid argument, must be type of ClientGoalHandle');
351
+ throw new TypeValidationError(
352
+ 'goalHandle',
353
+ goalHandle,
354
+ 'ClientGoalHandle',
355
+ {
356
+ entityType: 'action client',
357
+ entityName: this._actionName,
358
+ }
359
+ );
320
360
  }
321
361
 
322
362
  let request = new this.typeClass.impl.GetResultService.Request();
@@ -327,8 +367,14 @@ class ActionClient extends Entity {
327
367
  request.serialize()
328
368
  );
329
369
  if (this._pendingResultRequests.has(sequenceNumber)) {
330
- throw new Error(
331
- `Sequence (${sequenceNumber}) conflicts with pending result request`
370
+ throw new OperationError(
371
+ `Sequence (${sequenceNumber}) conflicts with pending result request`,
372
+ {
373
+ code: 'SEQUENCE_CONFLICT',
374
+ entityType: 'action client',
375
+ entityName: this._actionName,
376
+ details: { sequenceNumber: sequenceNumber, requestType: 'result' },
377
+ }
332
378
  );
333
379
  }
334
380
 
@@ -14,6 +14,8 @@
14
14
 
15
15
  'use strict';
16
16
 
17
+ const { TypeValidationError } = require('../errors.js');
18
+
17
19
  /**
18
20
  * @class - Wraps a promise allowing it to be resolved elsewhere.
19
21
  * @ignore
@@ -42,7 +44,9 @@ class Deferred {
42
44
  */
43
45
  beforeSetResultCallback(callback) {
44
46
  if (typeof callback !== 'function') {
45
- throw new TypeError('Invalid parameter');
47
+ throw new TypeValidationError('callback', callback, 'function', {
48
+ entityType: 'deferred promise',
49
+ });
46
50
  }
47
51
 
48
52
  this._beforeSetResultCallback = callback;
@@ -70,7 +74,9 @@ class Deferred {
70
74
  */
71
75
  setDoneCallback(callback) {
72
76
  if (typeof callback !== 'function') {
73
- throw new TypeError('Invalid parameter');
77
+ throw new TypeValidationError('callback', callback, 'function', {
78
+ entityType: 'deferred promise',
79
+ });
74
80
  }
75
81
 
76
82
  this._promise.finally(() => callback(this._result));
@@ -23,6 +23,7 @@ const { CancelResponse, GoalEvent, GoalResponse } = require('./response.js');
23
23
  const loader = require('../interface_loader.js');
24
24
  const QoS = require('../qos.js');
25
25
  const ServerGoalHandle = require('./server_goal_handle.js');
26
+ const { TypeValidationError } = require('../errors.js');
26
27
 
27
28
  /**
28
29
  * Execute the goal.
@@ -219,7 +220,15 @@ class ActionServer extends Entity {
219
220
  */
220
221
  registerExecuteCallback(executeCallback) {
221
222
  if (typeof executeCallback !== 'function') {
222
- throw new TypeError('Invalid argument');
223
+ throw new TypeValidationError(
224
+ 'executeCallback',
225
+ executeCallback,
226
+ 'function',
227
+ {
228
+ entityType: 'action server',
229
+ entityName: this._actionName,
230
+ }
231
+ );
223
232
  }
224
233
 
225
234
  this._callback = executeCallback;
@@ -16,6 +16,7 @@
16
16
 
17
17
  const ActionInterfaces = require('./interfaces.js');
18
18
  const { randomUUID } = require('crypto');
19
+ const { TypeValidationError } = require('../errors.js');
19
20
 
20
21
  /**
21
22
  * @class - Represents a unique identifier used by actions.
@@ -31,7 +32,9 @@ class ActionUuid {
31
32
  constructor(bytes) {
32
33
  if (bytes) {
33
34
  if (!(bytes instanceof Uint8Array)) {
34
- throw new Error('Invalid parameter');
35
+ throw new TypeValidationError('bytes', bytes, 'Uint8Array', {
36
+ entityType: 'action uuid',
37
+ });
35
38
  }
36
39
 
37
40
  this._bytes = bytes;
package/lib/client.js CHANGED
@@ -17,8 +17,58 @@
17
17
  const rclnodejs = require('./native_loader.js');
18
18
  const DistroUtils = require('./distro.js');
19
19
  const Entity = require('./entity.js');
20
+ const {
21
+ TypeValidationError,
22
+ TimeoutError,
23
+ AbortError,
24
+ } = require('./errors.js');
20
25
  const debug = require('debug')('rclnodejs:client');
21
26
 
27
+ // Polyfill for AbortSignal.any() for Node.js <= 20.3.0
28
+ // AbortSignal.any() was added in Node.js 20.3.0
29
+ // See https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/any_static
30
+ if (!AbortSignal.any) {
31
+ AbortSignal.any = function (signals) {
32
+ // Filter out null/undefined values and validate inputs
33
+ const validSignals = Array.isArray(signals)
34
+ ? signals.filter((signal) => signal != null)
35
+ : [];
36
+
37
+ // If no valid signals, return a never-aborting signal
38
+ if (validSignals.length === 0) {
39
+ return new AbortController().signal;
40
+ }
41
+
42
+ const controller = new AbortController();
43
+ const listeners = [];
44
+
45
+ // Cleanup function to remove all event listeners
46
+ const cleanup = () => {
47
+ listeners.forEach(({ signal, listener }) => {
48
+ signal.removeEventListener('abort', listener);
49
+ });
50
+ };
51
+
52
+ for (const signal of validSignals) {
53
+ if (signal.aborted) {
54
+ cleanup();
55
+ controller.abort(signal.reason);
56
+ return controller.signal;
57
+ }
58
+
59
+ const listener = () => {
60
+ cleanup();
61
+ controller.abort(signal.reason);
62
+ };
63
+
64
+ signal.addEventListener('abort', listener);
65
+ listeners.push({ signal, listener });
66
+ }
67
+
68
+ return controller.signal;
69
+ };
70
+ }
71
+
22
72
  /**
23
73
  * @class - Class representing a Client in ROS
24
74
  * @hideconstructor
@@ -33,7 +83,7 @@ class Client extends Entity {
33
83
  }
34
84
 
35
85
  /**
36
- * This callback is called when a resopnse is sent back from service
86
+ * This callback is called when a response is sent back from service
37
87
  * @callback ResponseCallback
38
88
  * @param {Object} response - The response sent from the service
39
89
  * @see [Client.sendRequest]{@link Client#sendRequest}
@@ -43,7 +93,7 @@ class Client extends Entity {
43
93
  */
44
94
 
45
95
  /**
46
- * Send the request and will be notified asynchronously if receiving the repsonse.
96
+ * Send the request and will be notified asynchronously if receiving the response.
47
97
  * @param {object} request - The request to be submitted.
48
98
  * @param {ResponseCallback} callback - Thc callback function for receiving the server response.
49
99
  * @return {undefined}
@@ -51,7 +101,10 @@ class Client extends Entity {
51
101
  */
52
102
  sendRequest(request, callback) {
53
103
  if (typeof callback !== 'function') {
54
- throw new TypeError('Invalid argument');
104
+ throw new TypeValidationError('callback', callback, 'function', {
105
+ entityType: 'service',
106
+ entityName: this._serviceName,
107
+ });
55
108
  }
56
109
 
57
110
  let requestToSend =
@@ -65,6 +118,102 @@ class Client extends Entity {
65
118
  this._sequenceNumberToCallbackMap.set(sequenceNumber, callback);
66
119
  }
67
120
 
121
+ /**
122
+ * Send the request and return a Promise that resolves with the response.
123
+ * @param {object} request - The request to be submitted.
124
+ * @param {object} [options] - Optional parameters for the request.
125
+ * @param {number} [options.timeout] - Timeout in milliseconds for the request.
126
+ * @param {AbortSignal} [options.signal] - AbortSignal to cancel the request.
127
+ * @return {Promise<object>} Promise that resolves with the service response.
128
+ * @throws {module:rclnodejs.TimeoutError} If the request times out (when options.timeout is exceeded).
129
+ * @throws {module:rclnodejs.AbortError} If the request is manually aborted (via options.signal).
130
+ * @throws {Error} If the request fails for other reasons.
131
+ */
132
+ sendRequestAsync(request, options = {}) {
133
+ return new Promise((resolve, reject) => {
134
+ let sequenceNumber = null;
135
+ let isResolved = false;
136
+ let isTimeout = false;
137
+
138
+ const cleanup = () => {
139
+ if (sequenceNumber !== null) {
140
+ this._sequenceNumberToCallbackMap.delete(sequenceNumber);
141
+ }
142
+ isResolved = true;
143
+ };
144
+
145
+ let effectiveSignal = options.signal;
146
+
147
+ if (options.timeout !== undefined && options.timeout >= 0) {
148
+ const timeoutSignal = AbortSignal.timeout(options.timeout);
149
+
150
+ timeoutSignal.addEventListener('abort', () => {
151
+ isTimeout = true;
152
+ });
153
+
154
+ if (options.signal) {
155
+ effectiveSignal = AbortSignal.any([options.signal, timeoutSignal]);
156
+ } else {
157
+ effectiveSignal = timeoutSignal;
158
+ }
159
+ }
160
+
161
+ if (effectiveSignal) {
162
+ if (effectiveSignal.aborted) {
163
+ const error = isTimeout
164
+ ? new TimeoutError('Service request', options.timeout, {
165
+ entityType: 'service',
166
+ entityName: this._serviceName,
167
+ })
168
+ : new AbortError('Service request', undefined, {
169
+ entityType: 'service',
170
+ entityName: this._serviceName,
171
+ });
172
+ reject(error);
173
+ return;
174
+ }
175
+
176
+ effectiveSignal.addEventListener('abort', () => {
177
+ if (!isResolved) {
178
+ cleanup();
179
+ const error = isTimeout
180
+ ? new TimeoutError('Service request', options.timeout, {
181
+ entityType: 'service',
182
+ entityName: this._serviceName,
183
+ })
184
+ : new AbortError('Service request', undefined, {
185
+ entityType: 'service',
186
+ entityName: this._serviceName,
187
+ });
188
+ reject(error);
189
+ }
190
+ });
191
+ }
192
+
193
+ try {
194
+ let requestToSend =
195
+ request instanceof this._typeClass.Request
196
+ ? request
197
+ : new this._typeClass.Request(request);
198
+
199
+ let rawRequest = requestToSend.serialize();
200
+ sequenceNumber = rclnodejs.sendRequest(this._handle, rawRequest);
201
+
202
+ debug(`Client has sent a ${this._serviceName} request (async).`);
203
+
204
+ this._sequenceNumberToCallbackMap.set(sequenceNumber, (response) => {
205
+ if (!isResolved) {
206
+ cleanup();
207
+ resolve(response);
208
+ }
209
+ });
210
+ } catch (error) {
211
+ cleanup();
212
+ reject(error);
213
+ }
214
+ });
215
+ }
216
+
68
217
  processResponse(sequenceNumber, response) {
69
218
  if (this._sequenceNumberToCallbackMap.has(sequenceNumber)) {
70
219
  debug(`Client has received ${this._serviceName} response from service.`);
package/lib/clock.js CHANGED
@@ -17,6 +17,7 @@
17
17
  const rclnodejs = require('./native_loader.js');
18
18
  const Time = require('./time.js');
19
19
  const ClockType = require('./clock_type.js');
20
+ const { TypeValidationError } = require('./errors.js');
20
21
 
21
22
  /**
22
23
  * @class - Class representing a Clock in ROS
@@ -102,7 +103,9 @@ class ROSClock extends Clock {
102
103
 
103
104
  set rosTimeOverride(time) {
104
105
  if (!(time instanceof Time)) {
105
- throw new TypeError('Invalid argument, must be type of Time');
106
+ throw new TypeValidationError('time', time, 'Time', {
107
+ entityType: 'clock',
108
+ });
106
109
  }
107
110
  rclnodejs.setRosTimeOverride(this._handle, time._handle);
108
111
  }
package/lib/context.js CHANGED
@@ -15,6 +15,7 @@
15
15
  'use strict';
16
16
 
17
17
  const rclnodejs = require('./native_loader.js');
18
+ const { OperationError } = require('./errors.js');
18
19
 
19
20
  let defaultContext = null;
20
21
 
@@ -184,11 +185,20 @@ class Context {
184
185
 
185
186
  onNodeCreated(node) {
186
187
  if (!node) {
187
- throw new Error('Node must be defined to add to Context');
188
+ throw new OperationError('Node must be defined to add to Context', {
189
+ code: 'NODE_UNDEFINED',
190
+ entityType: 'context',
191
+ });
188
192
  }
189
193
 
190
194
  if (this.isShutdown()) {
191
- throw new Error('Can not add a Node to a Context that is shutdown');
195
+ throw new OperationError(
196
+ 'Can not add a Node to a Context that is shutdown',
197
+ {
198
+ code: 'CONTEXT_SHUTDOWN',
199
+ entityType: 'context',
200
+ }
201
+ );
192
202
  }
193
203
 
194
204
  if (this._nodes.includes(node)) {
package/lib/duration.js CHANGED
@@ -15,6 +15,7 @@
15
15
  'use strict';
16
16
 
17
17
  const rclnodejs = require('./native_loader.js');
18
+ const { TypeValidationError, RangeValidationError } = require('./errors.js');
18
19
  const S_TO_NS = 10n ** 9n;
19
20
 
20
21
  /**
@@ -29,17 +30,31 @@ class Duration {
29
30
  */
30
31
  constructor(seconds = 0n, nanoseconds = 0n) {
31
32
  if (typeof seconds !== 'bigint') {
32
- throw new TypeError('Invalid argument of seconds');
33
+ throw new TypeValidationError('seconds', seconds, 'bigint', {
34
+ entityType: 'duration',
35
+ });
33
36
  }
34
37
 
35
38
  if (typeof nanoseconds !== 'bigint') {
36
- throw new TypeError('Invalid argument of nanoseconds');
39
+ throw new TypeValidationError('nanoseconds', nanoseconds, 'bigint', {
40
+ entityType: 'duration',
41
+ });
37
42
  }
38
43
 
39
44
  const total = seconds * S_TO_NS + nanoseconds;
40
45
  if (total >= 2n ** 63n) {
41
- throw new RangeError(
42
- 'Total nanoseconds value is too large to store in C time point.'
46
+ throw new RangeValidationError(
47
+ 'total nanoseconds',
48
+ total,
49
+ '< 2^63 (max C duration)',
50
+ {
51
+ entityType: 'duration',
52
+ details: {
53
+ seconds: seconds,
54
+ nanoseconds: nanoseconds,
55
+ total: total,
56
+ },
57
+ }
43
58
  );
44
59
  }
45
60
 
@@ -67,9 +82,9 @@ class Duration {
67
82
  if (other instanceof Duration) {
68
83
  return this._nanoseconds === other.nanoseconds;
69
84
  }
70
- throw new TypeError(
71
- `Can't compare duration with object of type: ${other.constructor.name}`
72
- );
85
+ throw new TypeValidationError('other', other, 'Duration', {
86
+ entityType: 'duration',
87
+ });
73
88
  }
74
89
 
75
90
  /**
@@ -81,7 +96,9 @@ class Duration {
81
96
  if (other instanceof Duration) {
82
97
  return this._nanoseconds !== other.nanoseconds;
83
98
  }
84
- throw new TypeError('Invalid argument');
99
+ throw new TypeValidationError('other', other, 'Duration', {
100
+ entityType: 'duration',
101
+ });
85
102
  }
86
103
 
87
104
  /**
@@ -93,7 +110,9 @@ class Duration {
93
110
  if (other instanceof Duration) {
94
111
  return this._nanoseconds < other.nanoseconds;
95
112
  }
96
- throw new TypeError('Invalid argument');
113
+ throw new TypeValidationError('other', other, 'Duration', {
114
+ entityType: 'duration',
115
+ });
97
116
  }
98
117
 
99
118
  /**
@@ -105,7 +124,9 @@ class Duration {
105
124
  if (other instanceof Duration) {
106
125
  return this._nanoseconds <= other.nanoseconds;
107
126
  }
108
- throw new TypeError('Invalid argument');
127
+ throw new TypeValidationError('other', other, 'Duration', {
128
+ entityType: 'duration',
129
+ });
109
130
  }
110
131
 
111
132
  /**
@@ -117,7 +138,9 @@ class Duration {
117
138
  if (other instanceof Duration) {
118
139
  return this._nanoseconds > other.nanoseconds;
119
140
  }
120
- throw new TypeError('Invalid argument');
141
+ throw new TypeValidationError('other', other, 'Duration', {
142
+ entityType: 'duration',
143
+ });
121
144
  }
122
145
 
123
146
  /**
@@ -129,7 +152,9 @@ class Duration {
129
152
  if (other instanceof Duration) {
130
153
  return this._nanoseconds >= other.nanoseconds;
131
154
  }
132
- throw new TypeError('Invalid argument');
155
+ throw new TypeValidationError('other', other, 'Duration', {
156
+ entityType: 'duration',
157
+ });
133
158
  }
134
159
  }
135
160