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.
@@ -0,0 +1,309 @@
1
+ // Copyright (c) 2025 Mahmoud Alghalayini. All rights reserved.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ 'use strict';
16
+
17
+ const EventEmitter = require('events');
18
+ const { TypeValidationError, OperationError } = require('./errors');
19
+ const { normalizeNodeName } = require('./utils');
20
+ const debug = require('debug')('rclnodejs:parameter_watcher');
21
+
22
+ /**
23
+ * @class ParameterWatcher - Watches parameter changes on a remote node
24
+ *
25
+ * Subscribes to /parameter_events and emits 'change' events when
26
+ * watched parameters on the target node are modified.
27
+ *
28
+ * @extends EventEmitter
29
+ */
30
+ class ParameterWatcher extends EventEmitter {
31
+ #node;
32
+ #paramClient;
33
+ #subscription;
34
+ #watchedParams;
35
+ #remoteNodeName;
36
+ #destroyed;
37
+
38
+ /**
39
+ * Create a ParameterWatcher instance.
40
+ * Note: Use node.createParameterWatcher() instead of calling this directly.
41
+ *
42
+ * @param {object} node - The local rclnodejs Node instance
43
+ * @param {string} remoteNodeName - Name of the remote node to watch
44
+ * @param {string[]} parameterNames - Array of parameter names to watch
45
+ * @param {object} [options] - Options for the parameter client
46
+ * @param {number} [options.timeout=5000] - Default timeout for parameter operations
47
+ * @hideconstructor
48
+ */
49
+ constructor(node, remoteNodeName, parameterNames, options = {}) {
50
+ super();
51
+
52
+ if (!node || typeof node.createParameterClient !== 'function') {
53
+ throw new TypeValidationError('node', node, 'Node instance', {
54
+ entityType: 'parameter watcher',
55
+ });
56
+ }
57
+
58
+ if (typeof remoteNodeName !== 'string' || remoteNodeName.trim() === '') {
59
+ throw new TypeValidationError(
60
+ 'remoteNodeName',
61
+ remoteNodeName,
62
+ 'non-empty string',
63
+ {
64
+ entityType: 'parameter watcher',
65
+ }
66
+ );
67
+ }
68
+
69
+ if (!Array.isArray(parameterNames) || parameterNames.length === 0) {
70
+ throw new TypeValidationError(
71
+ 'parameterNames',
72
+ parameterNames,
73
+ 'non-empty array',
74
+ {
75
+ entityType: 'parameter watcher',
76
+ }
77
+ );
78
+ }
79
+
80
+ this.#node = node;
81
+ this.#watchedParams = new Set(parameterNames);
82
+ this.#paramClient = node.createParameterClient(remoteNodeName, options);
83
+ // Cache the remote node name for error messages (in case paramClient is destroyed)
84
+ this.#remoteNodeName = this.#paramClient.remoteNodeName;
85
+ this.#subscription = null;
86
+ this.#destroyed = false;
87
+
88
+ debug(
89
+ 'Created ParameterWatcher for node=%s, params=%o',
90
+ remoteNodeName,
91
+ parameterNames
92
+ );
93
+ }
94
+
95
+ /**
96
+ * Get the remote node name being watched.
97
+ * @type {string}
98
+ * @readonly
99
+ */
100
+ get remoteNodeName() {
101
+ return this.#remoteNodeName;
102
+ }
103
+
104
+ /**
105
+ * Get the list of watched parameter names.
106
+ * @type {string[]}
107
+ * @readonly
108
+ */
109
+ get watchedParameters() {
110
+ return Array.from(this.#watchedParams);
111
+ }
112
+
113
+ /**
114
+ * Start watching for parameter changes.
115
+ * Waits for the remote node's parameter services and subscribes to parameter events.
116
+ *
117
+ * @param {number} [timeout=5000] - Timeout in milliseconds to wait for services
118
+ * @returns {Promise<boolean>} Resolves to true when watching has started
119
+ * @throws {Error} If the watcher has been destroyed
120
+ */
121
+ async start(timeout = 5000) {
122
+ this.#checkNotDestroyed();
123
+
124
+ debug('Starting ParameterWatcher for node=%s', this.remoteNodeName);
125
+
126
+ const available = await this.#paramClient.waitForService(timeout);
127
+
128
+ if (!available) {
129
+ debug(
130
+ 'Parameter services not available for node=%s',
131
+ this.remoteNodeName
132
+ );
133
+ return false;
134
+ }
135
+
136
+ if (!this.#subscription) {
137
+ this.#subscription = this.#node.createSubscription(
138
+ 'rcl_interfaces/msg/ParameterEvent',
139
+ '/parameter_events',
140
+ (event) => this.#handleParameterEvent(event)
141
+ );
142
+
143
+ debug('Subscribed to /parameter_events');
144
+ }
145
+
146
+ return true;
147
+ }
148
+
149
+ /**
150
+ * Get current values of all watched parameters.
151
+ *
152
+ * @param {object} [options] - Options for the parameter client
153
+ * @param {number} [options.timeout] - Timeout in milliseconds
154
+ * @param {AbortSignal} [options.signal] - AbortSignal for cancellation
155
+ * @returns {Promise<Parameter[]>} Array of Parameter objects
156
+ * @throws {Error} If the watcher has been destroyed
157
+ */
158
+ async getCurrentValues(options) {
159
+ this.#checkNotDestroyed();
160
+ return await this.#paramClient.getParameters(
161
+ Array.from(this.#watchedParams),
162
+ options
163
+ );
164
+ }
165
+
166
+ /**
167
+ * Add a parameter name to the watch list.
168
+ *
169
+ * @param {string} name - Parameter name to watch
170
+ * @throws {TypeError} If name is not a string
171
+ * @throws {Error} If the watcher has been destroyed
172
+ */
173
+ addParameter(name) {
174
+ this.#checkNotDestroyed();
175
+
176
+ if (typeof name !== 'string' || name.trim() === '') {
177
+ throw new TypeValidationError('name', name, 'non-empty string', {
178
+ entityType: 'parameter watcher',
179
+ entityName: this.remoteNodeName,
180
+ });
181
+ }
182
+
183
+ const wasAdded = !this.#watchedParams.has(name);
184
+ this.#watchedParams.add(name);
185
+
186
+ if (wasAdded) {
187
+ debug('Added parameter to watch list: %s', name);
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Remove a parameter name from the watch list.
193
+ *
194
+ * @param {string} name - Parameter name to stop watching
195
+ * @returns {boolean} True if the parameter was in the watch list
196
+ * @throws {Error} If the watcher has been destroyed
197
+ */
198
+ removeParameter(name) {
199
+ this.#checkNotDestroyed();
200
+
201
+ const wasRemoved = this.#watchedParams.delete(name);
202
+
203
+ if (wasRemoved) {
204
+ debug('Removed parameter from watch list: %s', name);
205
+ }
206
+
207
+ return wasRemoved;
208
+ }
209
+
210
+ /**
211
+ * Check if the watcher has been destroyed.
212
+ *
213
+ * @returns {boolean} True if destroyed
214
+ */
215
+ isDestroyed() {
216
+ return this.#destroyed;
217
+ }
218
+
219
+ /**
220
+ * Destroy the watcher and clean up resources.
221
+ * Unsubscribes from parameter events and destroys the parameter client.
222
+ */
223
+ destroy() {
224
+ if (this.#destroyed) {
225
+ return;
226
+ }
227
+
228
+ debug('Destroying ParameterWatcher for node=%s', this.remoteNodeName);
229
+
230
+ if (this.#subscription) {
231
+ try {
232
+ this.#node.destroySubscription(this.#subscription);
233
+ } catch (error) {
234
+ debug('Error destroying subscription: %s', error.message);
235
+ }
236
+ this.#subscription = null;
237
+ }
238
+
239
+ if (this.#paramClient) {
240
+ try {
241
+ this.#node.destroyParameterClient(this.#paramClient);
242
+ } catch (error) {
243
+ debug('Error destroying parameter client: %s', error.message);
244
+ }
245
+ this.#paramClient = null;
246
+ }
247
+
248
+ this.removeAllListeners();
249
+
250
+ this.#destroyed = true;
251
+ }
252
+
253
+ /**
254
+ * Handle parameter event from /parameter_events topic.
255
+ * @private
256
+ */
257
+ #handleParameterEvent(event) {
258
+ if (normalizeNodeName(event.node) !== this.remoteNodeName) {
259
+ return;
260
+ }
261
+
262
+ const relevantChanges = [];
263
+
264
+ if (event.new_parameters) {
265
+ const newParams = event.new_parameters.filter((p) =>
266
+ this.#watchedParams.has(p.name)
267
+ );
268
+ relevantChanges.push(...newParams);
269
+ }
270
+
271
+ if (event.changed_parameters) {
272
+ const changedParams = event.changed_parameters.filter((p) =>
273
+ this.#watchedParams.has(p.name)
274
+ );
275
+ relevantChanges.push(...changedParams);
276
+ }
277
+
278
+ if (event.deleted_parameters) {
279
+ const deletedParams = event.deleted_parameters.filter((p) =>
280
+ this.#watchedParams.has(p.name)
281
+ );
282
+ relevantChanges.push(...deletedParams);
283
+ }
284
+
285
+ if (relevantChanges.length > 0) {
286
+ debug(
287
+ 'Parameter change detected: %o',
288
+ relevantChanges.map((p) => p.name)
289
+ );
290
+ this.emit('change', relevantChanges);
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Check if the watcher has been destroyed and throw if so.
296
+ * @private
297
+ */
298
+ #checkNotDestroyed() {
299
+ if (this.#destroyed) {
300
+ throw new OperationError('ParameterWatcher has been destroyed', {
301
+ code: 'WATCHER_DESTROYED',
302
+ entityType: 'parameter watcher',
303
+ entityName: this.remoteNodeName,
304
+ });
305
+ }
306
+ }
307
+ }
308
+
309
+ module.exports = ParameterWatcher;
package/lib/qos.js CHANGED
@@ -14,6 +14,8 @@
14
14
 
15
15
  'use strict';
16
16
 
17
+ const { TypeValidationError } = require('./errors.js');
18
+
17
19
  /**
18
20
  * Enum for HistoryPolicy
19
21
  * @readonly
@@ -129,7 +131,9 @@ class QoS {
129
131
  */
130
132
  set history(history) {
131
133
  if (typeof history !== 'number') {
132
- throw new TypeError('Invalid argument');
134
+ throw new TypeValidationError('history', history, 'number', {
135
+ entityType: 'qos',
136
+ });
133
137
  }
134
138
 
135
139
  this._history = history;
@@ -154,7 +158,9 @@ class QoS {
154
158
  */
155
159
  set depth(depth) {
156
160
  if (typeof depth !== 'number') {
157
- throw new TypeError('Invalid argument');
161
+ throw new TypeValidationError('depth', depth, 'number', {
162
+ entityType: 'qos',
163
+ });
158
164
  }
159
165
 
160
166
  this._depth = depth;
@@ -179,7 +185,9 @@ class QoS {
179
185
  */
180
186
  set reliability(reliability) {
181
187
  if (typeof reliability !== 'number') {
182
- throw new TypeError('Invalid argument');
188
+ throw new TypeValidationError('reliability', reliability, 'number', {
189
+ entityType: 'qos',
190
+ });
183
191
  }
184
192
 
185
193
  this._reliability = reliability;
@@ -204,7 +212,9 @@ class QoS {
204
212
  */
205
213
  set durability(durability) {
206
214
  if (typeof durability !== 'number') {
207
- throw new TypeError('Invalid argument');
215
+ throw new TypeValidationError('durability', durability, 'number', {
216
+ entityType: 'qos',
217
+ });
208
218
  }
209
219
 
210
220
  this._durability = durability;
@@ -229,7 +239,14 @@ class QoS {
229
239
  */
230
240
  set avoidRosNameSpaceConventions(avoidRosNameSpaceConventions) {
231
241
  if (typeof avoidRosNameSpaceConventions !== 'boolean') {
232
- throw new TypeError('Invalid argument');
242
+ throw new TypeValidationError(
243
+ 'avoidRosNameSpaceConventions',
244
+ avoidRosNameSpaceConventions,
245
+ 'boolean',
246
+ {
247
+ entityType: 'qos',
248
+ }
249
+ );
233
250
  }
234
251
 
235
252
  this._avoidRosNameSpaceConventions = avoidRosNameSpaceConventions;
package/lib/rate.js CHANGED
@@ -15,6 +15,7 @@
15
15
  const rclnodejs = require('../index.js');
16
16
  const Context = require('./context.js');
17
17
  const NodeOptions = require('./node_options.js');
18
+ const { OperationError } = require('./errors.js');
18
19
 
19
20
  const NOP_FN = () => {};
20
21
 
@@ -86,7 +87,11 @@ class Rate {
86
87
  */
87
88
  async sleep() {
88
89
  if (this.isCanceled()) {
89
- throw new Error('Rate has been cancelled.');
90
+ throw new OperationError('Rate has been cancelled', {
91
+ code: 'RATE_CANCELLED',
92
+ entityType: 'rate',
93
+ details: { frequency: this._hz },
94
+ });
90
95
  }
91
96
 
92
97
  return new Promise((resolve) => {
@@ -15,6 +15,7 @@
15
15
  'use strict';
16
16
 
17
17
  const rclnodejs = require('./native_loader.js');
18
+ const { TypeValidationError } = require('./errors.js');
18
19
 
19
20
  class Serialization {
20
21
  /**
@@ -25,7 +26,9 @@ class Serialization {
25
26
  */
26
27
  static serializeMessage(message, typeClass) {
27
28
  if (!(message instanceof typeClass)) {
28
- throw new TypeError('Message must be a valid ros2 message type');
29
+ throw new TypeValidationError('message', message, typeClass.name, {
30
+ entityType: 'serializer',
31
+ });
29
32
  }
30
33
  return rclnodejs.serialize(
31
34
  typeClass.type().pkgName,
@@ -43,7 +46,9 @@ class Serialization {
43
46
  */
44
47
  static deserializeMessage(buffer, typeClass) {
45
48
  if (!(buffer instanceof Buffer)) {
46
- throw new TypeError('Buffer is required for deserialization');
49
+ throw new TypeValidationError('buffer', buffer, 'Buffer', {
50
+ entityType: 'serializer',
51
+ });
47
52
  }
48
53
  const rosMsg = new typeClass();
49
54
  rclnodejs.deserialize(
package/lib/time.js CHANGED
@@ -17,6 +17,7 @@
17
17
  const rclnodejs = require('./native_loader.js');
18
18
  const Duration = require('./duration.js');
19
19
  const ClockType = require('./clock_type.js');
20
+ const { TypeValidationError, RangeValidationError } = require('./errors.js');
20
21
  const S_TO_NS = 10n ** 9n;
21
22
 
22
23
  /**
@@ -36,29 +37,49 @@ class Time {
36
37
  clockType = ClockType.SYSTEM_TIME
37
38
  ) {
38
39
  if (typeof seconds !== 'bigint') {
39
- throw new TypeError('Invalid argument of seconds');
40
+ throw new TypeValidationError('seconds', seconds, 'bigint', {
41
+ entityType: 'time',
42
+ });
40
43
  }
41
44
 
42
45
  if (typeof nanoseconds !== 'bigint') {
43
- throw new TypeError('Invalid argument of nanoseconds');
46
+ throw new TypeValidationError('nanoseconds', nanoseconds, 'bigint', {
47
+ entityType: 'time',
48
+ });
44
49
  }
45
50
 
46
51
  if (typeof clockType !== 'number') {
47
- throw new TypeError('Invalid argument of clockType');
52
+ throw new TypeValidationError('clockType', clockType, 'number', {
53
+ entityType: 'time',
54
+ });
48
55
  }
49
56
 
50
57
  if (seconds < 0n) {
51
- throw new RangeError('seconds value must not be negative');
58
+ throw new RangeValidationError('seconds', seconds, '>= 0', {
59
+ entityType: 'time',
60
+ });
52
61
  }
53
62
 
54
63
  if (nanoseconds < 0n) {
55
- throw new RangeError('nanoseconds value must not be negative');
64
+ throw new RangeValidationError('nanoseconds', nanoseconds, '>= 0', {
65
+ entityType: 'time',
66
+ });
56
67
  }
57
68
 
58
69
  const total = seconds * S_TO_NS + nanoseconds;
59
70
  if (total >= 2n ** 63n) {
60
- throw new RangeError(
61
- 'Total nanoseconds value is too large to store in C time point.'
71
+ throw new RangeValidationError(
72
+ 'total nanoseconds',
73
+ total,
74
+ '< 2^63 (max C time point)',
75
+ {
76
+ entityType: 'time',
77
+ details: {
78
+ seconds: seconds,
79
+ nanoseconds: nanoseconds,
80
+ total: total,
81
+ },
82
+ }
62
83
  );
63
84
  }
64
85
  this._nanoseconds = total;
@@ -116,7 +137,9 @@ class Time {
116
137
  this._clockType
117
138
  );
118
139
  }
119
- throw new TypeError('Invalid argument');
140
+ throw new TypeValidationError('other', other, 'Duration', {
141
+ entityType: 'time',
142
+ });
120
143
  }
121
144
 
122
145
  /**
@@ -127,7 +150,18 @@ class Time {
127
150
  sub(other) {
128
151
  if (other instanceof Time) {
129
152
  if (other._clockType !== this._clockType) {
130
- throw new TypeError("Can't subtract times with different clock types");
153
+ throw new TypeValidationError(
154
+ 'other',
155
+ other,
156
+ `Time with clock type ${this._clockType}`,
157
+ {
158
+ entityType: 'time',
159
+ details: {
160
+ expectedClockType: this._clockType,
161
+ providedClockType: other._clockType,
162
+ },
163
+ }
164
+ );
131
165
  }
132
166
  return new Duration(0n, this._nanoseconds - other._nanoseconds);
133
167
  } else if (other instanceof Duration) {
@@ -137,7 +171,9 @@ class Time {
137
171
  this._clockType
138
172
  );
139
173
  }
140
- throw new TypeError('Invalid argument');
174
+ throw new TypeValidationError('other', other, 'Time or Duration', {
175
+ entityType: 'time',
176
+ });
141
177
  }
142
178
 
143
179
  /**
@@ -148,11 +184,24 @@ class Time {
148
184
  eq(other) {
149
185
  if (other instanceof Time) {
150
186
  if (other._clockType !== this._clockType) {
151
- throw new TypeError("Can't compare times with different clock types");
187
+ throw new TypeValidationError(
188
+ 'other',
189
+ other,
190
+ `Time with clock type ${this._clockType}`,
191
+ {
192
+ entityType: 'time',
193
+ details: {
194
+ expectedClockType: this._clockType,
195
+ providedClockType: other._clockType,
196
+ },
197
+ }
198
+ );
152
199
  }
153
200
  return this._nanoseconds === other.nanoseconds;
154
201
  }
155
- throw new TypeError('Invalid argument');
202
+ throw new TypeValidationError('other', other, 'Time', {
203
+ entityType: 'time',
204
+ });
156
205
  }
157
206
 
158
207
  /**
@@ -163,10 +212,24 @@ class Time {
163
212
  ne(other) {
164
213
  if (other instanceof Time) {
165
214
  if (other._clockType !== this._clockType) {
166
- throw new TypeError("Can't compare times with different clock types");
215
+ throw new TypeValidationError(
216
+ 'other',
217
+ other,
218
+ `Time with clock type ${this._clockType}`,
219
+ {
220
+ entityType: 'time',
221
+ details: {
222
+ expectedClockType: this._clockType,
223
+ providedClockType: other._clockType,
224
+ },
225
+ }
226
+ );
167
227
  }
168
228
  return this._nanoseconds !== other.nanoseconds;
169
229
  }
230
+ throw new TypeValidationError('other', other, 'Time', {
231
+ entityType: 'time',
232
+ });
170
233
  }
171
234
 
172
235
  /**
@@ -177,11 +240,24 @@ class Time {
177
240
  lt(other) {
178
241
  if (other instanceof Time) {
179
242
  if (other._clockType !== this._clockType) {
180
- throw new TypeError("Can't compare times with different clock types");
243
+ throw new TypeValidationError(
244
+ 'other',
245
+ other,
246
+ `Time with clock type ${this._clockType}`,
247
+ {
248
+ entityType: 'time',
249
+ details: {
250
+ expectedClockType: this._clockType,
251
+ providedClockType: other._clockType,
252
+ },
253
+ }
254
+ );
181
255
  }
182
256
  return this._nanoseconds < other.nanoseconds;
183
257
  }
184
- throw new TypeError('Invalid argument');
258
+ throw new TypeValidationError('other', other, 'Time', {
259
+ entityType: 'time',
260
+ });
185
261
  }
186
262
 
187
263
  /**
@@ -192,11 +268,24 @@ class Time {
192
268
  lte(other) {
193
269
  if (other instanceof Time) {
194
270
  if (other._clockType !== this._clockType) {
195
- throw new TypeError("Can't compare times with different clock types");
271
+ throw new TypeValidationError(
272
+ 'other',
273
+ other,
274
+ `Time with clock type ${this._clockType}`,
275
+ {
276
+ entityType: 'time',
277
+ details: {
278
+ expectedClockType: this._clockType,
279
+ providedClockType: other._clockType,
280
+ },
281
+ }
282
+ );
196
283
  }
197
284
  return this._nanoseconds <= other.nanoseconds;
198
285
  }
199
- throw new TypeError('Invalid argument');
286
+ throw new TypeValidationError('other', other, 'Time', {
287
+ entityType: 'time',
288
+ });
200
289
  }
201
290
 
202
291
  /**
@@ -207,11 +296,24 @@ class Time {
207
296
  gt(other) {
208
297
  if (other instanceof Time) {
209
298
  if (other._clockType !== this._clockType) {
210
- throw new TypeError("Can't compare times with different clock types");
299
+ throw new TypeValidationError(
300
+ 'other',
301
+ other,
302
+ `Time with clock type ${this._clockType}`,
303
+ {
304
+ entityType: 'time',
305
+ details: {
306
+ expectedClockType: this._clockType,
307
+ providedClockType: other._clockType,
308
+ },
309
+ }
310
+ );
211
311
  }
212
312
  return this._nanoseconds > other.nanoseconds;
213
313
  }
214
- throw new TypeError('Invalid argument');
314
+ throw new TypeValidationError('other', other, 'Time', {
315
+ entityType: 'time',
316
+ });
215
317
  }
216
318
 
217
319
  /**
@@ -222,11 +324,24 @@ class Time {
222
324
  gte(other) {
223
325
  if (other instanceof Time) {
224
326
  if (other._clockType !== this._clockType) {
225
- throw new TypeError("Can't compare times with different clock types");
327
+ throw new TypeValidationError(
328
+ 'other',
329
+ other,
330
+ `Time with clock type ${this._clockType}`,
331
+ {
332
+ entityType: 'time',
333
+ details: {
334
+ expectedClockType: this._clockType,
335
+ providedClockType: other._clockType,
336
+ },
337
+ }
338
+ );
226
339
  }
227
340
  return this._nanoseconds >= other.nanoseconds;
228
341
  }
229
- throw new TypeError('Invalid argument');
342
+ throw new TypeValidationError('other', other, 'Time', {
343
+ entityType: 'time',
344
+ });
230
345
  }
231
346
 
232
347
  /**