/* Copyright (c) 2010 - 2017, Nordic Semiconductor ASA
*
* All rights reserved.
*
* Use in source and binary forms, redistribution in binary form only, with
* or without modification, are permitted provided that the following conditions
* are met:
*
* 1. Redistributions in binary form, except as embedded into a Nordic
* Semiconductor ASA integrated circuit in a product or a software update for
* such product, must reproduce the above copyright notice, this list of
* conditions and the following disclaimer in the documentation and/or other
* materials provided with the distribution.
*
* 2. Neither the name of Nordic Semiconductor ASA nor the names of its
* contributors may be used to endorse or promote products derived from this
* software without specific prior written permission.
*
* 3. This software, with or without modification, must only be used with a Nordic
* Semiconductor ASA integrated circuit.
*
* 4. Any software provided in binary form under this license must not be reverse
* engineered, decompiled, modified and/or disassembled.
*
* THIS SOFTWARE IS PROVIDED BY NORDIC SEMICONDUCTOR ASA "AS IS" AND ANY EXPRESS OR
* IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY, NONINFRINGEMENT, AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL NORDIC SEMICONDUCTOR ASA OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR
* TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
* THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
'use strict';
const _ = require('underscore');
const EventEmitter = require('events');
const fs = require('fs');
const JSZip = require('jszip');
const BleTransport = require('./dfu/bleTransport');
const createError = require('./dfu/dfuConstants').createError;
const ErrorCode = require('./dfu/dfuConstants').ErrorCode;
const DfuSpeedometer = require('./dfu/dfuSpeedometer');
const logLevel = require('./util/logLevel');
/** @constant Enumeration of the Dfu controllers's possible states. */
const DfuState = Object.freeze({
READY: 0,
IN_PROGRESS: 1,
ABORTING: 2,
});
/**
* Class that provides Dfu controller functionality.
*
* @fires Adapter#stateChanged
* @fires Dfu#transferStart
* @fires Dfu#transferComplete
* @fires Dfu#progressUpdate
* @fires Adapter#logMessage
*/
class Dfu extends EventEmitter {
/**
* Initializes the Dfu controller.
*
* @constructor
* @param {string} transportType TODO: is this used anywhere?
* @param {Object} transportParameters Configuration parameters.
* Available transport parameters:
* <ul>
* <li>{Object} adapter: An instance of adapter.
* <li>{string} targetAddress: The target address to connect to.
* <li>{string} targetAddressType: The target address type.
* <li>{number} [prnValue]: Packet receipt notification number.
* <li>{number} [mtuSize]: Maximum transmission unit number.
* </ul>
*/
constructor(transportType, transportParameters) {
super();
if (!transportType) {
throw new Error('No transport type provided.');
}
if (!transportParameters) {
throw new Error('No transport parameters provided.');
}
this._transportType = transportType;
this._transportParameters = transportParameters;
this._transport = null;
this._speedometer = null;
this._setState(DfuState.READY);
}
/**
* Perform DFU with the given zip file. Successful when callback is invoked with no arguments.
*
* @param {string} zipFilePath Path to zip file containing data for Dfu.
* @param {function} callback Signature: (err, abort) => {}.
* @returns {void}
*/
performDFU(zipFilePath, callback) {
if (this._state !== DfuState.READY) {
throw new Error('Not in READY state. DFU in progress or aborting.');
}
if (!zipFilePath) {
throw new Error('No zipFilePath provided.');
}
if (!callback) {
throw new Error('No callback function provided.');
}
this._log(logLevel.INFO, `Performing DFU with file: ${zipFilePath}`);
this._setState(DfuState.IN_PROGRESS);
this._fetchUpdates(zipFilePath)
.then(updates => this._performUpdates(updates))
.then(() => {
this._log(logLevel.INFO, 'DFU completed successfully.');
this._setState(DfuState.READY);
callback();
})
.catch(err => {
if (err.code === ErrorCode.ABORTED) {
this._log(logLevel.INFO, 'DFU aborted.');
callback(null, true);
} else {
this._log(logLevel.ERROR, `DFU failed with error: ${err.message}.`);
callback(err);
}
this._setState(DfuState.READY);
});
}
/**
* Abort the Dfu procedure.
*
* @returns {void}
*/
abort() {
this._log(logLevel.INFO, 'Aborting DFU.');
this._setState(DfuState.ABORTING);
if (this._transport) {
this._transport.abort();
}
}
_setState(state) {
if (this._state !== state) {
this._state = state;
this.emit('stateChanged', state);
}
}
_performUpdates(updates) {
return updates.reduce((prevPromise, update) => {
return prevPromise.then(() => this._performSingleUpdate(update.datFile, update.binFile));
}, Promise.resolve());
}
_performSingleUpdate(datFile, binFile) {
return this._createBleTransport()
.then(() => this._checkAbortState())
.then(() => this._transferInitPacket(datFile))
.then(() => this._transferFirmware(binFile))
.then(() => this._transport.waitForDisconnection())
.then(() => this._destroyBleTransport())
.catch(err => {
this._destroyBleTransport();
throw err;
});
}
_checkAbortState() {
if (this._state === DfuState.ABORTING) {
return Promise.reject(createError(ErrorCode.ABORTED, 'Abort was triggered.'));
}
return Promise.resolve();
}
_createBleTransport() {
return Promise.resolve()
.then(() => {
this._log(logLevel.DEBUG, 'Creating DFU transport.');
this._transport = new BleTransport(this._transportParameters);
this._setupTransportListeners();
return this._transport.init();
});
}
_destroyBleTransport() {
if (this._transport) {
this._log(logLevel.DEBUG, 'Destroying DFU transport.');
this._removeTransportListeners();
this._transport.destroy();
this._transport = null;
} else {
this._log(logLevel.DEBUG, 'No DFU transport exists, so nothing to clean up.');
}
}
_setupTransportListeners() {
const progressInterval = 1000;
const onProgressUpdate = _.throttle(progressUpdate => {
this._handleProgressUpdate(progressUpdate);
}, progressInterval);
const onLogMessage = (level, message) => {
this._log(level, message);
};
this._transport.on('progressUpdate', onProgressUpdate);
this._transport.on('logMessage', onLogMessage);
}
_removeTransportListeners() {
this._transport.removeAllListeners('progressUpdate');
this._transport.removeAllListeners('logMessage');
}
_transferInitPacket(file) {
/**
* DFU transfer start event.
*
* @event Dfu#transferStart
* @type {Object}
* @property {string} file.name - The name of the file being transferred.
*/
this.emit('transferStart', file.name);
return file.loadData().then(data => {
return this._transport.sendInitPacket(data)
.then(() => this.emit('transferComplete', file.name));
});
}
_transferFirmware(file) {
this.emit('transferStart', file.name);
return file.loadData().then(data => {
return this._transport.getFirmwareState(data)
.then(state => {
this._speedometer = new DfuSpeedometer(data.length, state.offset);
return this._transport.sendFirmware(data);
})
/**
* DFU transfer complete event.
*
* @event Dfu#transferComplete
* @type {Object}
* @property {string} file.name - The name of the file that was transferred.
*/
.then(() => this.emit('transferComplete', file.name));
});
}
_handleProgressUpdate(progressUpdate) {
if (progressUpdate.offset) {
this._speedometer.updateState(progressUpdate.offset);
/**
* DFU progress update event.
*
* @event Dfu#progressUpdate
* @type {Object}
* @property {Object} _ - Progress meta-data.
*/
this.emit('progressUpdate', {
stage: progressUpdate.stage,
completedBytes: progressUpdate.offset,
totalBytes: this._speedometer.totalBytes,
bytesPerSecond: this._speedometer.calculateBytesPerSecond(),
averageBytesPerSecond: this._speedometer.calculateAverageBytesPerSecond(),
percentCompleted: this._speedometer.calculatePercentCompleted(),
});
} else {
this.emit('progressUpdate', {
stage: progressUpdate.stage,
});
}
}
/**
* Get promise for manifest.json from the given zip file.
* This function is a wrapper for getManifest().
*
* @param {string} zipFilePath Path of the zip file.
* @returns {Promise} For manifest.json
* @private
*/
_getManifestAsync(zipFilePath) {
return new Promise((resolve, reject) => {
this.getManifest(zipFilePath, (err, manifest) => {
err ? reject(err) : resolve(manifest);
});
});
}
/**
* Get promise for JSZip zip object of the given zip file.
* This function is a wrapper for _loadZip().
*
* @param {string} zipFilePath path of the zip file
* @returns {Promise} for JSZip zip object
* @private
*/
_loadZipAsync(zipFilePath) {
return new Promise((resolve, reject) => {
this._loadZip(zipFilePath, (err, zip) => {
err ? reject(err) : resolve(zip);
});
});
}
/**
* Fetch datFile and binFile for all updates included in the zip.
* Returns a sorted array of updates, on the format:
* [{
* datFile: {
* name: filename.dat,
* loadData: <function returning promise with data>
* },
* binFile: {
* name: filename.bin,
* loadData: <function returning promise with data>
* }
* }, ... ]
*
* The sorting is such that the application update is put last.
*
* @param {string} zipFilePath path of the zip file containing the updates
* @returns {Promise} resolves to an array of updates
* @returns {void}
* @private
*/
_fetchUpdates(zipFilePath) {
this._log(logLevel.DEBUG, `Loading zip file: ${zipFilePath}`);
return Promise.all([
this._loadZipAsync(zipFilePath),
this._getManifestAsync(zipFilePath),
]).then(result => {
const zip = result[0];
const manifest = result[1];
return this._getFirmwareTypes(manifest).map(type => {
const firmwareUpdate = manifest[type];
const datFileName = firmwareUpdate.dat_file;
const binFileName = firmwareUpdate.bin_file;
this._log(logLevel.DEBUG, `Found ${type} files: ${datFileName}, ${binFileName}`);
return {
datFile: {
name: datFileName,
loadData: () => zip.file(datFileName).async('array'),
},
binFile: {
name: binFileName,
loadData: () => zip.file(binFileName).async('array'),
},
};
});
});
}
_getFirmwareTypes(manifest) {
return [
'softdevice',
'bootloader',
'softdevice_bootloader',
'application',
].filter(type => !!manifest[type]);
}
/**
* Get JSZip zip object of the given zip file.
*
* @param {string} zipFilePath Path of the zip file.
* @param {function} callback Signature: (err, zip) => {}.
* @returns {void}
* @private
*/
_loadZip(zipFilePath, callback) {
fs.readFile(zipFilePath, (err, data) => {
if (err) {
return callback(err);
}
// Get and return zip object
JSZip.loadAsync(data)
.then(zip => {
callback(undefined, zip);
})
.catch(error => {
callback(error);
});
});
}
/**
* Get and return manifest object from the given zip file.
*
* The manifest object has one or more of the following properties:
* {
* application: {},
* bootloader: {},
* softdevice: {},
* softdevice_bootloader: {},
* }
*
* Each of the above properties have the following:
* {
* bin_file: <string>, // Firmware filename
* dat_file: <string>, // Init packet filename
* }
*
* The softdevice_bootloader property also has:
* info_read_only_metadata: {
* bl_size: <integer>, // Bootloader size
* sd_size: <integer>, // Softdevice size
* }
*
* @param {string} zipFilePath Path to the zip file.
* @param {function} callback Signature: (err, manifest) => {}.
* @returns {void}
*/
getManifest(zipFilePath, callback) {
if (!zipFilePath) {
throw new Error('No zipFilePath provided.');
}
// Fetch zip object
this._loadZip(zipFilePath, (err, zip) => {
if (err) {
return callback(err);
}
// Read out manifest from zip
zip.file('manifest.json')
.async('string')
.then(data => {
let manifest;
try {
// Parse manifest as JSON
manifest = JSON.parse(data).manifest;
} catch (error) {
return callback(error);
}
// Return manifest
return callback(undefined, manifest);
});
});
}
_log(level, message) {
this.emit('logMessage', level, message);
}
}
module.exports = Dfu;