UNPKG

particle-cli

Version:

Simple Node commandline application for working with your Particle devices and using the Particle Cloud

579 lines (545 loc) 16.2 kB
const { spin } = require('../app/ui'); const { getDevice, isDeviceId } = require('./device-util'); const { systemSupportsUdev, promptAndInstallUdevRules } = require('./udev'); const { delay, asyncMapSeries } = require('../lib/utilities'); const { platformForId, PLATFORMS } = require('../lib/platform'); const { getDevices, openDeviceById, NotFoundError, NotAllowedError, TimeoutError, DeviceProtectionError } = require('particle-usb'); const deviceProtectionHelper = require('../lib/device-protection-helper'); const { validateDFUSupport } = require('./device-util'); // Timeout when reopening a USB device after an update via control requests. This timeout should be // long enough to allow the bootloader apply the update const REOPEN_TIMEOUT = 60000; // When reopening a device that was about to reset, give it some time to boot into the firmware const REOPEN_DELAY = 500; async function _getDeviceInfo(device) { let id = null; let mode = null; try { await device.open(); id = device._id; if (device.isInDfuMode) { mode = 'DFU'; return { id, mode }; } mode = await device.getDeviceMode({ timeout: 10 * 1000 }); // not required to show NORMAL mode to the user if (mode === 'NORMAL') { mode = ''; } return { id, mode }; } catch (err) { if (err instanceof TimeoutError) { return { id, mode: 'UNKNOWN' }; } else if (err instanceof DeviceProtectionError) { return { id, mode: 'PROTECTED' }; } else { throw new Error(`Unable to get device mode: ${err.message}`); } } finally { if (device.isOpen) { await device.close(); } } } async function _getDeviceName({ id, api, auth, ui }) { try { const device = await getDevice({ id, api, auth, ui }); return device && device.name ? device.name : '<no name>'; } catch (err) { return '<unknown>'; } } /** * USB permissions error. */ class UsbPermissionsError extends Error { /** * Construct an error instance. * * @param {String} message Error message. */ constructor(message) { super(message); this.name = this.constructor.name; } } /** * Executes a function with a USB device, handling device protection and DFU mode. * * @param {Object} options - The options for executing with the USB device. * @param {Object} options.args - The arguments to identify and configure the USB device. * @param {Function} options.func - The function to execute with the USB device. * @param {boolean} [options.dfuMode=false] - Flag indicating whether to include devices in DFU mode. * @returns {Promise<*>} The result of the executed function. * @throws {Error} If the device is protected and cannot be unprotected, or if the executed function throws an error. * * @example * await executeWithUsbDevice({ * args: { idOrName: 'e00fce6819ef5f971ea9563a' }, * func: async (device) => { * // Perform operations with the device * return result; * }, * dfuMode: true * }); */ async function executeWithUsbDevice({ args, func, enterDfuMode = false, allowProtectedDevices = true } = {}) { let device = await getOneUsbDevice(args); const deviceId = device.id; let deviceIsProtected = false; // Protected and Protected Devices in Service Mode let disableProtection = false; // Only Protected Devices (not in Service Mode) const platform = platformForId(device.platformId); if (platform.generation > 2) { // Skipping device protection check for Gen2 platforms try { const s = await deviceProtectionHelper.getProtectionStatus(device); deviceIsProtected = s.overridden || s.protected; disableProtection = s.protected && !s.overridden; if (deviceIsProtected && !allowProtectedDevices) { throw new Error('This command is not allowed on Protected Devices.'); } } catch (err) { if (err.message === 'Not supported') { // Device Protection is not supported on certain platforms and versions. // It means that the device is not protected. } else { throw err; } } if (disableProtection) { const deviceWasInDfuMode = device.isInDfuMode; if (deviceWasInDfuMode) { device = await _putDeviceInSafeMode(device); } await deviceProtectionHelper.disableDeviceProtection(device); if (deviceWasInDfuMode) { device = await reopenInDfuMode(device); } } } try { if (enterDfuMode) { validateDFUSupport({ device, ui: args.ui }); device = await reopenInDfuMode(device); } await func(device); } finally { if (deviceIsProtected) { try { device = await waitForDeviceToRespond(deviceId); await deviceProtectionHelper.turnOffServiceMode(device); } catch (error) { // Ignore error. At most, device is left in Service Mode } } if (device && device.isOpen) { await device.close(); } } } /** * Waits for device readiness (mainly to send control requsts to it) * Useful for enabling Device Protection on a device in after its current operation completes. * @param {*} deviceId * @returns */ async function waitForDeviceToRespond(deviceId, { timeout = 10000 } = {}) { const REBOOT_TIME_MSEC = timeout; const REBOOT_INTERVAL_MSEC = 500; const start = Date.now(); let device; while (Date.now() - start < REBOOT_TIME_MSEC) { try { if (device && device.isOpen) { await device.close(); } await delay(REBOOT_INTERVAL_MSEC); device = await reopenDevice({ id: deviceId }); if (device.isInDfuMode) { return device; } // Check device readiness await device.getDeviceId(); return device; } catch (error) { // ignore errors // device could be open after the last iteration if (device && device.isOpen) { await device.close(); } } } return null; } /** * Attempts to enter Safe Mode to enable operations on Protected Devices in DFU mode. * * @async * @param {Object} device - The device to reset. * @returns {Promise<void>} */ async function _putDeviceInSafeMode(dev) { try { await dev.enterSafeMode(); } catch (error) { // ignore errors } return reopenInNormalMode({ id: this.deviceId }); } /** * Open a USB device. * * This function checks whether the user has necessary permissions to access the device. * Use this function instead of particle-usb's Device.open(). * * @param {Object} usbDevice USB device. * @param {Object} options Options. * @param {Boolean} [options.dfuMode] Set to `true` if the device can be in DFU mode. * @return {Promise} */ async function openUsbDevice(usbDevice, { dfuMode = false } = {}){ if (!dfuMode && usbDevice.isInDfuMode){ throw new Error('The device should not be in DFU mode'); } try { return await usbDevice.open(); } catch (err) { await handleUsbError(err); } } /** * Open a USB device with the specified device ID. * * This function checks whether the user has necessary permissions to access the device. * Use this function instead of particle-usb's openDeviceById(). * * @param {String} id Device ID. * @param {Object} [options] Options. * @param {String} [options.displayName] Device name as shown to the user. * @param {Boolean} [options.dfuMode] Set to `true` if the device can be in DFU mode. * @return {Promise} */ async function openUsbDeviceById(id, { displayName, dfuMode = false } = {}) { let dev; try { dev = await openDeviceById(id); } catch (err) { if (err instanceof NotFoundError) { throw new Error(`Unable to connect to the device ${displayName || id}. Make sure the device is connected to the host computer via USB`); } await handleUsbError(err); // Throws the original error or a UsbPermissionsError } if (dev.isInDfuMode && !dfuMode) { await dev.close(); throw new Error('The device should not be in DFU mode'); } return dev; } /** * Open a USB device with the specified device ID or name. * * This function checks whether the user has necessary permissions to access the device. * * @param {String} idOrName Device ID or name. * @param {Object} api API client. * @param {String} auth Access token. * @param {Object} [options] Options. * @param {Boolean} [options.dfuMode] Set to `true` if the device can be in DFU mode. * @return {Promise} */ async function openUsbDeviceByIdOrName(idOrName, api, auth, { dfuMode = false } = {}) { let device; if (isDeviceId(idOrName)) { // Try to open the device straight away try { device = await openDeviceById(idOrName); } catch (err) { // continue if the device is not found if (!(err instanceof NotFoundError)) { await handleUsbError(err); } } } if (!device) { let deviceInfo = await getDevice({ id: idOrName, api, auth }); try { device = await openDeviceById(deviceInfo.id); } catch (err) { // TODO: improve error message when device is not found. Currently it says Device is not found await handleUsbError(err); } } if (!dfuMode && device.isInDfuMode){ await device.close(); throw new Error('The device should not be in DFU mode'); } return device; } /** * Get the list of USB devices attached to the host. * * @param {Object} options Options. * @param {Boolean} [options.dfuMode] Set to `true` to include devices in DFU mode. * @return {Promise} */ async function getUsbDevices({ dfuMode = false } = {}){ try { return await getDevices({ includeDfu: dfuMode }); } catch (err) { await handleUsbError(err); } } async function getOneUsbDevice({ idOrName, api, auth, ui, flashMode, platformId }) { let usbDevice; const normalModes = ['NORMAL', 'LISTENING', '']; const dfuModes = ['DFU']; if (idOrName) { const device = await openUsbDeviceByIdOrName(idOrName, api, auth, { dfuMode: true }); await checkFlashMode({ flashMode, device }); return device; } const usbDevices = await getUsbDevices({ dfuMode: true }); if (!usbDevices.length) { throw new Error('No devices found'); } let devices = await Promise.all(usbDevices.map(async (d) => { const { id, mode } = await _getDeviceInfo(d); const name = await _getDeviceName({ id, api, auth, ui }); return { id, name: `${name} [${id}] (${(platformForId(d._info.id)).displayName}${mode ? ', ' + mode : '' })`, platformId: d._info.id, mode, value: d }; })); devices = devices.sort((d1, d2) => d1.id.localeCompare(d2.id)); if (flashMode === 'DFU') { devices = devices.filter(d => dfuModes.includes(d.mode)); } if (flashMode === 'NORMAL') { devices = devices.filter(d => normalModes.includes(d.mode)); } if (platformId) { devices = devices.filter(d => d.platformId === platformId); } // filter out linux kind devices const linuxPlatforms = PLATFORMS.filter(p => p.features.includes('linux')); devices = devices.filter(d => !linuxPlatforms.includes(platformForId(d.platformId))); if (devices.length > 1) { const question = { type: 'list', name: 'device', message: 'Which device would you like to select?', choices() { return devices; } }; const nonInteractiveError = 'Multiple devices found. Connect only one device when running in non-interactive mode.'; const ans = await ui.prompt([question], { nonInteractiveError }); usbDevice = ans.device; } else if (!devices.length) { if (flashMode === 'DFU') { ui.logDFUModeRequired(); } else if (flashMode === 'NORMAL') { ui.logNormalModeRequired(); } throw new Error('No devices found'); } else { usbDevice = devices[0].value; } try { await usbDevice.open(); return usbDevice; } catch (err) { await handleUsbError(err); } } async function checkFlashMode({ flashMode, device, ui }){ switch (flashMode) { case 'DFU': if (!device.isInDfuMode) { ui.logDFUModeRequired(); throw new Error('Put the device in DFU mode and try again'); } break; case 'NORMAL': if (device.isInDfuMode) { ui.logNormalModeRequired(); throw new Error('Put the device in Normal mode and try again'); } break; default: break; } } async function reopenInDfuMode(device) { const { id } = device; const start = Date.now(); while (Date.now() - start < REOPEN_TIMEOUT) { await delay(REOPEN_DELAY); try { if (device && device.isOpen) { await device.close(); } device = await openUsbDeviceById(id, { dfuMode: true }); if (!device.isInDfuMode) { await device.enterDfuMode(); await device.close(); device = await openUsbDeviceById(id); } return device; } catch (error) { // ignore other errors if (error instanceof DeviceProtectionError) { throw new Error('Operation cannot be completed due to Device Protection.'); } } } throw new Error('Unable to reconnect to the device. Try again or run particle update to repair the device'); } async function reopenInNormalMode(device, { reset } = {}) { const { id } = device; if (reset && device.isOpen) { await device.reset(); } if (device.isOpen) { await device.close(); } const start = Date.now(); while (Date.now() - start < REOPEN_TIMEOUT) { await delay(REOPEN_DELAY); try { device = await openDeviceById(id); if (device.isInDfuMode) { await device.close(); } else { // check if we can communicate with the device if (device.isOpen) { return device; } } } catch (err) { // ignore errors } } throw new Error('Unable to reconnect to the device. Try again or run particle update to repair the device'); } async function reopenDevice(device) { const { id } = device; if (device.isOpen) { await device.close(); } const start = Date.now(); while (Date.now() - start < REOPEN_TIMEOUT) { await delay(REOPEN_DELAY); try { device = await openDeviceById(id); // check if we can communicate with the device if (device.isOpen) { return device; } } catch (err) { // ignore error } } throw new Error('Unable to reconnect to the device. Try again or run particle update to repair the device'); } async function forEachUsbDevice(args, func, { dfuMode = false } = {}){ const msg = 'Getting device information...'; const operation = openUsbDevices(args, { dfuMode }); let lastError = null; let outputMsg = []; return spin(operation, msg) .then(usbDevices => { const p = usbDevices.map(async (usbDevice) => { return Promise.resolve() .then(async () => { await executeWithUsbDevice({ args: { idOrName : usbDevice.id, api: args.api, auth: args.auth }, func, dfuMode }); }) .catch(e => lastError = e); }); return spin(Promise.all(p), 'Sending a command to the device...'); }) .then(() => { if (outputMsg.length > 0) { outputMsg.forEach(msg => console.log(msg)); } if (lastError){ throw lastError; } }); } async function openUsbDevices(args, { dfuMode = false } = {}){ const deviceIds = args.params.devices; return Promise.resolve() .then(() => { if (args.all){ return getUsbDevices({ dfuMode: true }) .then(usbDevices => { return asyncMapSeries(usbDevices, (usbDevice) => { return openUsbDevice(usbDevice, { dfuMode }) .then(() => usbDevice); }); }); } if (deviceIds.length === 0){ return getUsbDevices({ dfuMode: true }) .then(usbDevices => { if (usbDevices.length === 0){ throw new Error('No devices found'); } if (usbDevices.length > 1){ throw new Error('Found multiple devices. Please specify the ID or name of one of them'); } const usbDevice = usbDevices[0]; return openUsbDevice(usbDevice, { dfuMode }) .then(() => [usbDevice]); }); } return asyncMapSeries(deviceIds, (id) => { return openUsbDeviceByIdOrName(id, args.api, args.auth, { dfuMode }) .then(usbDevice => usbDevice); }); }); } async function handleUsbError(err){ if (err instanceof NotAllowedError) { err = new UsbPermissionsError('Missing permissions to access the USB device'); if (systemSupportsUdev()) { try { await promptAndInstallUdevRules(err); } catch (err) { throw new UsbPermissionsError(err.message); } } } throw err; } module.exports = { openUsbDevice, openUsbDeviceById, openUsbDeviceByIdOrName, getUsbDevices, getOneUsbDevice, reopenInDfuMode, reopenInNormalMode, reopenDevice, UsbPermissionsError, TimeoutError, DeviceProtectionError, forEachUsbDevice, openUsbDevices, executeWithUsbDevice, waitForDeviceToRespond };