// Copyright (c) 2020 Matt Richard. 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 rclnodejs = require('bindings')('rclnodejs');
const ActionInterfaces = require('./interfaces.js');
const ActionUuid = require('./uuid.js');
const Entity = require('../entity.js');
const { CancelResponse, GoalEvent, GoalResponse } = require('./response.js');
const loader = require('../interface_loader.js');
const QoS = require('../qos.js');
const ServerGoalHandle = require('./server_goal_handle.js');
/**
* Execute the goal.
* @param {ServerGoalHandle} goalHandle - The server goal handle.
* @returns {undefined}
*/
function defaultHandleAcceptedCallback(goalHandle) {
goalHandle.execute();
}
/**
* Accept all goals.
* @returns {number} - Always responds with acceptance.
*/
function defaultGoalCallback() {
return GoalResponse.ACCEPT;
}
/**
* No cancellations.
* @returns {number} - Always responds with rejection
*/
function defaultCancelCallback() {
return CancelResponse.REJECT;
}
/**
* @class - ROS Action server.
*/
class ActionServer extends Entity {
/**
* Creates a new action server.
* @constructor
*
* @param {Node} node - The ROS node to add the action server to.
* @param {function|string|object} typeClass - Type of the action.
* @param {string} actionName - Name of the action. Used as part of the underlying topic and service names.
* @param {function} executeCallback - Callback function for processing accepted goals.
* @param {function} goalCallback - Callback function for handling new goal requests.
* @param {function} handleAcceptedCallback - Callback function for handling newly accepted goals.
* @param {function} cancelCallback - Callback function for handling cancel requests.
* @param {object} options - Action server options.
* @param {number} options.resultTimeout - How long in seconds a result is kept by the server after a goal reaches a terminal state in seconds, default: 900.
* @param {boolean} options.enableTypedArray - The topic will use TypedArray if necessary, default: true.
* @param {object} options.qos - ROS Middleware "quality of service" options.
* @param {QoS} options.qos.goalServiceQosProfile - Quality of service option for the goal service, default: QoS.profileServicesDefault.
* @param {QoS} options.qos.resultServiceQosProfile - Quality of service option for the result service, default: QoS.profileServicesDefault.
* @param {QoS} options.qos.cancelServiceQosProfile - Quality of service option for the cancel service, default: QoS.profileServicesDefault..
* @param {QoS} options.qos.feedbackSubQosProfile - Quality of service option for the feedback subscription,
* default: new QoS(QoS.HistoryPolicy.RMW_QOS_POLICY_HISTORY_SYSTEM_DEFAULT, 10).
* @param {QoS} options.qos.statusSubQosProfile - Quality of service option for the status subscription, default: QoS.profileActionStatusDefault.
*/
constructor(
node,
typeClass,
actionName,
executeCallback,
goalCallback = defaultGoalCallback,
handleAcceptedCallback = defaultHandleAcceptedCallback,
cancelCallback = defaultCancelCallback,
options
) {
super(null, null, options);
this._node = node;
this._typeClass = loader.loadInterface(typeClass);
this._actionName = actionName;
this._goalHandles = new Map();
// Setup options defaults
this._options = this._options || {};
this._options.resultTimeout =
this._options.resultTimeout != null ? this._options.resultTimeout : 900;
this._options.enableTypedArray = this._options.enableTypedArray !== false;
this._options.qos = this._options.qos || {};
this._options.qos.goalServiceQosProfile =
this._options.qos.goalServiceQosProfile || QoS.profileServicesDefault;
this._options.qos.resultServiceQosProfile =
this._options.qos.resultServiceQosProfile || QoS.profileServicesDefault;
this._options.qos.cancelServiceQosProfile =
this._options.qos.cancelServiceQosProfile || QoS.profileServicesDefault;
this._options.qos.feedbackSubQosProfile =
this._options.qos.feedbackSubQosProfile ||
new QoS(QoS.HistoryPolicy.RMW_QOS_POLICY_HISTORY_SYSTEM_DEFAULT, 10);
this._options.qos.statusSubQosProfile =
this._options.qos.statusSubQosProfile || QoS.profileActionStatusDefault;
this.registerExecuteCallback(executeCallback);
this.registerGoalCallback(goalCallback);
this.registerHandleAcceptedCallback(handleAcceptedCallback);
this.registerCancelCallback(cancelCallback);
let type = this.typeClass.type();
this._handle = rclnodejs.actionCreateServer(
node.handle,
node.getClock().handle,
actionName,
type.interfaceName,
type.pkgName,
this.qos.goalServiceQosProfile,
this.qos.resultServiceQosProfile,
this.qos.cancelServiceQosProfile,
this.qos.feedbackSubQosProfile,
this.qos.statusSubQosProfile,
this.options.resultTimeout
);
node._addActionServer(this);
}
processGoalRequest(header, request) {
this._executeGoalRequest(header, request);
}
processCancelRequest(header, request) {
this._executeCancelRequest(header, request);
}
processResultRequest(header, request) {
this._executeGetResultRequest(header, request);
}
processGoalExpired(result, count) {
this._executeExpiredGoals(result, count);
}
/**
* Register a callback for handling newly accepted goals.
*
* The provided function is called whenever a new goal has been accepted by this action server.
* The function should expect an instance of {@link ServerGoalHandle} as an argument,
* which represents a handle to the goal that was accepted.
* The goal handle can be used to interact with the goal, e.g. publish feedback,
* update the status, or execute a deferred goal.
*
* There can only be one handle accepted callback per {@link ActionServer},
* therefore calling this function will replace any previously registered callback.
*
* @param {function} handleAcceptedCallback - Callback function, if not provided,
* then unregisters any previously registered callback.
* @returns {undefined}
*/
registerHandleAcceptedCallback(handleAcceptedCallback) {
this._handleAcceptedCallback =
handleAcceptedCallback || defaultHandleAcceptedCallback;
}
/**
* Register a callback for handling new goal requests.
*
* The purpose of the goal callback is to decide if a new goal should be accepted or rejected.
* The callback should take the goal request message as a parameter and must return a {@link GoalResponse} value.
*
* @param {function} goalCallback - Callback function, if not provided,
* then unregisters any previously registered callback.
* @returns {undefined}
*/
registerGoalCallback(goalCallback) {
this._goalCallback = goalCallback || defaultGoalCallback;
}
/**
* Register a callback for handling cancel requests.
*
* The purpose of the cancel callback is to decide if a request to cancel an on-going
* (or queued) goal should be accepted or rejected.
* The callback should take one parameter containing the cancel request (a goal handle) and must return a
* {@link CancelResponse} value.
*
* There can only be one cancel callback per {@link ActionServer}, therefore calling this
* function will replace any previously registered callback.
* @param {function} cancelCallback - Callback function, if not provided,
* then unregisters any previously registered callback.
* @returns {undefined}
*/
registerCancelCallback(cancelCallback) {
this._cancelCallback = cancelCallback || defaultCancelCallback;
}
/**
* Register a callback for executing action goals.
*
* The purpose of the execute callback is to execute the action goal and return a result when finished.
* The callback should take one parameter containing goal request and must return a
* result instance (i.e. `action_type.Result`).
*
* There can only be one execute callback per {@link ActionServer}, therefore calling this
* function will replace any previously registered callback.
*
* @param {function} executeCallback - Callback function.
* @returns {undefined}
*/
registerExecuteCallback(executeCallback) {
if (typeof executeCallback !== 'function') {
throw new TypeError('Invalid argument');
}
this._callback = executeCallback;
}
notifyExecute(goalHandle, callback) {
if (!callback && !this._callback) {
return;
}
this._executeGoal(callback || this._callback, goalHandle);
}
_sendResultResponse(header, result) {
rclnodejs.actionSendResultResponse(this.handle, header, result.serialize());
}
_executeGoalRequest(header, request) {
let goalUuid = request.goal_id;
let goalInfo = new ActionInterfaces.GoalInfo();
goalInfo['goal_id'] = goalUuid;
// Initialize the stamp (otherwise we will get an error when we serialize the message)
goalInfo.stamp = {
sec: 0,
nanosec: 0,
};
this._node.getLogger().debug(`New goal request with ID: ${goalUuid.uuid}`);
let goalIdExists = rclnodejs.actionServerGoalExists(
this._handle,
goalInfo.serialize()
);
let accepted = false;
if (!goalIdExists) {
let result = this._goalCallback(
request.goal.toPlainObject(this.typedArrayEnabled)
);
accepted = result === GoalResponse.ACCEPT;
}
let goalHandle;
if (accepted) {
// Stamp time of acceptance
const secondsAndNanos = this._node.getClock().now().secondsAndNanoseconds;
goalInfo.stamp = {
sec: secondsAndNanos.seconds,
nanosec: secondsAndNanos.nanoseconds,
};
try {
goalHandle = new ServerGoalHandle(this, goalInfo, request.goal);
} catch (error) {
this._node
.getLogger()
.error(
`Failed to accept new goal with ID ${goalUuid.uuid}: ${error}`
);
accepted = false;
}
if (accepted) {
let uuid = ActionUuid.fromBytes(goalUuid.uuid).toString();
this._goalHandles.set(uuid, goalHandle);
}
}
// Send response
let response = new this.typeClass.impl.SendGoalService.Response();
response.accepted = accepted;
response.stamp = goalInfo.stamp;
rclnodejs.actionSendGoalResponse(
this._handle,
header,
response.serialize()
);
if (!accepted) {
this._node.getLogger().debug(`New goal rejected: ${goalUuid.uuid}`);
return;
}
this._node.getLogger().debug(`New goal accepted: ${goalUuid.uuid}`);
this._handleAcceptedCallback(goalHandle);
}
async _executeCancelRequest(header, request) {
let Response = this.typeClass.impl.CancelGoal.Response;
let response = new Response();
this._node.getLogger().debug(`Cancel request received: ${request}`);
// Get list of goals that are requested to be canceled
const msgHandle = rclnodejs.actionProcessCancelRequest(
this.handle,
request.serialize(),
response.toRawROS()
);
let cancelResponse = new Response();
cancelResponse.deserialize(response.refObject);
let goalsCanceling = [];
for (let goalInfo of cancelResponse.goals_canceling.data) {
let uuid = ActionUuid.fromBytes(goalInfo.goal_id.uuid).toString();
// Possibly the user doesn't care to track the goal handle
if (this._goalHandles.has(uuid)) {
let goalHandle = this._goalHandles.get(uuid);
let result = await this._cancelCallback(goalHandle);
if (result === CancelResponse.ACCEPT) {
// Notify goal handle
goalHandle._updateState(GoalEvent.CANCEL_GOAL);
goalsCanceling.push(goalInfo);
}
}
}
cancelResponse['goals_canceling'] = goalsCanceling;
rclnodejs.actionSendCancelResponse(
this.handle,
header,
cancelResponse.serialize()
);
msgHandle.release();
}
async _executeGoal(callback, goalHandle) {
const goalUuid = goalHandle.goalId.uuid;
this._node.getLogger().debug(`Executing goal with ID ${goalUuid}`);
let result;
try {
result = await callback(goalHandle);
} catch (error) {
// Create an empty result so that we can still send a response to the client
result = new this.typeClass.Result();
this._node
.getLogger()
.error(`Error raised in execute callback: ${error}`);
}
// If user did not trigger a terminal state, assume aborted
if (goalHandle.isActive) {
this._node
.getLogger()
.warn(`Goal state not set, assuming aborted. Goal ID: ${goalUuid}`);
goalHandle.abort();
}
this._node
.getLogger()
.debug(
`Goal with ID ${goalUuid} finished with state ${goalHandle.status}`
);
// Set result
let response = new this.typeClass.impl.GetResultService.Response();
response.status = goalHandle.status;
response.result = result;
goalHandle._deferred.setResult(response);
}
_executeGetResultRequest(header, request) {
let uuid = ActionUuid.fromBytes(request.goal_id.uuid).toString();
this._node
.getLogger()
.debug(
`Result request received for goal with ID: ${request.goal_id.uuid}`
);
// If no goal with the requested ID exists, then return UNKNOWN status
if (!this._goalHandles.has(uuid)) {
this._node
.getLogger()
.debug(
`Sending result response for unknown goal ID: ${request.goal_id.uuid}`
);
let response = new this.typeClass.impl.GetResultService.Response();
response.status = ActionInterfaces.GoalStatus.STATUS_UNKNOWN;
rclnodejs.actionSendResultResponse(
this.handle,
header,
response.serialize()
);
return;
}
this._goalHandles
.get(uuid)
._deferred.setDoneCallback((result) =>
this._sendResultResponse(header, result)
);
}
_executeExpiredGoals(result, count) {
for (let i = 0; i < count; i++) {
const goal = result.data[i];
const goalInfo = new ActionInterfaces.GoalInfo();
goalInfo.deserialize(goal.refObject);
let uuid = ActionUuid.fromBytes(goalInfo.goal_id.uuid).toString();
this._goalHandles.delete(uuid);
}
}
/**
* Destroy the action server and all goals.
* @return {undefined}
*/
destroy() {
if (this._destroyed) {
return;
}
for (let goalHandle of Array.from(this._goalHandles.values())) {
goalHandle.destroy();
}
this._goalHandles.clear();
this._node._destroyEntity(this, this._node._actionServers);
this._destroyed = true;
}
}
module.exports = ActionServer;