UNPKG

particle-cli

Version:

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

381 lines (344 loc) 13.2 kB
const os = require('os'); const path = require('path'); const chalk = require('chalk'); const CLICommandBase = require('./base'); const spinnerMixin = require('../lib/spinner-mixin'); const usbUtils = require('../cmd/usb-util'); const ParticleApi = require('../cmd/api'); const settings = require('../../settings'); const createApiCache = require('../lib/api-cache'); const { downloadDeviceOsVersionBinaries } = require('../lib/device-os-version-util'); const FlashCommand = require('./flash'); const { platformForId } = require('../lib/platform'); const BinaryCommand = require('./binary'); const DeviceProtectionHelper = require('../lib/device-protection-helper'); module.exports = class DeviceProtectionCommands extends CLICommandBase { constructor({ ui } = {}) { super(); spinnerMixin(this); const { api } = this._particleApi(); this.api = api; this.deviceId = null; this.device = null; this.ui = ui || this.ui; this.productId = null; this.status = { protected: null, overridden: null }; } /** * Retrieves and displays the protection status of the device. * * It retrieves the current protection status and constructs a message * indicating whether the device is Protected, in Service Mode, or Open. * * @async * @returns {Promise<Object>} The protection state of the device. * @throws {Error} Throws an error if any of the async operations fail. */ async getStatus({ device } = {}) { this.deviceId = device; let addToOutput = []; let s; try { await this._withDevice({ spinner: 'Getting device status' }, async () => { let res; let helper; s = this.status; if (s.overridden) { res = 'Protected Device (Service Mode)'; helper = `Run ${chalk.yellow('particle device-protection enable')} to take the device out of Service Mode.`; } else if (s.protected) { res = 'Protected Device'; helper = `Run ${chalk.yellow('particle device-protection disable')} to put the device in Service Mode.`; } else { res = 'Open Device'; helper = `Run ${chalk.yellow('particle device-protection enable')} to protect the device.`; } const deviceStr = await this._getDeviceString(); addToOutput.push(`${deviceStr}: ${chalk.bold(res)}${os.EOL}${helper}${os.EOL}`); }); } catch (error) { // TODO: Log detailed and user-friendly error messages from the device or API instead of displaying the raw error message if (error.message === 'Not supported') { throw new Error(`Device Protection feature is not supported on this device. Visit ${chalk.yellow('https://docs.particle.io')} for more information.`); } throw new Error(`Unable to get device status: ${error.message}${os.EOL}`); } addToOutput.forEach((line) => { this.ui.stdout.write(line); }); return s; } /** * Disables protection on the device. * * This method checks the current protection status of the device and proceeds to put the device in Service Mode * if the device is protected. If the device is not protected or is already in Service Mode, * appropriate messages are logged to the console. * * @async * @returns {Promise<void>} * @throws {Error} - Throws an error if any of the async operations fail. */ async disableProtection({ device } = {}) { this.deviceId = device; let addToOutput = []; try { await this._withDevice({ spinner: 'Disabling Device Protection' }, async () => { const deviceStr = await this._getDeviceString(); const s = this.status; if (!s.protected && !s.overridden) { addToOutput.push(`${deviceStr} is not a Protected Device.${os.EOL}`); return; } if (this.device.isInDfuMode) { await this._putDeviceInSafeMode(); } await DeviceProtectionHelper.disableDeviceProtection(this.device); addToOutput.push(`${deviceStr} is now in Service Mode.${os.EOL}A Protected Device stays in Service Mode for a total of 20 reboots or 24 hours.${os.EOL}`); }); } catch (error) { if (error.message === 'Not supported') { throw new Error(`Device Protection feature is not supported on this device. Visit ${chalk.yellow('https://docs.particle.io')} for more information.`); } throw new Error(`Failed to disable Device Protection: ${error.message}${os.EOL}`); } addToOutput.forEach((line) => { this.ui.stdout.write(line); }); } /** * Enables protection on the device. * * This method checks the current protection status of the device and proceeds to enable protection by * either terminating the protection if the device is already protected or enabling protection on the device * if the device is not protected and the Device Protection feature is active in the product. * It flashes a protected bootloader binary to the device if necessary and remove the device from development mode. * * @async * @param {Object} [options={}] - Options for enabling protection. * @param {string} [options.file] - The path to a bootloader binary file to use for protection. * @returns {Promise<void>} * @throws {Error} Throws an error if any of the asynchronous operations fail. */ async enableProtection({ file, device } = {}) { this.deviceId = device; let addToOutput = []; try { await this._withDevice({ spinner: 'Enabling Device Protection' }, async () => { const deviceStr = await this._getDeviceString(); const s = this.status; // Protected (Service Mode) Device if (s.overridden) { await DeviceProtectionHelper.turnOffServiceMode(this.device); addToOutput.push(`${deviceStr} is now a Protected Device.${os.EOL}`); return; } // Protected Device if (s.protected) { addToOutput.push(`${deviceStr} is already a Protected Device.${os.EOL}`); return; } if (this.device.isInDfuMode) { await this._putDeviceInSafeMode(); } // Open Device let localBootloaderPath = file; // bypass checking the product and clearing development mode when the bootloader is provided to allow for enabling Device Protection offline const onlineMode = !file; if (onlineMode) { const deviceProtectionActiveInProduct = await this._isDeviceProtectionActiveInProduct(); if (!deviceProtectionActiveInProduct) { addToOutput.push(`${deviceStr} is not in a product that supports Device Protection.${os.EOL}`); return; } localBootloaderPath = await this._downloadBootloader(); } const protectedBinary = await this._getProtectedBinary({ file: localBootloaderPath, verbose: false }); await this._flashBootloader(protectedBinary); addToOutput.push(`${deviceStr} is now a Protected Device.${os.EOL}`); if (onlineMode) { const success = await this._markAsDevelopmentDevice(false); if (typeof success !== 'undefined') { addToOutput.push(success ? // TODO: Improve these lines `Device removed from development mode to maintain current settings.${os.EOL}` : `Failed to remove device from development mode. Device protection may be disabled on next cloud connection.${os.EOL}` ); } } }); } catch (error) { if (error.message === 'Not supported') { throw new Error(`Device Protection feature is not supported on this device. Visit ${chalk.yellow('https://docs.particle.io')} for more information${os.EOL}`); } throw new Error(`Failed to enable Device Protection: ${error.message}${os.EOL}`); } addToOutput.forEach((line) => { this.ui.stdout.write(line); }); } async _getProtectedBinary({ file, verbose=true }) { const res = await new BinaryCommand().createProtectedBinary({ file, verbose }); return res; } /** * Downloads the bootloader binary for the device. * * This method retrieves the firmware module information from the device to determine the version and platform ID. * It then downloads the device OS version binaries and returns the path to the bootloader binary. * * @async * @returns {Promise<string>} The file path to the downloaded bootloader * @throws {Error} Throws an error if any of the async operations fail. */ async _downloadBootloader() { const modules = await this.device.getFirmwareModuleInfo(); const version = modules.find(m => m.type === 'SYSTEM_PART').version; const platformId = this.device.platformId; const downloadedFilePaths = await downloadDeviceOsVersionBinaries({ api: this.api, platformId, version, ui: this.ui, verbose: false }); const platformName = platformForId(platformId).name; return downloadedFilePaths.find(f => path.basename(f).includes(`${platformName}-bootloader`)); } /** * Flashes the bootloader on the device. * * @async * @param {string} path - The path to the bootloader binary. * @param {string} action - The action to perform ('enable' or 'disable'). * @returns {Promise<void>} */ async _flashBootloader(path) { const flashCmdInstance = new FlashCommand(); await flashCmdInstance.flashLocal({ files: [path], applicationOnly: true, verbose: false }); } /** * Marks the device as a development device. * * @async * @param {boolean} state - The state to set for the development device. * @returns {Promise<boolean|undefined>} Undefined if no need to change mode, true if the mode was successfully changed, false otherwise. */ async _markAsDevelopmentDevice(state) { try { if (this.productId) { const data = await this.api.getDeviceAttributes(this.deviceId, this.productId); if (data.development === state) { return; } await this.api.markAsDevelopmentDevice(this.deviceId, state, this.productId); return true; } } catch (error) { // Optionally log the error or handle it as needed } return false; } /** * Checks if Device Protection is active in the product. * * @async * @returns {Promise<boolean>} True if Device Protection is active, false otherwise. */ async _isDeviceProtectionActiveInProduct() { await this._getProductId(); if (!this.productId) { return false; } const res = await this.api.getProduct({ product: this.productId, auth: settings.access_token }); return res?.product?.device_protection === 'active'; } /** * Retrieves the product ID of the device. * * @async * @returns {Promise<string|null>} The product ID if available, otherwise null. */ async _getProductId() { if (this.productId) { return this.productId; } try { const attrs = await this.api.getDeviceAttributes(this.deviceId); this.productId = attrs.platform_id !== attrs.product_id ? attrs.product_id : null; } catch (error) { return null; } } /** * Executes a function with the device (Open / Protected / Protected (Service Mode)), ensuring it is in the correct mode. * Checks the protection status of the device which is needed for all the commands * * @async * @param {Object} options * @param {Function} options.spinner - The text to display in a spinner until the operation completes * @param {Function} fn - The function to execute with the device. * @returns {Promise<*>} The result of the function execution. */ async _withDevice({ spinner }, fn) { await this._getUsbDevice(this.device); const platform = platformForId(this.device.platformId); // Gen2 platforms can take a long time to respond to control requests. Quit early. if (platform.generation <= 2) { throw new Error(`Device Protection feature is not supported on this device. Visit ${chalk.yellow('https://docs.particle.io')} for more information.`); } await this.ui.showBusySpinnerUntilResolved(spinner, (async () => { this.status = await DeviceProtectionHelper.getProtectionStatus(this.device); return await fn(); })()); if (this.device && this.device.isOpen) { await this.device.close(); } } /** * Constructs and returns a string representation of the device, including its product ID. * * @async * @returns {Promise<string>} A string representing the device and its product ID. */ async _getDeviceString() { await this._getProductId(); return `[${this.deviceId}] (Product ${this.productId || 'N/A'})`; } /** * Retrieves the USB device and updates the instance's device ID. * * @async * @param {Object} dev - The USB device instance. * @returns {Promise<void>} */ async _getUsbDevice(dev) { if (!dev || dev.isOpen === false) { this.device = await usbUtils.getOneUsbDevice({ api: this.api, idOrName: this.deviceId, ui: this.ui }); this.deviceId = this.device._id; } } /** * 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 _putDeviceInSafeMode() { try { await this.device.enterSafeMode(); } catch (error) { // ignore errors } this.device = await usbUtils.reopenInNormalMode({ id: this.deviceId }); } /** * Creates and returns the Particle API and authentication token. * * @returns {Object} The Particle API instance and authentication token. */ _particleApi() { const auth = settings.access_token; const api = new ParticleApi(settings.apiUrl, { accessToken: auth } ); const apiCache = createApiCache(api); return { api: apiCache, auth }; } };