const EventEmitter = require('events');
const Logger = require('winston');
const Enum = require('./util/Enum');
const CommandParser = require('./CommandParser');
const { characteristicSendUuids, characteristicReceiveUuids } = require('./CharacteristicEnums');
const MANUFACTURER_SERIALS = [
'4300cf1900090100',
'4300cf1909090100',
'4300cf1907090100',
];
const DRONE_PREFIXES = [
'RS_',
'Mars_',
'Travis_',
'Maclan_',
'Mambo_',
'Blaze_',
'NewZ_',
];
// http://forum.developer.parrot.com/t/minidrone-characteristics-uuid/4686/3
const handshakeUuids = [
'fb0f', 'fb0e', 'fb1b', 'fb1c',
'fd22', 'fd23', 'fd24', 'fd52',
'fd53', 'fd54',
];
// the following UUID segments come from the Mambo and from the documenation at
// http://forum.developer.parrot.com/t/minidrone-characteristics-uuid/4686/3
// the 3rd and 4th bytes are used to identify the service
const serviceUuids = new Enum({
'fa': 'ARCOMMAND_SENDING_SERVICE',
'fb': 'ARCOMMAND_RECEIVING_SERVICE',
'fc': 'PERFORMANCE_COUNTER_SERVICE',
'fd21': 'NORMAL_BLE_FTP_SERVICE',
'fd51': 'UPDATE_BLE_FTP',
'fe00': 'UPDATE_RFCOMM_SERVICE',
'1800': 'Device Info',
'1801': 'unknown',
});
/**
* Drone connection class
*
* Exposes an api for controlling the drone
*
* @fires DroneCommand#connected
* @fires DroneCommand#disconnected
* @fires DroneCommand#sensor:
* @property {CommandParser} parser - {@link CommandParser} instance
*/
class DroneConnection extends EventEmitter {
/**
* Creates a new DroneConnection instance
* @param {string} [droneFilter=] - The drone name leave blank for no filter
* @param {boolean} [warmup=true] - Warmup the command parser
*/
constructor(droneFilter = '', warmup = true) {
super();
this.characteristics = [];
this._characteristicLookupCache = {};
this._commandCallback = {};
this._sensorStore = {};
this._stepStore = {};
this.droneFilter = droneFilter;
this.noble = require('noble');
this.parser = new CommandParser();
if (warmup) {
// We'll do it for you so you don't have to
this.parser.warmup();
}
// bind noble event handlers
this.noble.on('stateChange', state => this._onNobleStateChange(state));
this.noble.on('discover', peripheral => this._onPeripheralDiscovery(peripheral));
}
/**
* Event handler for when noble broadcasts a state change
* @param {String} state a string describing noble's state
* @return {undefined}
* @private
*/
_onNobleStateChange(state) {
Logger.debug(`Noble state changed to ${state}`);
if (state === 'poweredOn') {
Logger.info('Searching for drones...');
this.noble.startScanning();
}
}
/**
* Event handler for when noble discovers a peripheral
* Validates it is a drone and attempts to connect.
*
* @param {Peripheral} peripheral a noble peripheral class
* @return {undefined}
* @private
*/
_onPeripheralDiscovery(peripheral) {
if (!this._validatePeripheral(peripheral)) {
return;
}
Logger.info(`Peripheral found ${peripheral.advertisement.localName}`);
this.noble.stopScanning();
peripheral.connect((error) => {
if (error) {
throw error;
}
this._peripheral = peripheral;
this._setupPeripheral();
});
}
/**
* Validates a noble Peripheral class is a Parrot MiniDrone
* @param {Peripheral} peripheral a noble peripheral object class
* @return {boolean} If the peripheral is a drone
* @private
*/
_validatePeripheral(peripheral) {
if (!peripheral) {
return false;
}
const localName = peripheral.advertisement.localName;
const manufacturer = peripheral.advertisement.manufacturerData;
const matchesFilter = this.droneFilter ? localName === this.droneFilter : false;
const localNameMatch = matchesFilter || DRONE_PREFIXES.some((prefix) => localName && localName.indexOf(prefix) >= 0);
const manufacturerMatch = manufacturer && MANUFACTURER_SERIALS.indexOf(manufacturer) >= 0;
// Is TRUE according to droneFilter or if empty, for EITHER an "RS_" name OR manufacturer code.
return localNameMatch || manufacturerMatch;
}
/**
* Sets up a peripheral and finds all of it's services and characteristics
* @return {undefined}
*/
_setupPeripheral() {
this.peripheral.discoverAllServicesAndCharacteristics((err, services, characteristics) => {
if (err) {
throw err;
}
this.characteristics = characteristics;
if (Logger.level === 'debug') {
Logger.debug('Found the following characteristics:');
// Get uuids
const characteristicUuids = this.characteristics.map(x => x.uuid.substr(4, 4).toLowerCase());
characteristicUuids.sort();
characteristicUuids.join(', ').replace(/([^\n]{40,}?), /g, '$1|').split('|').map(s => Logger.debug(s));
}
Logger.debug('Preforming handshake');
for (const uuid of handshakeUuids) {
const target = this.getCharacteristic(uuid);
target.subscribe();
}
Logger.debug('Adding listeners (fb uuid prefix)');
for (const uuid of characteristicReceiveUuids.values()) {
const target = this.getCharacteristic('fb' + uuid);
target.subscribe();
target.on('data', data => this._handleIncoming(uuid, data));
}
Logger.info(`Device connected ${this.peripheral.advertisement.localName}`);
// Register some event handlers
/**
* Drone disconnected event
* Fired when the bluetooth connection has been disconnected
*
* @event DroneCommand#disconnected
*/
this.noble.on('disconnect', () => this.emit('disconnected'));
setTimeout(() => {
/**
* Drone connected event
* You can control the drone once this event has been triggered.
*
* @event DroneCommand#connected
*/
this.emit('connected');
}, 200);
});
}
/**
* @returns {Peripheral} a noble peripheral object class
*/
get peripheral() {
return this._peripheral;
}
/**
* @returns {boolean} If the drone is connected
*/
get connected() {
return this.characteristics.length > 0;
}
/**
* Finds a Noble Characteristic class for the given characteristic UUID
* @param {String} uuid The characteristics UUID
* @return {Characteristic} The Noble Characteristic corresponding to that UUID
*/
getCharacteristic(uuid) {
uuid = uuid.toLowerCase();
if (typeof this._characteristicLookupCache[uuid] === 'undefined') {
this._characteristicLookupCache[uuid] = this.characteristics.find(x => x.uuid.substr(4, 4).toLowerCase() === uuid);
}
return this._characteristicLookupCache[uuid];
}
/**
* Send a command to the drone and execute it
* @param {DroneCommand} command - Command instance to be ran
* @returns {Promise} - Resolves when the command has been received (if ack is required)
* @async
*/
runCommand(command) {
const buffer = command.toBuffer();
const packetId = this._getStep(command.bufferType);
buffer.writeUIntLE(packetId, 1, 1);
Logger.debug(`SEND ${command.bufferType}[${packetId}]: `, command.toString());
return new Promise(accept => {
this.getCharacteristic(command.sendCharacteristicUuid).write(buffer, true);
switch (command.bufferType) {
case 'DATA_WITH_ACK':
case 'SEND_WITH_ACK':
if (!this._commandCallback['ACK_COMMAND_SENT']) {
this._commandCallback['ACK_COMMAND_SENT'] = [];
}
this._commandCallback['ACK_COMMAND_SENT'][packetId] = accept;
break;
case 'SEND_HIGH_PRIORITY':
if (!this._commandCallback['ACK_HIGH_PRIORITY']) {
this._commandCallback['ACK_HIGH_PRIORITY'] = [];
}
this._commandCallback['ACK_HIGH_PRIORITY'][packetId] = accept;
break;
default:
accept();
break;
}
});
}
/**
* Handles incoming data from the drone
* @param {string} channelUuid - The channel uuid
* @param {Buffer} buffer - The packet data
* @private
*/
_handleIncoming(channelUuid, buffer) {
const channel = characteristicReceiveUuids.findForValue(channelUuid);
let callback;
switch (channel) {
case 'ACK_DRONE_DATA':
// We need to response with an ack
this._updateSensors(buffer, true);
break;
case 'NO_ACK_DRONE_DATA':
this._updateSensors(buffer);
break;
case 'ACK_COMMAND_SENT':
case 'ACK_HIGH_PRIORITY':
const packetId = buffer.readUInt8(2);
callback = (this._commandCallback[channel] || {})[packetId];
if (callback) {
delete this._commandCallback[channel][packetId];
}
if (typeof callback === 'function') {
Logger.debug(`${channel}: packet id ${packetId}`);
callback();
} else {
Logger.debug(`${channel}: packet id ${packetId}, no callback :(`);
}
break;
default:
Logger.warn(`Got data on an unknown channel ${channel}(${channelUuid}) (wtf!?)`);
break;
}
}
/**
* Update the sensor
*
* @param {Buffer} buffer - Command buffer
* @param {boolean} ack - If an acknowledgement for receiving the data should be sent
* @private
* @fires DroneConnection#sensor:
*/
_updateSensors(buffer, ack = false) {
if (buffer[2] === 0) {
return;
}
try {
const command = this.parser.parseBuffer(buffer.slice(2));
const token = [command.projectName, command.className, command.commandName].join('-');
this._sensorStore[token] = command;
Logger.debug('RECV:', command.toString());
/**
* Fires when a new sensor reading has been received
*
* @event DroneConnection#sensor:
* @type {DroneCommand} - The sensor reading
* @example
* connection.on('sensor:minidrone-UsbAccessoryState-GunState', function(sensor) {
* if (sensor.state.value === sensor.state.enum.READY) {
* console.log('The gun is ready to fire!');
* }
* });
*/
this.emit('sensor:' + token, command);
this.emit('sensor:*', command);
} catch (e) {
Logger.warn('Unable to parse packet:', buffer);
Logger.warn(e);
}
if (ack) {
const packetId = buffer.readUInt8(1);
this.ack(packetId);
}
}
/**
* Get the most recent sensor reading
*
* @param {string} project - Project name
* @param {string} class_ - Class name
* @param {string} command - Command name
* @returns {DroneCommand|undefined} - {@link DroneCommand} instance or {@link undefined} if no sensor reading could be found
* @see {@link https://github.com/Parrot-Developers/arsdk-xml/blob/master/xml/}
*/
getSensor(project, class_, command) {
const token = [project, class_, command].join('-');
return this.getSensorFromToken(token);
}
/**
* Get the most recent sensor reading using the sensor token
*
* @param {string} token - Command token
* @returns {DroneCommand|undefined} - {@link DroneCommand} instance or {@link undefined} if no sensor reading could be found
* @see {@link https://github.com/Parrot-Developers/arsdk-xml/blob/master/xml/}
* @see {@link DroneCommand.getToken}
*/
getSensorFromToken(token) {
let command = this._sensorStore[token];
if (command) {
command = command.copy();
}
return command;
}
/**
* Get the logger level
* @returns {string|number} - logger level
* @see {@link https://github.com/winstonjs/winston}
*/
get logLevel() {
return Logger.level;
}
/**
* Set the logger level
* @param {string|number} value - logger level
* @see {@link https://github.com/winstonjs/winston}
*/
set logLevel(value) {
Logger.level = typeof value === 'number' ? value : value.toString();
}
/**
* used to count the drone command steps
* @param {string} id - Step store id
* @returns {number} - step number
*/
_getStep(id) {
if (typeof this._stepStore[id] === 'undefined') {
this._stepStore[id] = 0;
}
const out = this._stepStore[id];
this._stepStore[id]++;
this._stepStore[id] &= 0xFF;
return out;
}
/**
* Acknowledge a packet
* @param {number} packetId - Id of the packet to ack
*/
ack(packetId) {
Logger.debug('ACK: packet id ' + packetId);
const characteristic = characteristicSendUuids.ACK_COMMAND;
const buffer = new Buffer(3);
buffer.writeUIntLE(characteristic, 0, 1);
buffer.writeUIntLE(this._getStep(characteristic), 1, 1);
buffer.writeUIntLE(packetId, 2, 1);
this.getCharacteristic('fa' + characteristic).write(buffer, true);
}
}
module.exports = DroneConnection;