particle-cli
Version:
Simple Node commandline application for working with your Particle devices and using the Particle Cloud
578 lines (519 loc) • 18.4 kB
JavaScript
const CLICommandBase = require('./base');
const spinnerMixin = require('../lib/spinner-mixin');
const usbUtils = require('../cmd/usb-util');
const inquirer = require('inquirer');
const RESCAN_LABEL = '[rescan networks]';
const fs = require('fs-extra');
const { deviceControlError } = require('../lib/device-error-handler');
const ParticleApi = require('../cmd/api');
const settings = require('../../settings');
const createApiCache = require('../lib/api-cache');
const utilities = require('../lib/utilities');
const os = require('os');
const { WifiSecurityEnum } = require('particle-usb');
const chalk = require('chalk');
const { platformForId } = require('../lib/platform');
const JOIN_NETWORK_TIMEOUT = 30000;
const REQUEST_TIMEOUT = 10000;
const TIME_BETWEEN_RETRIES = 1000;
const NUM_TRIES = 3;
const WIFI_COMMANDS_SUPPORTED_DEVICE_GEN = 3;
const securityMapping = {
'NO_SECURITY': 'NONE',
'NONE': 'NONE',
'WEP': 'WEP',
'WPA_AES': 'WPA_PSK',
'WPA_TKIP': 'WPA_PSK',
'WPA_AES+TKIP': 'WPA_PSK',
'WPA_PSK': 'WPA_PSK',
'WPA2_AES': 'WPA2_PSK',
'WPA2_TKIP': 'WPA2_PSK',
'WPA2_AES+TKIP': 'WPA2_PSK',
'WPA2_PSK': 'WPA2_PSK',
'WPA_WPA2_PSK': 'WPA_WPA2_PSK',
'WPA3_PSK': 'WPA3_PSK',
'WPA2_WPA3_PSK': 'WPA2_WPA3_PSK',
};
const WifiSecurityConsolidatedForUserPrompt = ['NO_SECURITY', 'WEP', 'WPA_PSK', 'WPA2_PSK', 'WPA3_PSK'];
module.exports = class WiFiCommands extends CLICommandBase {
constructor({ ui } = {}) {
super();
spinnerMixin(this);
const { api } = this._particleApi();
this.api = api;
this.ui = ui || this.ui;
}
async addNetwork(args) {
this.file = args.file;
return await usbUtils.executeWithUsbDevice({
args: { api: this.api, auth: this.auth },
func: async (device) => {
await this._compatCheck(device);
const { ssid, security, password, hidden } = await this._getNetwork(device);
await this._performWifiOperation(device, `Adding Wi-Fi network '${ssid}'`, async () => {
await device.setWifiCredentials({ ssid, security, password, hidden }, { timeout: REQUEST_TIMEOUT });
});
this.ui.stdout.write(`Wi-Fi network '${ssid}' added successfully.${os.EOL}`);
this.ui.stdout.write(`To join this network, run ${chalk.yellow('particle wifi join --ssid <SSID>')}${os.EOL}`);
this.ui.stdout.write(os.EOL);
}
});
}
async joinNetwork(args) {
this.file = args.file;
return await usbUtils.executeWithUsbDevice({
args: { api: this.api, auth: this.auth },
func: async (device) => {
await this._compatCheck(device);
const { ssid, security, password, hidden } = await this._getNetwork(device);
let mode;
await this._performWifiOperation(device, `Joining Wi-Fi network '${ssid}'`, async () => {
mode = await device.getDeviceMode({ timeout: 10 * 1000 });
await device.joinNewWifiNetwork({ ssid, security, password, hidden }, { timeout: JOIN_NETWORK_TIMEOUT });
});
// Device does not exit listening mode after joining a network.
// Until the behavior is fixed, we will exit listening mode manually.
if (mode === 'LISTENING') {
this.ui.stdout.write(`Exiting listening mode...${os.EOL}`);
try {
await device.leaveListeningMode();
} catch (error) {
// Ignore error
// It's not critical that the device does not exit listening mode
}
}
this.ui.stdout.write(`Wi-Fi network '${ssid}' configured successfully. Attempting to join...${os.EOL}Use ${chalk.yellow('particle wifi current')} to check the current network.${os.EOL}`);
}
});
}
async joinKnownNetwork(args) {
this.file = args.file;
return await usbUtils.executeWithUsbDevice({
args: { api: this.api, auth: this.auth },
func: async (device) => {
await this._compatCheck(device);
let mode;
const ssid = args.ssid;
await this._performWifiOperation(device, `Joining a known Wi-Fi network '${ssid}'`, async () => {
mode = await device.getDeviceMode({ timeout: 10 * 1000 });
await device.joinKnownWifiNetwork({ ssid }, { timeout: JOIN_NETWORK_TIMEOUT });
});
// Device does not exit listening mode after joining a network.
// Until the behavior is fixed, we will exit listening mode manually.
if (mode === 'LISTENING') {
this.ui.stdout.write(`Exiting listening mode...${os.EOL}`);
try {
await device.leaveListeningMode();
} catch (error) {
// Ignore error
// It's not critical that the device does not exit listening mode
}
}
this.ui.stdout.write(`Wi-Fi network '${ssid}' configured successfully. Attemping to join...${os.EOL}Use ${chalk.yellow('particle wifi current')} to check the current network.${os.EOL}`);
}
});
}
async clearNetworks() {
return await usbUtils.executeWithUsbDevice({
args: { api: this.api, auth: this.auth },
func: async (device) => {
await this._compatCheck(device);
await this._performWifiOperation(device, 'Clearing Wi-Fi networks', async () => {
await device.clearWifiNetworks({ timeout: REQUEST_TIMEOUT });
});
this.ui.stdout.write(`Wi-Fi networks cleared successfully.${os.EOL}`);
}
});
}
async listNetworks() {
return await usbUtils.executeWithUsbDevice({
args: { api: this.api, auth: this.auth },
func: async (device) => {
await this._compatCheck(device);
let list, currentNetwork;
await this._performWifiOperation(device, 'Listing Wi-Fi networks', async () => {
list = await device.listWifiNetworks({ timeout: REQUEST_TIMEOUT });
currentNetwork = await this._getCurrentNetwork(device);
});
this.ui.stdout.write(`List of Wi-Fi networks on the device:${os.EOL}${os.EOL}`);
const networks = list?.networks || [];
if (networks.length) {
const currentSsid = currentNetwork?.ssid;
networks.forEach(network => {
const networkInfo = `- ${network.ssid} (${WifiSecurityEnum[network.security]})`;
if (currentSsid === network.ssid) {
this.ui.stdout.write(`${networkInfo} - current network${os.EOL}`);
} else {
this.ui.stdout.write(`${networkInfo}${os.EOL}`);
}
});
} else {
this.ui.stdout.write('No Wi-Fi networks found.');
}
this.ui.stdout.write(os.EOL);
}
});
}
async removeNetwork(args) {
const { ssid } = args;
if (!ssid) {
throw new Error('Please provide a network name to remove using the --ssid flag.');
}
return await usbUtils.executeWithUsbDevice({
args: { api: this.api, auth: this.auth },
func: async (device) => {
await this._compatCheck(device);
await this._performWifiOperation(device, 'Removing Wi-Fi networks', async () => {
await device.removeWifiNetwork({ ssid }, { timeout: REQUEST_TIMEOUT });
});
this.ui.stdout.write(`Wi-Fi network ${ssid} removed from device's list successfully.${os.EOL}`);
this.ui.stdout.write(`To disconnect from the network, run ${chalk.yellow('particle usb reset')}.${os.EOL}`);
this.ui.stdout.write(os.EOL);
}
});
}
async getCurrentNetwork() {
return await usbUtils.executeWithUsbDevice({
args: { api: this.api, auth: this.auth },
func: async (device) => {
await this._compatCheck(device);
const parsedResult = await this._performWifiOperation(device, 'Fetching current Wi-Fi network', async () => {
const currentNetwork = await this._getCurrentNetwork(device);
if (!currentNetwork) {
throw new Error('No Wi-Fi network connected');
}
return currentNetwork;
});
this.ui.stdout.write(`Current Wi-Fi network:${os.EOL}${os.EOL}`);
this.ui.stdout.write(`- SSID: ${parsedResult?.ssid}${os.EOL}` +
(` BSSID: ${parsedResult?.bssid}${os.EOL}`) +
` Channel: ${parsedResult?.channel}${os.EOL}` +
` RSSI: ${parsedResult?.rssi}${os.EOL}${os.EOL}`);
}
});
}
async _compatCheck(device) {
const deviceId = device.id;
const platformId = device.platformId;
const deviceGen = platformForId(platformId).generation;
const platformName = platformForId(platformId).name;
const features = platformForId(platformId).features;
if (features.includes('wifi') === false) {
throw new Error(`This device (${deviceId} / ${platformName}) does not support Wi-Fi.${os.EOL}`);
}
if (deviceGen < WIFI_COMMANDS_SUPPORTED_DEVICE_GEN) {
throw new Error(`The 'particle wifi' commands are not supported on this device (${deviceId} / ${platformName}).${os.EOL}Use 'particle serial wifi' instead.${os.EOL}`);
}
}
async _getNetwork(device) {
if (this.file) {
return this._getNetworkToConnectFromJson();
}
return this._getNetworkToConnect(device);
}
async _getNetworkToConnectFromJson() {
const { network, security, password, hidden } = await fs.readJSON(this.file);
if (!network) {
const error = new Error('No network name found in the file');
error.isUsageError = true;
throw error;
}
return { ssid: network, security: this._convertToKnownSecType(security), password, hidden };
}
async _getNetworkToConnect(device, { prompt = true } = { }) {
let scan = true;
if (prompt) {
scan = await this._promptForScanNetworks();
}
if (scan) {
const networks = await this._scanNetworks(device);
if (networks.length) {
const network = await this._promptToSelectNetwork(networks);
if (network?.rescan){
return await this._getNetworkToConnect(device, { prompt: false });
} else {
return network;
}
} else {
throw new Error('No Wi-Fi networks found');
}
}
return this._pickNetworkManually();
}
async _promptForScanNetworks() {
const question = {
type: 'confirm',
name: 'scan',
message: 'Would you like to scan for Wi-Fi networks?'
};
const ans = await this.ui.prompt([question]);
return ans.scan;
}
async _scanNetworks(device) {
const networks = await this._deviceScanNetworks(device);
if (!networks.length) {
const answers = await this.ui.prompt([{
type: 'confirm',
name: 'rescan',
message: 'No networks found. Try again?',
default: true
}]);
if (answers.rescan){
return this._scanNetworks(device);
}
}
return this._filterNetworks(networks);
}
_filterNetworks(networkList) {
const networks = networkList.filter((ap) => {
if (!ap){
return false;
}
// filter out null ssid
if (!ap.ssid){
return false;
}
// channel # > 14 === 5GHz
if (ap.channel && parseInt(ap.channel, 10) > 14){
return false;
}
return true;
});
return networks.reduce((acc, network) => {
if (!acc.find((n) => n.ssid === network.ssid)) {
acc.push(network);
}
return acc;
}, []);
}
async _deviceScanNetworks(device) {
this.newSpin('Scanning for Wi-Fi networks').start();
let attempts = NUM_TRIES;
let lastError = null;
while (attempts > 0) {
try {
if (!device) {
throw new Error('No device found');
}
const networks = await device.scanWifiNetworks();
this.stopSpin();
return this._serializeNetworks(networks) || [];
} catch (error) {
lastError = error;
await utilities.delay(TIME_BETWEEN_RETRIES);
attempts--;
}
}
this.stopSpin();
throw this._handleDeviceError(lastError, { action: 'scan for Wi-Fi networks' });
}
async _promptToSelectNetwork(networks) {
let password;
const questions = [
{
type: 'list',
name: 'network',
message: 'Select the Wi-Fi network with which you wish to connect your device:',
choices: () => {
const ns = networks.map((n) => n.ssid);
ns.unshift(new inquirer.Separator());
ns.unshift(RESCAN_LABEL);
ns.unshift(new inquirer.Separator());
return ns;
},
when: () => networks.length > 0
},
];
const ans = await this.ui.prompt(questions);
if (ans.network === RESCAN_LABEL) {
return { ssid: null, rescan: true };
}
const network = networks.find((n) => n.ssid === ans.network);
if (!network.unsecure) {
password = await this._promptForPassword();
}
return { ssid: network.ssid, security: this._convertToKnownSecType(network.security), password };
}
async _performWifiOperation(device, operationName, operationCallback) {
this.newSpin(`${operationName}`).start();
let lastError;
if (!device) {
this.stopSpin();
throw new Error('No device found');
}
for (let attempt = 0; attempt < NUM_TRIES; attempt++) {
try {
// The device API can throw an error directly
// or it can return an error in its result object in result.error
const result = await operationCallback();
this.stopSpin();
return result;
} catch (error) {
// TODO: FIXME: use error.id === 'NOT_SUPPORTED'
if (error.message === 'Not supported') {
this.stopSpin();
throw this._handleDeviceError(error, { action: this._getActionStringFromOp(operationName) });
}
if ((error.message === 'Invalid argument' || error.message === 'Invalid state') && operationName.toLowerCase().includes('join')) {
this.stopSpin();
throw this._handleDeviceError(error, { action: this._getActionStringFromOp(operationName) });
}
lastError = error;
}
await utilities.delay(TIME_BETWEEN_RETRIES);
}
this.stopSpin();
throw this._handleDeviceError(lastError, { action: this._getActionStringFromOp(operationName) });
}
_getActionStringFromOp(operationName) {
// This converts an operation name (e.g., 'Adding Wi-Fi network') to a verb form (e.g., 'Add Wi-Fi network')
return operationName.replace(/^\w+/, match => match.toLowerCase().replace(/ing$/, ''));
}
async _getCurrentNetwork(device) {
let currentNetwork;
const ifaces = await device.getNetworkInterfaceList();
const wifiIface = await device.getNetworkInterface({ index: ifaces.find(iface => iface.type === 'WIFI').index });
if (wifiIface && wifiIface.flagsStrings.includes('LOWER_UP')) {
try {
currentNetwork = await device.getCurrentWifiNetwork({ timeout: REQUEST_TIMEOUT });
} catch (error) {
// Ignore error if the device does not support the getCurrentWifiNetwork command
}
}
return currentNetwork;
}
async _pickNetworkManually() {
const ssid = await this._promptForSSID();
const security = await this._promptForSecurityType();
let password = null;
if (security !== 'NO_SECURITY') {
password = await this._promptForPassword();
}
const hidden = await this._promptForHiddenNetwork();
return { ssid, security: this._convertToKnownSecType(security), password, hidden };
}
// For Gen 3 and above, ensure that the security string ends with `PSK`.
// Device-OS processes only known security types and they all end with `PSK` of their kind.
// Similar security types are consolidated and mapped to a unified type ending in `PSK`.
// Example: WPA_AES, WPA_TKIP, WPA_AES+TKIP are all treated as WPA_PSK.
// For the exact mapping, see https://github.com/particle-iot/device-os-protobuf/blob/main/control/wifi_new.proto
_convertToKnownSecType(security) {
const mapping = securityMapping[security];
if (!mapping) {
throw new Error(`Unknown security type: ${security}`);
}
return mapping;
}
async _promptForHiddenNetwork() {
const question = {
type: 'confirm',
name: 'hidden',
message: 'Is this a hidden network?',
default: false
};
const ans = await this.ui.prompt([question]);
return ans.hidden;
}
async _promptForSSID() {
const question = {
type: 'input',
name: 'ssid',
message: 'SSID',
validate: (input) => {
if (!input || !input.trim()) {
return 'Please enter the SSID';
} else {
return true;
}
},
filter: (input) => {
return input.trim();
}
};
const ans = await this.ui.prompt([question]);
return ans.ssid;
}
async _promptForPassword() {
const question = {
type: 'input',
name: 'password',
message: 'Wi-Fi Password',
validate: (input) => {
return !!input;
}
};
const ans = await this.ui.prompt([question]);
return ans.password;
}
async _promptForSecurityType() {
// TODO: Expand the list of security types to include more relevant options
// (e.g., WPA_AES for WPA_PSK) to assist users who may not know the specific associations
const securityChoices = WifiSecurityConsolidatedForUserPrompt;
const question = [
{
type: 'list',
name: 'security',
message: 'Select the security type for your Wi-Fi network:',
choices: securityChoices
},
];
const ans = await this.ui.prompt(question);
return ans.security;
}
_serializeNetworks(networks) {
return networks?.map((ap) => {
return {
ssid: ap.ssid,
security: ap.security,
signal_level: ap.rssi,
channel: ap.channel.toString(),
unsecure: ap.security === 'NO_SECURITY',
mac: ''
};
});
}
// TODO: Fix error handling
// Figure out a way to differentiate between USB errors and device errors and handle them accordingly
_handleDeviceError(_error, { action } = { }) {
const error = _error;
if (_error.cause) {
error.message = deviceControlError[error.name];
}
let helperString = '';
switch (error.message) {
case 'Request timeout':
if (action.toLowerCase().includes('join')) {
helperString = 'Please check the network credentials.';
}
break;
case 'Invalid state':
if (action.toLowerCase().includes('fetch')) {
helperString = 'Check that the device is connected to the network.';
}
if (action.toLowerCase().includes('join')) {
helperString = `${os.EOL}1. Please check the network credentials.\
${os.EOL}2. Please verify that the Access Point is in range.\
${os.EOL}3. If you are using a hidden network, please add the hidden network credentials first using 'particle wifi add'.${os.EOL}`;
}
break;
case 'Not found':
helperString = 'If you are using a hidden network, please add the hidden network credentials first using \'particle wifi add\'.';
break;
case 'Not supported':
helperString = `This feature is likely not supported on this firmware version.${os.EOL}Update to device-os 6.2.0 or use 'particle wifi join --help' to join a network.${os.EOL}Alternatively, check 'particle serial wifi'.${os.EOL}`;
break;
case 'Invalid argument':
helperString = 'Please check the network credentials.';
break;
default:
break;
}
return new Error(`Unable to ${action}: ${error.message}${os.EOL}${helperString}`);
}
_particleApi() {
const auth = settings.access_token;
const api = new ParticleApi(settings.apiUrl, { accessToken: auth } );
const apiCache = createApiCache(api);
return { api: apiCache, auth };
}
};