const { parseString } = require('xml2js');
const DroneCommand = require('./DroneCommand');
const Logger = require('winston');
const InvalidCommandError = require('./InvalidCommandError');
const fs = require('fs');
const path = require('path');
const resolve = require('resolve');
/**
* Command parser used for looking up commands in the xml definition
*/
class CommandParser {
/**
* CommandParser constructor
*/
constructor() {
if (typeof CommandParser._fileCache === 'undefined') {
CommandParser._fileCache = {};
}
this._commandCache = {};
}
/**
* Get an xml file and convert it to json
* @param {string} name - Project name
* @returns {Object} - Parsed Xml data using xml2js
* @private
*/
_getJson(name) {
const file = this._getXml(name);
if (typeof file === 'undefined') {
throw new Error(`Xml file ${name} could not be found`);
}
if (typeof CommandParser._fileCache[name] === 'undefined') {
CommandParser._fileCache[name] = null;
parseString(file, { async: false }, (e, result) => {
CommandParser._fileCache[name] = result;
});
return this._getJson(name);
} else if (CommandParser._fileCache[name] === null) {
// Fuck javascript async hipster shit
return this._getJson(name);
}
return CommandParser._fileCache[name];
}
/**
* Get a command based on it's path in the xml definition
* @param {string} projectName - The xml file name (project name)
* @param {string} className - The command class name
* @param {string} commandName - The command name
* @param {Object?} commandArguments - Optional command arguments
* @returns {DroneCommand} - Target command
* @throws InvalidCommandError
* @see {@link https://github.com/Parrot-Developers/arsdk-xml/blob/master/xml/}
* @example
* const parser = new CommandParser();
* const backFlip = parser.getCommand('minidrone', 'Animations', 'Flip', {direction: 'back'});
*/
getCommand(projectName, className, commandName, commandArguments = {}) {
const cacheToken = [projectName, className, commandName].join('-');
if (typeof this._commandCache[cacheToken] === 'undefined') {
// Find project
const project = this._getJson(projectName).project;
this._assertElementExists(project, 'project', projectName);
const context = [projectName];
// Find class
const targetClass = project.class.find(v => v.$.name === className);
this._assertElementExists(targetClass, 'class', className);
context.push(className);
// Find command
const targetCommand = targetClass.cmd.find(v => v.$.name === commandName);
this._assertElementExists(targetCommand, 'command', commandName);
const result = new DroneCommand(project, targetClass, targetCommand);
this._commandCache[cacheToken] = result;
if (result.deprecated) {
Logger.warn(`${result.toString()} has been deprecated`);
}
}
const target = this._commandCache[cacheToken].clone();
for (const arg of Object.keys(commandArguments)) {
if (target.hasArgument(arg)) {
target[arg] = commandArguments[arg];
}
}
return target;
}
/**
* Gets the command by analysing the buffer
* @param {Buffer} buffer - Command buffer without leading 2 bytes
* @returns {DroneCommand} - Buffer's related DroneCommand
* @private
*/
_getCommandFromBuffer(buffer) {
// https://github.com/algolia/pdrone/commit/43cc0c4150297dab97d0f0bc119b8bd551da268f#comments
buffer = buffer.readUInt8(0) > 0x80 ? buffer.slice(1) : buffer;
const projectId = buffer.readUInt8(0);
const classId = buffer.readUInt8(1);
const commandId = buffer.readUInt8(2);
const cacheToken = [projectId, classId, commandId].join('-');
// Build command if needed
if (typeof this._commandCache[cacheToken] === 'undefined') {
// Find project
const project = CommandParser._files
.map(x => this._getJson(x).project)
.filter(x => typeof x !== 'undefined')
.find(x => Number(x.$.id) === projectId);
this._assertElementExists(project, 'project', projectId);
// find class
const targetClass = project.class.find(x => Number(x.$.id) === classId);
const context = [project.$.name];
this._assertElementExists(targetClass, 'class', classId, context);
// find command
const targetCommand = targetClass.cmd.find(x => Number(x.$.id) === commandId);
context.push(targetClass.$.name);
this._assertElementExists(targetCommand, 'command', commandId, context);
// Build command and store it
this._commandCache[cacheToken] = new DroneCommand(project, targetClass, targetCommand);
}
return this._commandCache[cacheToken].clone();
}
/**
* Parse the input buffer and get the correct command with parameters
* Used internally to parse sensor data
* @param {Buffer} buffer - The command buffer without the first two bytes
* @returns {DroneCommand} - Parsed drone command
* @throws InvalidCommandError
* @throws TypeError
*/
parseBuffer(buffer) {
const command = this._getCommandFromBuffer(buffer);
let bufferOffset = 4;
for (const arg of command.arguments) {
let valueSize = arg.getValueSize();
let value = 0;
switch (arg.type) {
case 'u8':
case 'u16':
case 'u32':
case 'u64':
value = buffer.readUIntLE(bufferOffset, valueSize);
break;
case 'i8':
case 'i16':
case 'i32':
case 'i64':
value = buffer.readIntLE(bufferOffset, valueSize);
break;
case 'enum':
// @todo figure out why I have to do this
value = buffer.readIntLE(bufferOffset + 1, valueSize - 1);
break;
// eslint-disable-next-line no-case-declarations
case 'string':
value = '';
let c = ''; // Last character
for (valueSize = 0; valueSize < buffer.length && c !== '\0'; valueSize++) {
c = String.fromCharCode(buffer[bufferOffset]);
value += c;
}
break;
case 'float':
value = buffer.readFloatLE(bufferOffset);
break;
case 'double':
value = buffer.readDoubleLE(bufferOffset);
break;
default:
throw new TypeError(`Can't parse buffer: unknown data type "${arg.type}" for argument "${arg.name}" in ${command.getToken()}`);
}
arg.value = value;
bufferOffset += valueSize;
}
return command;
}
/**
* Warn up the parser by pre-fetching the xml files
* @param {string[]} files - List of files to load in defaults to {@link CommandParser._files}
* @returns {void}
*
*/
warmup(files = this.constructor._files) {
for (const file of files) {
this._getJson(file);
}
}
/**
* Mapping of known xml files
* @type {string[]} - known xml files
* @private
*/
static get _files() {
if (typeof this.__files === 'undefined') {
const arsdkXmlPath = CommandParser._arsdkXmlPath;
const isFile = filePath => fs.lstatSync(filePath).isFile();
this.__files = fs
.readdirSync(arsdkXmlPath)
.map(String)
.filter(file => file.endsWith('.xml'))
.filter(file => isFile(path.join(arsdkXmlPath, file)))
.map(file => file.replace('.xml', ''));
Logger.debug(`_files list found ${this._files.length} items`);
}
return this.__files;
}
/**
* helper method
* @param {Object|undefined} value - Xml node value
* @param {string} type - Xml node type
* @param {string|number} target - Xml node value
* @param {Array<Object|undefined>} context - Parser context
* @private
* @throws InvalidCommandError
* @returns {void}
*/
_assertElementExists(value, type, target, context = []) {
if (typeof value === 'undefined') {
throw new InvalidCommandError(value, type, target, context);
}
}
/**
* Reads xml file from ArSDK synchronously without a cache
* @param {string} name - Xml file name
* @returns {string} - File contents
* @private
*/
_getXml(name) {
const arsdkXmlPath = CommandParser._arsdkXmlPath;
const filePath = `${arsdkXmlPath}/${name}.xml`;
return fs.readFileSync(filePath);
}
/**
* Path of the ArSDK xml directory
* @returns {string} - Path
* @private
*/
static get _arsdkXmlPath() {
if (typeof this.__arsdkPath === 'undefined') {
// common.xml is a file we know exists so we can use it to find the xml directory
this.__arsdkPath = path.dirname(resolve.sync('arsdk-xml/xml/common.xml'));
}
return this.__arsdkPath;
}
}
module.exports = CommandParser;