UNPKG

rclnodejs

Version:
967 lines (883 loc) 25.4 kB
// Copyright (c) 2020 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. // Note: parameter api and function based on // https://design.ros2.org/articles/ros_parameters.html // https://github.com/ros2/rcl and // https://github.com/ros2/rclpy 'use strict'; const { isClose } = require('./utils.js'); const { TypeValidationError, RangeValidationError, ParameterError, ParameterTypeError, } = require('./errors.js'); /** * The plus/minus tolerance for determining number equivalence. * @constant {number} * * @see [FloatingPointRange]{@link FloatingPointRange} * @see [IntegerRange]{@link IntegerRange} */ const DEFAULT_NUMERIC_RANGE_TOLERANCE = 1e-6; const PARAMETER_SEPARATOR = '.'; const PARAMETER_BYTE = 10; /** * Enum for ParameterType * @readonly * @enum {number} */ const ParameterType = { /** @member {number} */ PARAMETER_NOT_SET: 0, /** @member {number} */ PARAMETER_BOOL: 1, /** @member {number} */ PARAMETER_INTEGER: 2, /** @member {number} */ PARAMETER_DOUBLE: 3, /** @member {number} */ PARAMETER_STRING: 4, /** @member {number} */ PARAMETER_BYTE_ARRAY: 5, /** @member {number} */ PARAMETER_BOOL_ARRAY: 6, /** @member {number} */ PARAMETER_INTEGER_ARRAY: 7, /** @member {number} */ PARAMETER_DOUBLE_ARRAY: 8, /** @member {number} */ PARAMETER_STRING_ARRAY: 9, }; /** * A node parameter. * @class */ class Parameter { /** * Create a Parameter instance from an rlc_interfaces/msg/Parameter message. * @constructs * @param {rcl_interfaces/msg/Parameter} parameterMsg - The message to convert to a parameter. * @return {Parameter} - The new instance. */ static fromParameterMessage(parameterMsg) { const name = parameterMsg.name; const type = parameterMsg.value.type; let value; switch (type) { case ParameterType.PARAMETER_NOT_SET: break; case ParameterType.PARAMETER_BOOL: value = parameterMsg.value.bool_value; break; case ParameterType.PARAMETER_BOOL_ARRAY: value = parameterMsg.value.bool_array_value; break; case ParameterType.PARAMETER_BYTE_ARRAY: value = Array.from(parameterMsg.value.byte_array_value); break; case ParameterType.PARAMETER_DOUBLE: value = parameterMsg.value.double_value; break; case ParameterType.PARAMETER_DOUBLE_ARRAY: value = Array.from(parameterMsg.value.double_array_value); break; case ParameterType.PARAMETER_INTEGER: value = parameterMsg.value.integer_value; break; case ParameterType.PARAMETER_INTEGER_ARRAY: value = Array.from(parameterMsg.value.integer_array_value); break; case ParameterType.PARAMETER_STRING: value = parameterMsg.value.string_value; break; case ParameterType.PARAMETER_STRING_ARRAY: value = Array.from(parameterMsg.value.string_array_value); break; } return new Parameter(name, type, value); } /** * Create new parameter instances. * @constructor * * @param {string} name - The parameter name, must be a valid name. * @param {ParameterType} type - The type identifier. * @param {any} value - The parameter value. */ constructor(name, type, value) { this._name = name; this._type = type; // Convert to bigint if it's type of `PARAMETER_INTEGER`. this._value = this._type == ParameterType.PARAMETER_INTEGER ? BigInt(value) : value; this._isDirty = true; this.validate(); } /** * Get name * * @return {string} - The parameter name. */ get name() { return this._name; } /** * Get type * * @return {ParameterType} - The parameter type. */ get type() { return this._type; } /** * Get value. * * @return {any} - The parameter value. */ get value() { return this._value; } /** * Set value. * Value must be compatible with the type property. * @param {any} newValue - The parameter name. */ set value(newValue) { // no empty array value allowed this._value = Array.isArray(newValue) && newValue.length === 0 ? null : newValue; this._dirty = true; this.validate(); } /** * Check the state of this property. * Throw TypeError on first property with invalid type. * @return {undefined} */ validate() { // validate name if ( !this.name || typeof this.name !== 'string' || this.name.trim().length === 0 ) { throw new TypeValidationError('name', this.name, 'non-empty string', { entityType: 'parameter', }); } // validate type if (!validType(this.type)) { throw new ParameterError('Invalid parameter type', this.name, { details: { providedType: this.type }, }); } // validate value if (!validValue(this.value, this.type)) { throw new ParameterTypeError(this.name, this.type, typeof this.value, { details: { providedValue: this.value, }, }); } this._dirty = false; } /** * Create a rcl_interfaces.msg.Parameter from this instance. * * @return {rcl_interfaces.msg.Parameter} - The new instance. */ toParameterMessage() { const msg = { name: this.name, value: this.toParameterValueMessage(), }; return msg; } /** * Create a rcl_interfaces.msg.ParameterValue from this instance. * * @return {rcl_interfaces.msg.ParameterValue} - The new instance. */ toParameterValueMessage() { const msg = {}; msg.type = this.type; switch (this.type) { case ParameterType.PARAMETER_NOT_SET: break; case ParameterType.PARAMETER_BOOL: msg.bool_value = this.value; break; case ParameterType.PARAMETER_BOOL_ARRAY: msg.bool_array_value = this.value; break; case ParameterType.PARAMETER_BYTE_ARRAY: msg.byte_array_value = this.value.map((val) => Math.trunc(val)); break; case ParameterType.PARAMETER_DOUBLE: msg.double_value = this.value; break; case ParameterType.PARAMETER_DOUBLE_ARRAY: msg.double_array_value = this.value; break; case ParameterType.PARAMETER_INTEGER: msg.integer_value = this.value; break; case ParameterType.PARAMETER_INTEGER_ARRAY: msg.integer_array_value = this.value; break; case ParameterType.PARAMETER_STRING: msg.string_value = this.value; break; case ParameterType.PARAMETER_STRING_ARRAY: msg.string_array_value = this.value; break; } return msg; } } /** * A node parameter descriptor. * @class */ class ParameterDescriptor { /** * Create a new instance from a parameter. * @constructs * @param {Parameter} parameter - The parameter from which new instance is constructed. * @return {ParameterDescriptor} - The new instance. */ static fromParameter(parameter) { const name = parameter.name; const type = parameter.type; return new ParameterDescriptor(name, type, 'Created from parameter.'); } /** * Create new instances. * @constructor * @param {string} name - The descriptor name, must be a valid name. * @param {ParameterType} type - The type identifier. * @param {string} [description] - A descriptive string. * @param {boolean} [readOnly] - True indicates a parameter of this type can not be modified. Default = false. * @param {Range} [range] - An optional IntegerRange or FloatingPointRange. */ constructor( name, type = ParameterType.PARAMETER_NOT_SET, description = 'no description', readOnly = false, range = null ) { this._name = name; // string this._type = type; // ParameterType this._description = description; this._readOnly = readOnly; this._additionalConstraints = ''; this._range = range; this.validate(); } /** * Get name. * * @return {string} - The name property. */ get name() { return this._name; } /** * Get type. * * @return {ParameterType} - The type property. */ get type() { return this._type; } /** * Get description. * * @return {string} - A descriptive string property. */ get description() { return this._description; } /** * Get readOnly property. * * @return {boolean} - The readOnly property. */ get readOnly() { return this._readOnly; } /** * Get additionalConstraints property. * * @return {string} - The additionalConstraints property. */ get additionalConstraints() { return this._additionalConstraints; } /** * Set additionalConstraints property. . * * @param {string} constraintDescription - The new value. */ set additionalConstraints(constraintDescription) { this._additionalConstraints = constraintDescription; } /** * Determine if rangeConstraint property has been set. * * @return {boolean} - The rangeConstraint property. */ hasRange() { return !!this._range; } /** * Get range. * * @return {FloatingPointRange|IntegerRange} - The range property. */ get range() { return this._range; } /** * Set range. * The range must be compatible with the type property. * @param {FloatingPointRange|IntegerRange} range - The new range. */ set range(range) { if (!range) { this._range = null; return; } if (!(range instanceof Range)) { throw new TypeValidationError('range', range, 'Range', { entityType: 'parameter descriptor', parameterName: this.name, }); } if (!range.isValidType(this.type)) { throw new ParameterError( 'Incompatible Range for parameter type', this.name, { entityType: 'parameter descriptor', details: { rangeType: range.constructor.name, parameterType: this.type, }, } ); } this._range = range; } /** * Check the state and ensure it is valid. * Throw a TypeError if invalid state is detected. * * @return {undefined} */ validate() { // validate name if ( !this.name || typeof this.name !== 'string' || this.name.trim().length === 0 ) { throw new TypeValidationError('name', this.name, 'non-empty string', { entityType: 'parameter descriptor', }); } // validate type if (!validType(this.type)) { throw new ParameterError('Invalid parameter type', this.name, { entityType: 'parameter descriptor', details: { providedType: this.type }, }); } // validate description if (this.description && typeof this.description !== 'string') { throw new TypeValidationError('description', this.description, 'string', { entityType: 'parameter descriptor', parameterName: this.name, }); } // validate rangeConstraint if (this.hasRange() && !this.range.isValidType(this.type)) { throw new ParameterError( 'Incompatible Range for parameter type', this.name, { entityType: 'parameter descriptor', details: { rangeType: this.range.constructor.name, parameterType: this.type, }, } ); } } /** * Check a parameter for consistency with this descriptor. * Throw an Error if an inconsistent state is detected. * * @param {Parameter} parameter - The parameter to test for consistency. * @return {undefined} */ validateParameter(parameter) { if (!parameter) { throw new TypeValidationError('parameter', parameter, 'Parameter', { entityType: 'parameter descriptor', parameterName: this.name, }); } // ensure parameter is valid try { parameter.validate(); } catch (err) { throw new ParameterError('Parameter is invalid', parameter.name, { cause: err, details: { validationError: err.message }, }); } // ensure this descriptor is valid try { this.validate(); } catch (err) { throw new ParameterError('Descriptor is invalid', this.name, { entityType: 'parameter descriptor', cause: err, details: { validationError: err.message }, }); } if (this.name !== parameter.name) { throw new ParameterError('Name mismatch', this.name, { details: { descriptorName: this.name, parameterName: parameter.name, }, }); } if (this.type !== parameter.type) { throw new ParameterTypeError(this.name, this.type, parameter.type, { details: { expectedType: this.type, actualType: parameter.type, }, }); } if (this.hasRange() && !this.range.inRange(parameter.value)) { throw new RangeValidationError( 'value', parameter.value, `${this.range.fromValue} to ${this.range.toValue}`, { entityType: 'parameter', parameterName: parameter.name, details: { range: { from: this.range.fromValue, to: this.range.toValue, step: this.range.step, }, }, } ); } } /** * Create a rcl_interfaces.msg.ParameterDescriptor from this descriptor. * * @return {rcl_interfaces.msg.ParameterDescriptor} - The new message. */ toMessage() { const msg = { name: this.name, type: this.type, description: this.description, additional_constraints: this.additionalConstraints, read_only: this.readOnly, }; if ( (this._type === ParameterType.PARAMETER_INTEGER || this._type === ParameterType.PARAMETER_INTEGER_ARRAY) && this._rangeConstraint instanceof IntegerRange ) { msg.integer_range = [this._rangeConstraint]; } else if ( (this._type === ParameterType.PARAMETER_DOUBLE || this._type === ParameterType.PARAMETER_DOUBLE_ARRAY) && this._rangeConstraint instanceof FloatingPointRange ) { msg.floating_point_range = [this._rangeConstraint]; } return msg; } } /** * An abstract class defining a range of numbers between 2 points inclusively * divided by a step value. * @class */ class Range { /** * Create a new instance. * @constructor * @param {number} fromValue - The lowest inclusive value in range * @param {number} toValue - The highest inclusive value in range * @param {number} step - The internal unit size. */ constructor(fromValue, toValue, step = 1) { this._fromValue = fromValue; this._toValue = toValue; this._step = step; } /** * Get fromValue. * * @return {number} - The lowest inclusive value in range. */ get fromValue() { return this._fromValue; } /** * Get toValue. * * @return {number} - The highest inclusive value in range. */ get toValue() { return this._toValue; } /** * Get step unit. * * @return {number} - The internal unit size. */ get step() { return this._step; } /** * Determine if a value is within this range. * A TypeError is thrown when value is not a number or bigint. * Subclasses should override and call this method for basic type checking. * * @param {number|bigint} value - The number or bigint to check. * @return {boolean} - True if value satisfies the range; false otherwise. */ inRange(value) { if (Array.isArray(value)) { const valArr = value; return valArr.reduce( (inRange, val) => inRange && this.inRange(val), true ); } else if (typeof value !== 'number' && typeof value !== 'bigint') { throw new TypeValidationError('value', value, 'number or bigint', { entityType: 'range', }); } return true; } /** * Abstract method that determines if a ParameterType is compatible. * Subclasses must implement this method. * * @param {ParameterType} parameterType - The parameter type to test. * @return {boolean} - True if parameterType is compatible; otherwise return false. */ // eslint-disable-next-line no-unused-vars isValidType(parameterType) { return false; } } /** * Defines a range for floating point values. * @class */ class FloatingPointRange extends Range { /** * Create a new instance. * @constructor * @param {number} fromValue - The lowest inclusive value in range * @param {number} toValue - The highest inclusive value in range * @param {number} step - The internal unit size. * @param {number} tolerance - The plus/minus tolerance for number equivalence. */ constructor( fromValue, toValue, step = 1, tolerance = DEFAULT_NUMERIC_RANGE_TOLERANCE ) { super(fromValue, toValue, step); this._tolerance = tolerance; } get tolerance() { return this._tolerance; } /** * Determine if a ParameterType is compatible. * * @param {ParameterType} parameterType - The parameter type to test. * @return {boolean} - True if parameterType is compatible; otherwise return false. */ isValidType(parameterType) { const result = parameterType === ParameterType.PARAMETER_DOUBLE || parameterType === ParameterType.PARAMETER_DOUBLE_ARRAY; return result; } /** * Determine if a value is within this range. * A TypeError is thrown when value is not a number. * * @param {number} value - The number to check. * @return {boolean} - True if value satisfies the range; false otherwise. */ inRange(value) { if (!super.inRange(value)) return false; const min = Math.min(this.fromValue, this.toValue); const max = Math.max(this.fromValue, this.toValue); if ( isClose(value, min, this.tolerance) || isClose(value, max, this.tolerance) ) { return true; } if (value < min || value > max) { return false; } if (this.step != 0.0) { const distanceInSteps = Math.round((value - min) / this.step); if (!isClose(min + distanceInSteps * this.step, value, this.tolerance)) { return false; } } return true; } } /** * Defines a range for integer values. * @class */ class IntegerRange extends Range { /** * Create a new instance. * @constructor * @param {bigint} fromValue - The lowest inclusive value in range * @param {bigint} toValue - The highest inclusive value in range * @param {bigint} step - The internal unit size. */ constructor(fromValue, toValue, step = 1n) { super(fromValue, toValue, step); } /** * Determine if a ParameterType is compatible. * * @param {ParameterType} parameterType - The parameter type to test. * @return {boolean} - True if parameterType is compatible; otherwise return false. */ isValidType(parameterType) { const result = parameterType === ParameterType.PARAMETER_INTEGER || parameterType === ParameterType.PARAMETER_INTEGER_ARRAY; return result; } inRange(value) { const min = this.fromValue; const max = this.toValue; if (value < min || value > max) { return false; } if (this.step != 0n && (value - min) % this.step !== 0n) { return false; } return true; } } /** * Infer a ParameterType for JS primitive types: * string, boolean, number and arrays of these types. * A TypeError is thrown for a value who's type is not one of * the set listed. * @param {any} value - The value to infer it's ParameterType * @returns {ParameterType} - The ParameterType that best scribes the value. */ function parameterTypeFromValue(value) { if (value === null || value === undefined) { return ParameterType.PARAMETER_NOT_SET; } if (typeof value === 'boolean') { return ParameterType.PARAMETER_BOOL; } if (typeof value === 'string') { return ParameterType.PARAMETER_STRING; } if (typeof value === 'bigint') { return ParameterType.PARAMETER_INTEGER; } if (typeof value === 'number') { // Distinguish between integer and double return Number.isInteger(value) ? ParameterType.PARAMETER_INTEGER : ParameterType.PARAMETER_DOUBLE; } // Handle TypedArrays if (ArrayBuffer.isView(value) && !(value instanceof DataView)) { if (value instanceof Uint8Array) { return ParameterType.PARAMETER_BYTE_ARRAY; } // For other typed arrays, infer from first element if (value.length > 0) { const firstType = parameterTypeFromValue(value[0]); if (firstType === ParameterType.PARAMETER_INTEGER) { return ParameterType.PARAMETER_INTEGER_ARRAY; } return ParameterType.PARAMETER_DOUBLE_ARRAY; } return ParameterType.PARAMETER_NOT_SET; } if (Array.isArray(value)) { if (value.length === 0) { return ParameterType.PARAMETER_NOT_SET; } const elementType = parameterTypeFromValue(value[0]); switch (elementType) { case ParameterType.PARAMETER_BOOL: return ParameterType.PARAMETER_BOOL_ARRAY; case ParameterType.PARAMETER_INTEGER: return ParameterType.PARAMETER_INTEGER_ARRAY; case ParameterType.PARAMETER_DOUBLE: return ParameterType.PARAMETER_DOUBLE_ARRAY; case ParameterType.PARAMETER_STRING: return ParameterType.PARAMETER_STRING_ARRAY; default: return ParameterType.PARAMETER_NOT_SET; } } // Unrecognized value type throw new TypeValidationError('value', value, 'valid parameter type', { entityType: 'parameter', details: { supportedTypes: [ 'boolean', 'string', 'number', 'boolean[]', 'number[]', 'string[]', ], }, }); } /** * Determine if a number maps to is a valid ParameterType. * * @param {ParameterType} parameterType - The value to test. * @return {boolean} - True if value is a valid ParameterType; false otherwise. */ function validType(parameterType) { let result = typeof parameterType === 'number' && ParameterType.PARAMETER_NOT_SET <= parameterType <= ParameterType.PARAMETER_STRING_ARRAY; return result; } /** * Test if value can be represented by a ParameterType. * * @param {number} value - The value to test. * @param {ParameterType} type - The ParameterType to test value against. * @return {boolean} - True if value can be represented by type. */ function validValue(value, type) { if (value == null) { return type === ParameterType.PARAMETER_NOT_SET; } let result = true; switch (type) { case ParameterType.PARAMETER_NOT_SET: result = !value; break; case ParameterType.PARAMETER_BOOL: result = typeof value === 'boolean'; break; case ParameterType.PARAMETER_STRING: result = typeof value === 'string'; break; case ParameterType.PARAMETER_DOUBLE: case PARAMETER_BYTE: result = typeof value === 'number'; break; case ParameterType.PARAMETER_INTEGER: result = typeof value === 'bigint'; break; case ParameterType.PARAMETER_BOOL_ARRAY: case ParameterType.PARAMETER_BYTE_ARRAY: case ParameterType.PARAMETER_INTEGER_ARRAY: case ParameterType.PARAMETER_DOUBLE_ARRAY: case ParameterType.PARAMETER_STRING_ARRAY: result = _validArray(value, type); break; default: result = false; } return result; } function _validArray(values, type) { if (!Array.isArray(values)) return false; let arrayElementType; if (type === ParameterType.PARAMETER_BOOL_ARRAY) { arrayElementType = ParameterType.PARAMETER_BOOL; } else if (type === ParameterType.PARAMETER_BYTE_ARRAY) { arrayElementType = PARAMETER_BYTE; } if (type === ParameterType.PARAMETER_INTEGER_ARRAY) { arrayElementType = ParameterType.PARAMETER_INTEGER; } if (type === ParameterType.PARAMETER_DOUBLE_ARRAY) { arrayElementType = ParameterType.PARAMETER_DOUBLE; } if (type === ParameterType.PARAMETER_STRING_ARRAY) { arrayElementType = ParameterType.PARAMETER_STRING; } return values.reduce( (compatible, val) => compatible && !_isArrayParameterType(arrayElementType) && validValue(val, arrayElementType), true ); } function _isArrayParameterType(type) { return ( type === ParameterType.PARAMETER_BOOL_ARRAY || type === ParameterType.PARAMETER_BYTE_ARRAY || type === ParameterType.PARAMETER_INTEGER_ARRAY || type === ParameterType.PARAMETER_DOUBLE_ARRAY || type === ParameterType.PARAMETER_STRING_ARRAY ); } module.exports = { ParameterType, Parameter, ParameterDescriptor, PARAMETER_SEPARATOR, Range, FloatingPointRange, IntegerRange, DEFAULT_NUMERIC_RANGE_TOLERANCE, parameterTypeFromValue, };