From 9726a65ca6db9eb5123d16501907e16429b5afce Mon Sep 17 00:00:00 2001 From: keeramis Date: Sun, 19 May 2024 22:36:01 -0700 Subject: [PATCH] Add particle wifi commands --- src/cli/index.js | 2 + src/cli/wifi.js | 105 +++++++++++ src/cmd/serial.js | 7 +- src/cmd/wifi.js | 40 +++++ src/lib/wifi-control-request.js | 302 +++++++++++++++++++++++++++++--- 5 files changed, 433 insertions(+), 23 deletions(-) create mode 100644 src/cli/wifi.js create mode 100644 src/cmd/wifi.js diff --git a/src/cli/index.js b/src/cli/index.js index f426b403f..2da2b5963 100644 --- a/src/cli/index.js +++ b/src/cli/index.js @@ -25,6 +25,7 @@ const variable = require('./variable'); const version = require('./version'); const webhook = require('./webhook'); const whoami = require('./whoami'); +const wifi = require('./wifi'); const usb = require('./usb'); /** @@ -69,6 +70,7 @@ module.exports = function registerAllCommands(context) { version(context); webhook(context); whoami(context); + wifi(context); usb(context); alias(context); }; diff --git a/src/cli/wifi.js b/src/cli/wifi.js new file mode 100644 index 000000000..7b0cd30b4 --- /dev/null +++ b/src/cli/wifi.js @@ -0,0 +1,105 @@ +const unindent = require('../lib/unindent'); + +module.exports = ({ commandProcessor, root }) => { + const wifi = commandProcessor.createCategory(root, 'wifi', 'Configure Wi-Fi credentials to your device(s)'); + + const portOption = { + 'port': { + describe: 'Use this serial port instead of auto-detecting. Useful if there are more than 1 connected device' + } + }; + + commandProcessor.createCommand(wifi, 'add', 'Adds a WiFi network to your device', { + options: Object.assign({ + 'file': { + description: 'Take the credentials from a JSON file instead of prompting for them' + } + }, portOption), + handler: (args) => { + const WiFiCommands = require('../cmd/wifi'); + return new WiFiCommands().addNetwork(args); + }, + examples: { + '$0 $command': 'Prompt for Wi-Fi credentials and send them to a device', + '$0 $command --file credentials.json': 'Read Wi-Fi credentials from credentials.json and send them to a device' + }, + epilogue: unindent(` + The JSON file for passing Wi-Fi credentials should look like this: + { + "network": "my_ssid", + "security": "WPA2_AES", + "password": "my_password" + } + + The security property can be NONE, WEP, WPA2_AES, WPA2_TKIP, WPA2_AES+TKIP, WPA_AES, WPA_TKIP, WPA_AES+TKIP. + For enterprise Wi-Fi, set security to WPA_802.1x or WPA2_802.1x and provide the eap, username, outer_identity, client_certificate, private_key and root_ca properties. + `) + }); + + commandProcessor.createCommand(wifi, 'join', 'Joins a wifi network', { + options: Object.assign({ + 'file': { + description: 'Take the credentials from a JSON file instead of prompting for them' + }, + 'ssid': { + description: 'The name of the network to join' + }, + }, portOption), + handler: (args) => { + const WiFiCommands = require('../cmd/wifi'); + if (args.ssid) { + return new WiFiCommands().joinKnownNetwork(args); + } + return new WiFiCommands().joinNetwork(args); + }, + examples: { + '$0 $command': 'Prompt for Wi-Fi credentials and send them to a device', + '$0 $command --file credentials.json': 'Read Wi-Fi credentials from credentials.json and send them to a device' + }, + epilogue: unindent(` + The JSON file for passing Wi-Fi credentials should look like this: + { + "network": "my_ssid", + "security": "WPA2_AES", + "password": "my_password" + } + + The security property can be NONE, WEP, WPA2_AES, WPA2_TKIP, WPA2_AES+TKIP, WPA_AES, WPA_TKIP, WPA_AES+TKIP. + For enterprise Wi-Fi, set security to WPA_802.1x or WPA2_802.1x and provide the eap, username, outer_identity, client_certificate, private_key and root_ca properties. + `) + }); + + commandProcessor.createCommand(wifi, 'clear', 'Clears the list of wifi credentials on your device', { + handler: () => { + const WiFiCommands = require('../cmd/wifi'); + return new WiFiCommands().clearNetworks(); + }, + examples: { + '$0 $command': 'Clears the list of wifi credentials on your device', + } + }); + + commandProcessor.createCommand(wifi, 'list', 'Lists the wifi networks on your device', { + handler: () => { + const WiFiCommands = require('../cmd/wifi'); + return new WiFiCommands().listNetworks(); + }, + examples: { + '$0 $command': 'Clears the list of wifi credentials on your device', + } + }); + + commandProcessor.createCommand(wifi, 'remove', 'Removes a network from the device', { + params: '', + handler: (args) => { + const WiFiCommands = require('../cmd/wifi'); + return new WiFiCommands().removeNetwork(args.params.ssid); + }, + examples: { + '$0 $command ssid': 'Removes network from the device', + } + }); + + return wifi; +}; + diff --git a/src/cmd/serial.js b/src/cmd/serial.js index b93f08a62..d4688c9a2 100644 --- a/src/cmd/serial.js +++ b/src/cmd/serial.js @@ -20,7 +20,6 @@ const FlashCommand = require('./flash'); const usbUtils = require('./usb-util'); const { platformForId } = require('../lib/platform'); const { FirmwareModuleDisplayNames } = require('particle-usb'); -const WifiControlRequest = require('../lib/wifi-control-request'); const semver = require('semver'); const IDENTIFY_COMMAND_TIMEOUT = 20000; @@ -532,6 +531,12 @@ module.exports = class SerialCommand extends CLICommandBase { throw new VError('The device does not support Wi-Fi'); } + const fwVer = device.firmwareVersion; + if (semver.gte(fwVer, '6.2.0')) { + this.ui.stdout.write(`[Recommendation]${os.EOL}Use the improved Wi-Fi configuration commands for this device-os version (>= 6.2.0).${os.EOL}See 'particle wifi --help' for more details on available commands.${os.EOL}`); + this.ui.stdout.write(`${os.EOL}`); + } + // configure serial if (file){ return this._configWifiFromFile(deviceFromSerialPort, file); diff --git a/src/cmd/wifi.js b/src/cmd/wifi.js new file mode 100644 index 000000000..7f073514a --- /dev/null +++ b/src/cmd/wifi.js @@ -0,0 +1,40 @@ +const WifiControlRequest = require('../lib/wifi-control-request'); +const CLICommandBase = require('./base'); +const spinnerMixin = require('../lib/spinner-mixin'); + +// add checks that if the device < 6.2.0, then use the old wifi command + +module.exports = class WiFiCommands extends CLICommandBase { + constructor() { + super(); + spinnerMixin(this); + this.wifiControlRequest = new WifiControlRequest(null, { ui: this.ui, newSpin: this.newSpin, stopSpin: this.stopSpin }); + } + + addNetwork(args) { + this.wifiControlRequest.file = args.file; + return this.wifiControlRequest.addNetwork(); + } + + joinNetwork(args) { + this.wifiControlRequest.file = args.file; + return this.wifiControlRequest.joinNetwork(); + } + + joinKnownNetwork(args) { + this.wifiControlRequest.file = args.file; + return this.wifiControlRequest.joinKnownNetwork({ ssid: args.ssid }); + } + + clearNetworks() { + return this.wifiControlRequest.clearNetworks(); + } + + listNetworks() { + return this.wifiControlRequest.listNetworks(); + } + + removeNetwork(ssid) { + return this.wifiControlRequest.removeNetwork(ssid); + } +} \ No newline at end of file diff --git a/src/lib/wifi-control-request.js b/src/lib/wifi-control-request.js index c9b3a0402..47c764e15 100644 --- a/src/lib/wifi-control-request.js +++ b/src/lib/wifi-control-request.js @@ -5,12 +5,14 @@ const fs = require('fs-extra'); const { deviceControlError } = require('./device-error-handler'); const JOIN_NETWORK_TIMEOUT = 30000; const TIME_BETWEEN_RETRIES = 1000; -const RETRY_COUNT = 5; +const RETRY_COUNT = 1; const ParticleApi = require('../cmd/api'); const settings = require('../../settings'); const createApiCache = require('./api-cache'); const utilities = require('./utilities'); const os = require('os'); +const semver = require('semver'); +const { WifiSecurityEnum } = require('particle-usb'); module.exports = class WiFiControlRequest { constructor(deviceId, { ui, newSpin, stopSpin, file }) { @@ -24,14 +26,118 @@ module.exports = class WiFiControlRequest { this.api = api; } - async configureWifi() { + async addNetwork() { let network; - if (this.file) { - network = await this.getNetworkToConnectFromJson(); - } else { - network = await this.getNetworkToConnect(); + try { + if (!this.device || this.device.isOpen === false) { + this.device = await usbUtils.getOneUsbDevice({ api: this.api, idOrName: this.deviceId, ui: this.ui }); + } + + const fwVer = this.device.firmwareVersion; + if (semver.lt(fwVer, '6.2.0')) { + throw new Error(`The 'add' command is not supported on this firmware version.${os.EOL}Use 'particle wifi join --help' to join a network.${os.EOL}`); + } + await this.ensureVersionCompat({ + version: this.device.firmwareVersion, + command: 'add' + }); + + if (this.file) { + network = await this.getNetworkToConnectFromJson(); + } else { + network = await this.getNetworkToConnect(this.device); + } + await this.addWifi(network); + } catch (error) { + throw error; + } finally { + if (this.device && this.device.isOpen) { + await this.device.close(); + } + } + } + + async joinNetwork() { + let network; + try { + if (!this.device || this.device.isOpen === false) { + this.device = await usbUtils.getOneUsbDevice({ api: this.api, idOrName: this.deviceId, ui: this.ui }); + } + + if (this.file) { + network = await this.getNetworkToConnectFromJson(); + } else { + network = await this.getNetworkToConnect(this.device); + } + await this.joinWifi(network); + } catch (error) { + throw error; + } finally { + if (this.device && this.device.isOpen) { + await this.device.close(); + } + } + } + + async joinKnownNetwork(ssid) { + try { + if (!this.device || this.device.isOpen === false) { + this.device = await usbUtils.getOneUsbDevice({ api: this.api, idOrName: this.deviceId, ui: this.ui }); + } + await this.joinKnownWifi(ssid); + } catch (error) { + throw error; + } finally { + if (this.device && this.device.isOpen) { + await this.device.close(); + } + } + } + + async listNetworks() { + try { + if (!this.device || this.device.isOpen === false) { + this.device = await usbUtils.getOneUsbDevice({ api: this.api, idOrName: this.deviceId, ui: this.ui }); + } + await this.listWifi(); + } catch (error) { + throw error; + } finally { + if (this.device && this.device.isOpen) { + await this.device.close(); + } + } + } + + async removeNetwork(ssid) { + try { + if (!this.device || this.device.isOpen === false) { + this.device = await usbUtils.getOneUsbDevice({ api: this.api, idOrName: this.deviceId, ui: this.ui }); + } + await this.removeWifi(ssid); + await this.listNetworks(); + } catch (error) { + throw error; + } finally { + if (this.device && this.device.isOpen) { + await this.device.close(); + } + } + } + + async clearNetworks() { + try { + if (!this.device || this.device.isOpen === false) { + this.device = await usbUtils.getOneUsbDevice({ api: this.api, idOrName: this.deviceId, ui: this.ui }); + } + await this.clearWifi(); + } catch (error) { + throw error; + } finally { + if (this.device && this.device.isOpen) { + await this.device.close(); + } } - await this.joinWifi(network); } async getNetworkToConnectFromJson() { @@ -76,7 +182,6 @@ module.exports = class WiFiControlRequest { } async scanNetworks() { - // open device by id const networks = await this._deviceScanNetworks(); if (!networks.length) { const answers = await this.ui.prompt([{ @@ -122,9 +227,10 @@ module.exports = class WiFiControlRequest { let lastError = null; while (retries > 0) { try { - if (!this.device || this.device.isOpen === false) { - this.device = await usbUtils.getOneUsbDevice({ api: this.api, idOrName: this.deviceId, ui: this.ui }); + if (!this.device) { + throw new Error('No device found'); } + const networks = await this.device.scanWifiNetworks(); this.stopSpin(); return this._serializeNetworks(networks) || []; @@ -132,10 +238,6 @@ module.exports = class WiFiControlRequest { lastError = error; await utilities.delay(TIME_BETWEEN_RETRIES); retries--; - } finally { - if (this.device && this.device.isOpen) { - await this.device.close(); - } } } this.stopSpin(); @@ -170,17 +272,45 @@ module.exports = class WiFiControlRequest { return { ssid: network.ssid, password }; } + async addWifi({ ssid, password }) { + let retries = RETRY_COUNT; + const spin = this.newSpin(`Joining Wi-Fi network '${ssid}'`).start(); + let lastError; + while (retries > 0) { + try { + if (!this.device) { + throw new Error('No device found'); + } + const { pass, error } = await this.device.setWifiCredentials({ ssid, password }, { timeout: JOIN_NETWORK_TIMEOUT }); + if (pass) { + this.stopSpin(); + this.ui.stdout.write('Wi-Fi network added successfully.'); + this.ui.stdout.write(os.EOL); + return; + } + retries = 0; + lastError = new Error(error); + } catch (error) { + spin.setSpinnerTitle(`Joining Wi-Fi network '${ssid}' is taking longer than expected.`); + lastError = error; + await utilities.delay(TIME_BETWEEN_RETRIES); + retries--; + } + } + this.stopSpin(); + throw this._handleDeviceError(lastError, { action: 'add Wi-Fi network' }); + } + async joinWifi({ ssid, password }) { - // open device by id let retries = RETRY_COUNT; const spin = this.newSpin(`Joining Wi-Fi network '${ssid}'`).start(); let lastError; while (retries > 0) { try { - if (!this.device || this.device.isOpen === false) { - this.device = await usbUtils.getOneUsbDevice({ api: this.api, idOrName: this.deviceId }); + if (!this.device) { + throw new Error('No device found'); } - const { pass } = await this.device.joinNewWifiNetwork({ ssid, password }, { timeout: JOIN_NETWORK_TIMEOUT }); + const { pass, error } = await this.device.joinNewWifiNetwork({ ssid, password }, { timeout: JOIN_NETWORK_TIMEOUT }); if (pass) { this.stopSpin(); this.ui.stdout.write('Wi-Fi network configured successfully, your device should now restart.'); @@ -189,22 +319,150 @@ module.exports = class WiFiControlRequest { return; } retries = 0; - lastError = new Error('Please check your credentials and try again.'); + lastError = new Error(error); } catch (error) { spin.setSpinnerTitle(`Joining Wi-Fi network '${ssid}' is taking longer than expected.`); lastError = error; await utilities.delay(TIME_BETWEEN_RETRIES); retries--; - } finally { - if (this.device && this.device.isOpen) { - await this.device.close(); + } + } + this.stopSpin(); + throw this._handleDeviceError(lastError, { action: 'join Wi-Fi network' }); + } + + async joinKnownWifi({ ssid }) { + let retries = RETRY_COUNT; + const spin = this.newSpin(`Joining Wi-Fi network '${ssid}'`).start(); + let lastError; + while (retries > 0) { + try { + if (!this.device) { + throw new Error('No device found'); + } + const { pass, error } = await this.device.joinKnownWifiNetwork({ ssid }, { timeout: JOIN_NETWORK_TIMEOUT }); + if (pass) { + this.stopSpin(); + this.ui.stdout.write('Wi-Fi network configured successfully, your device should now restart.'); + this.ui.stdout.write(os.EOL); + await this.device.reset(); + return; } + retries = 0; + lastError = new Error(error); + } catch (error) { + lastError = error; + spin.setSpinnerTitle(`Joining Wi-Fi network '${ssid}' is taking longer than expected.`); + await utilities.delay(TIME_BETWEEN_RETRIES); + retries--; } } this.stopSpin(); + // TODO: Add a more helpful error msg. "Not found" could be either not found in the device or the network throw this._handleDeviceError(lastError, { action: 'join Wi-Fi network' }); } + async clearWifi() { + let retries = RETRY_COUNT; + const spin = this.newSpin('Clearing Wi-Fi networks').start(); + let lastError; + while (retries > 0) { + try { + if (!this.device) { + throw new Error('No device found'); + } + const { pass, error } = await this.device.clearWifiNetworks({ timeout : JOIN_NETWORK_TIMEOUT }); + if (pass) { + this.stopSpin(); + this.ui.stdout.write('Wi-Fi networks cleared successfully.'); + this.ui.stdout.write(os.EOL); + return; + } + retries = 0; + lastError = new Error(error); + } catch (error) { + lastError = error; + spin.setSpinnerTitle('Clearing Wi-Fi networks is taking longer than expected.'); + await utilities.delay(TIME_BETWEEN_RETRIES); + retries--; + } + } + this.stopSpin(); + throw this._handleDeviceError(lastError, { action: 'clear Wi-Fi networks' }); + } + + async listWifi() { + let retries = RETRY_COUNT; + const spin = this.newSpin('Listing Wi-Fi networks').start(); + let lastError; + while (retries > 0) { + try { + if (!this.device) { + throw new Error('No device found'); + } + const { pass, replyObject } = await this.device.listWifiNetworks({ timeout : JOIN_NETWORK_TIMEOUT }); + if (pass) { + this.stopSpin(); + this.ui.stdout.write(`List of Wi-Fi networks:${os.EOL}${os.EOL}`); + const networks = replyObject.networks; + if (networks.length) { + networks.forEach((network) => { + this.ui.stdout.write(`- SSID: ${network.ssid}\n Security: ${WifiSecurityEnum[network.security]}\n Credentials Type: ${network.credentialsType}`); + this.ui.stdout.write(os.EOL); + this.ui.stdout.write(os.EOL); + }); + } else { + this.ui.stdout.write('\tNo Wi-Fi networks found.'); + this.ui.stdout.write(os.EOL); + } + this.ui.stdout.write(os.EOL); + return; + } + retries = 0; + lastError = new Error(error); + } catch (error) { + lastError = error; + spin.setSpinnerTitle('Listing Wi-Fi networks is taking longer than expected.'); + await utilities.delay(TIME_BETWEEN_RETRIES); + retries--; + } + } + this.stopSpin(); + throw this._handleDeviceError(lastError, { action: 'list Wi-Fi networks' }); + } + + async removeWifi(ssid) { + let retries = RETRY_COUNT; + const spin = this.newSpin('Removing Wi-Fi networks').start(); + let lastError; + while (retries > 0) { + try { + if (!this.device) { + throw new Error('No device found'); + } + const { pass, error } = await this.device.removeWifiNetwork( { ssid }, { timeout : JOIN_NETWORK_TIMEOUT }); + if (pass) { + this.stopSpin(); + this.ui.stdout.write(`Wi-Fi network ${ssid} removed successfully.${os.EOL}`); + this.ui.stdout.write(`Your device will stay connected to this network until reset or connected to other network. Run 'particle wifi --help' to learn more.${os.EOL}`); + // XXX: What about disconnecting from the network? + this.ui.stdout.write(os.EOL); + return; + } + retries = 0; + lastError = new Error(error); + } catch (error) { + lastError = error; + spin.setSpinnerTitle('Removing Wi-Fi networks is taking longer than expected.'); + await utilities.delay(TIME_BETWEEN_RETRIES); + retries--; + } + } + this.stopSpin(); + throw this._handleDeviceError(lastError, { action: 'remove Wi-Fi networks' }); + + } + async pickNetworkManually() { const ssid = await this._promptForSSID(); const password = await this._promptForPassword();