UNPKG

particle-cli

Version:

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

930 lines (799 loc) 27.1 kB
const spinnerMixin = require('../lib/spinner-mixin'); const usbUtils = require('../cmd/usb-util'); const fs = require('fs-extra'); const utilities = require('../lib/utilities'); const os = require('os'); const { platformForId } = require('../lib/platform'); const CLICommandBase = require('./base'); const execa = require('execa'); const SerialCommand = require('./serial'); const FlashCommand = require('./flash'); const path = require('path'); const _ = require('lodash'); const chalk = require('chalk'); const ParticleCache = require('../lib/particle-cache'); const PROVISIONING_PROGRESS = 1; const PROVISIONING_SUCCESS = 2; const PROVISIONING_FAILURE = 3; const CTRL_REQUEST_APP_CUSTOM = 10; const GET_AT_COMMAND_STATUS = 4; const LPA_PROFILE_ENABLE_ERROR = 'profile not in disabled state'; const TEST_ICCID = ['89000123456789012341', '89000123456789012358']; const TWILIO_ICCID_PREFIX = '8988307'; module.exports = class ESimCommands extends CLICommandBase { constructor() { // TODO: Bring ui class super(); spinnerMixin(this); this.serial = new SerialCommand(); this.lpa = null; this.inputJson = null; this.inputJsonData = null; this.outputFolder = null; this.downloadedProfiles = []; this.binaries = null; this.verbose = false; this.availableProvisioningData = new Set(); this.isTachyon = false; this.adbProcess = null; this.cache = new ParticleCache(); this.cacheKey = 'esim_provisioned_profiles'; } async provisionCommand(args) { this.verbose = true; // Adding a device selector (since Tachyon might have multiple serial ports) const device = await this.serial.whatSerialPortDidYouMean(); if (device.type === 'Tachyon') { this.isTachyon = true; } this._validateArgs(args, { lpa: true, input: true, output: false, binary: !this.isTachyon }); await this._generateAvailableProvisioningData(); await this.doProvision(device); } async bulkProvisionCommand(args) { console.log(chalk.red(`Do not use bulk mode for Tachyon${os.EOL}`)); this._validateArgs(args, { lpa: true, input: true, output: false, binary: true }); await this._generateAvailableProvisioningData(); const provisionedDevices = new Set(); setInterval(async () => { const devices = await this.serial.findDevices(); for (const device of devices) { if (!provisionedDevices.has(device.deviceId)) { const deviceId = device.deviceId; provisionedDevices.add(deviceId); console.log(`Device ${deviceId} connected`); // Do not await here, so that the next device can be processed this.doProvision(device); } } }, 1000); console.log('Ready to bulk provision. Connect devices to start. Press Ctrl-C to exit.'); } async _checkForTachyonDevice() { console.log(chalk.bold(`Ensure only one device is connected${os.EOL}`)); this.verbose = true; const device = await this.serial.whatSerialPortDidYouMean(); if (device.type !== 'Tachyon') { throw new Error('This command is only for Tachyon devices'); } this.isTachyon = true; return device; } async enableCommand(iccid) { await this._checkForTachyonDevice(); await this.doEnableTachyon(iccid); } async deleteCommand(args, iccid) { this._validateArgs(args, { lpa: true }); const device = await this._checkForTachyonDevice(); await this.doDelete(device, iccid); } async listCommand() { await this._checkForTachyonDevice(); await this.doList(); } // Populate the availableProvisioningData set with the indices of the input JSON data // If a profile is already provisioned (exists in the cache), it removes the corresponding index from the set. async _generateAvailableProvisioningData() { for (let i = 0; i < this.inputJsonData.provisioning_data.length; i++) { this.availableProvisioningData.add(i); } // Remove indices of already provisioned profiles from the availableProvisioningData set const provisionedProfiles = this.cache.get(this.cacheKey) || []; for (const profileSet of provisionedProfiles) { const index = this.inputJsonData.provisioning_data.findIndex((item) => { return _.isEqual(item.profiles, profileSet); }); if (index !== -1) { this.availableProvisioningData.delete(index); } } } /* eslint-disable max-statements, max-depth */ async doProvision(device) { let provisionOutputLogs = []; let eid = null; let timestamp = new Date().toISOString().replace(/:/g, '-'); let success = false; const outputJsonFile = path.join(this.outputFolder, `${this.isTachyon ? 'tachyon' : device.deviceId}_${timestamp}.json`); const processOutput = async (failedLogs = []) => { const logs = Array.isArray(failedLogs) ? failedLogs : [failedLogs]; provisionOutputLogs.push({ step: 'final_step', timestamp: new Date().toISOString().replace(/:/g, '-'), success: success ? 'success' : 'failed', details: { rawLogs: success ? ['Provisioning successful'] : ['Provisioning failed', ...logs], } }); await this._changeLed(device, success ? PROVISIONING_SUCCESS : PROVISIONING_FAILURE); this._addToJson(outputJsonFile, provisionOutputLogs.filter(Boolean)); }; try { const port = device.port; // Flash firmware and wait for AT to work const flashResp = await this._flashATPassThroughFirmware(device); provisionOutputLogs.push(flashResp); if (flashResp?.status === 'failed') { await processOutput(); return; } // Start particle-tachyon-ril through ADB for Tachyon const qlrilStep = await this._initializeQlril(); provisionOutputLogs.push(qlrilStep); if (qlrilStep?.status === 'failed') { await processOutput(); return; } // Get the EID const eidResp = await this._getEid(port); provisionOutputLogs.push(eidResp); if (eidResp.status === 'failed') { await processOutput(); return; } eid = (eidResp.details.eid).trim(); if (!this.force) { // If atleast one profile exists on the device, skip provisioning // and ensure that the profiles on the device are enabled const profileCmdResp = await this._checkForExistingProfiles(port); provisionOutputLogs.push(profileCmdResp); if (profileCmdResp.status === 'failed') { await processOutput(); return; } const existingProfiles = profileCmdResp.details.existingProfiles; if (existingProfiles.length > 0) { // remove profiles with test ICCID from existingProfiles to verify existingProfiles.forEach((profile, index) => { const iccid = profile.split('[')[1].split(',')[0].trim(); if (TEST_ICCID.includes(iccid)) { existingProfiles.splice(index, 1); } }); if (existingProfiles.length > 0) { const iccidsOnDevice = await this._getIccidOnDevice(port); const iccidsOnDeviceNotTest = iccidsOnDevice.filter((iccid) => !TEST_ICCID.includes(iccid)); const iccidToEnable = this._getIccidToEnable({ iccidList: iccidsOnDeviceNotTest }); if (iccidToEnable === null) { success = false; await processOutput('No profile found on the device to enable'); return; } for (const iccid of iccidsOnDeviceNotTest) { const enableResp = await this._enableProfile(port, iccid); provisionOutputLogs.push(enableResp); if (enableResp.status === 'failed') { await processOutput(); return; } const verifyIccidEnabledResp = await this._verifyIccidEnaled(port, iccidToEnable); provisionOutputLogs.push(verifyIccidEnabledResp); if (verifyIccidEnabledResp.status === 'failed') { await processOutput(); return; } } success = true; console.log(`${os.EOL}Profile ${iccidToEnable} enabled for EID ${eid}`); await processOutput(); return; } } } // Get the next available profile list from availableProvisioningData const profileResp = this._getProfiles(); provisionOutputLogs.push(profileResp); if (profileResp.status === 'failed') { await processOutput(); return; } const profilesToDownload = profileResp.details.profiles; const expectedIccids = profilesToDownload.map((profile) => profile.iccid); // Download each profile and update the JSON output await this._changeLed(device, PROVISIONING_PROGRESS); provisionOutputLogs.push(`${os.EOL}Downloading profiles...`); const downloadResp = await this._doDownload(profilesToDownload, port); provisionOutputLogs.push(downloadResp); if (downloadResp.status === 'failed') { await processOutput(); return; } const profilesMatch = await this._verifyAgainstListProfiles(port, expectedIccids); provisionOutputLogs.push(profilesMatch); if (!this.force) { if (profilesMatch.status === 'failed') { await processOutput(); return; } } const iccidToEnable = this._getIccidToEnable({ iccidList: profilesMatch.details.iccidsOnDevice }); if (iccidToEnable === null) { success = false; await processOutput('No profile found on the device to enable'); return; } const enableResp = await this._enableProfile(port, iccidToEnable); provisionOutputLogs.push(enableResp); if (enableResp.status === 'failed') { await processOutput(); return; } const verifyIccidEnabledResp = await this._verifyIccidEnaled(port, iccidToEnable); provisionOutputLogs.push(verifyIccidEnabledResp); if (verifyIccidEnabledResp.status === 'failed') { await processOutput(); return; } success = true; console.log(`${os.EOL}Provisioning complete for EID ${eid}`); await processOutput(); // Update the cache with the provisioned profile set const provisionedProfiles = this.cache.get(this.cacheKey) || []; provisionedProfiles.push(profilesToDownload); this.cache.set(this.cacheKey, provisionedProfiles); return; } catch (error) { await processOutput(error.message); } finally { this._exitQlril(); } } /* eslint-enable max-statements, max-depth */ async doEnableTachyon(iccid) { try { const { stdout } = await execa('adb', ['shell', 'particle-tachyon-ril', 'enable', iccid]); if (stdout.includes(`ICCID currently active: ${iccid}`)) { console.log(`ICCID ${iccid} enabled successfully`); } } catch (error) { console.error(`Failed to enable profiles: ${error.message}`); } } async doDelete(device, iccid) { try { const port = device.port; await this._initializeQlril(); const iccidsOnDevice = await this._getIccidOnDevice(port); if (!iccidsOnDevice.includes(iccid)) { console.log(`ICCID ${iccid} not found on the device or is a test ICCID`); return; } try { await execa(this.lpa, ['disable', iccid, `--serial=${port}`]); } catch (error) { // Ignore the error if the profile is already disabled } await execa(this.lpa, ['delete', iccid, `--serial=${port}`]); console.log('Profile deleted successfully'); } catch (error) { console.error(`Failed to delete profile: ${error.message}`); } finally { this._exitQlril(); } } async doList() { try { const { stdout } = await execa('adb', ['shell', 'particle-tachyon-ril', 'listProfiles']); const iccids = stdout .trim() .replace(/^\[/, '') .replace(/\]$/, '') .split(',') .map(iccid => iccid.trim()) .filter(Boolean); if (iccids.length > 0) { console.log(`Profiles found:${os.EOL}`); iccids.forEach(iccid => console.log(`\t- ${iccid}`)); } } catch (error) { console.error(`Failed to list profiles: ${error.message}`); } } _validateArgs(args, required) { this.lpa = args?.lpa; this.inputJson = args?.input; this.force = args?.force; if (this.inputJson) { try { this.inputJsonData = JSON.parse(fs.readFileSync(this.inputJson)); } catch (error) { throw new Error(`Invalid JSON in input file: ${error.message}`); } } this.outputFolder = args?.output || path.join(process.cwd(), 'esim_loading_logs'); if (!fs.existsSync(this.outputFolder)) { fs.mkdirSync(this.outputFolder); } this.binaries = args?.binary; for (const key in required) { if (required[key] && !args[key]) { throw new Error(`Missing required argument: ${key}`); } } } async _verifyAgainstListProfiles(port, expectedIccids) { const res = { step: 'verify_profiles_after_download', timestamp: new Date().toISOString().replace(/:/g, '-'), status: 'failed', details: { expectedIccids: expectedIccids, iccidsOnDevice: [], rawLogs: [] } }; const iccidsOnDevice = await this._getIccidOnDevice(port); // remove test ICCIDs from iccidsOnDeviceAfterDownload const iccidsOnDeviceNotTest = iccidsOnDevice.filter((iccid) => !TEST_ICCID.includes(iccid)); const equal = _.isEqual(_.sortBy(expectedIccids), _.sortBy(iccidsOnDeviceNotTest)); res.details.iccidsOnDevice = iccidsOnDevice; res.details.rawLogs.push(equal ? ['Profiles on device match the expected profiles'] : ['Profiles on device do not match the expected profiles']); res.status = equal ? 'success' : 'failed'; return res; } async _flashATPassThroughFirmware(device) { let status = 'failed'; let timestamp = new Date().toISOString().replace(/:/g, '-'); let logs = []; let fwPath = null; const logAndPush = (message) => { logs.push(message); if (this.verbose) { console.log(message); } }; const stepOutput = () => ({ step: 'flash_at_firmware', timestamp, status, details: { fwPath: fwPath, rawLogs: logs } }); if (this.isTachyon) { return null; } const platform = platformForId(device.specs.productId).name; try { // Locate the firmware binary logAndPush(`${os.EOL}Locating firmware for platform: ${platform}`); const firmware = fs.readdirSync(this.binaries).find(file => file.endsWith(`${platform}.bin`)); if (!firmware) { logAndPush(`No firmware binary found for platform: ${platform}`); return stepOutput(); } fwPath = path.join(this.binaries, firmware); logAndPush(`${os.EOL}Found firmware: ${fwPath}`); // Flash the binary logAndPush(`${os.EOL}Flashing firmware...`); await this._runFlashCommand(device, fwPath); logAndPush(`${os.EOL}Firmware flashed successfully. Waiting for the device to reboot...`); // FIXME: The control request for the AT-OK check would give 'IN CONTROL transfer failed' without this delay await utilities.delay(5000); logAndPush(`${os.EOL}Checking for the AT-OK to work...`); const atOkReceived = await this._verifyAtOk(device); if (!atOkReceived) { logAndPush('AT-OK not received after flashing firmware'); return stepOutput(); } logAndPush('AT-OK received after flashing firmware'); status = 'success'; return stepOutput(); } catch (error) { logs.push(`Failed to flash AT passthrough firmware: ${error.message}`); return stepOutput(); } } async _verifyAtOk(device) { let usbDevice; let atOkReceived = false; const timeout = Date.now() + 30000; // Set a 30-second timeout while (Date.now() < timeout && !atOkReceived) { try { if (!usbDevice?.isOpen) { usbDevice = await usbUtils.reopenDevice(device); } const resp = await usbDevice.sendControlRequest(CTRL_REQUEST_APP_CUSTOM, JSON.stringify(GET_AT_COMMAND_STATUS)); // Check response for AT-OK if (resp?.result === 0 && resp.data?.[0] === '1') { atOkReceived = true; } } catch (error) { // } if (!atOkReceived) { await utilities.delay(1000); } } if (usbDevice?.isOpen) { await usbDevice.close(); } return atOkReceived; } async _runFlashCommand(device, fwPath) { const flashCmdInstance = new FlashCommand(); await flashCmdInstance.flashLocal({ files: [device.deviceId, fwPath], applicationOnly: true, verbose: false, }); } async _initializeQlril() { let status = 'failed'; let timestamp = new Date().toISOString().replace(/:/g, '-'); let logs = []; let output = ''; const logAndPush = (message) => { logs.push(message); if (this.verbose) { console.log(message); } }; const stepOutput = () => ({ step: 'initialize_qlril', timestamp, status, details: { rawLogs: logs, output } }); if (!this.isTachyon) { return null; } logAndPush('Initalizing qlril app on Tachyon through adb'); this.adbProcess = execa('adb', ['shell', 'particle-tachyon-ril', '--port', '/dev/ttyGS2']); try { await new Promise((resolve, reject) => { const TACHYON_QLRIL_WAIT_TIMEOUT = 10000; this.adbProcess.stdout.on('data', (data) => { output += data.toString(); if (output.includes('AT Passthrough Mode Started')) { resolve(); } }); this.adbProcess.then(() => { reject(new Error('adb process ended early')); }, (error) => { reject(error); }); setTimeout(() => { reject(new Error('Timeout waiting for qlril app to start')); }, TACHYON_QLRIL_WAIT_TIMEOUT); }); status = 'success'; } catch (error) { logAndPush(`Error starting qlril app through adb: ${error.message}`); } return stepOutput(); } async _exitQlril() { if (this.adbProcess) { this.adbProcess.kill('SIGINT'); } } async _getEid(port) { let status = 'failed'; let timestamp = new Date().toISOString().replace(/:/g, '-'); let logs = []; let eid = null; let command = `${this.lpa} getEid --serial=${port}`; const logAndPush = (message) => { logs.push(message); if (this.verbose) { console.log(message); } }; const stepOutput = () => ({ step: 'get_eid', timestamp: timestamp, status: status, details: { eid: eid, command: command, rawLogs: logs } }); try { logAndPush(`${os.EOL}Getting EID from the device...`); const resEid = await execa(this.lpa, ['getEid', `--serial=${port}`]); const eidOutput = resEid.stdout; // Find the line starting with "EID: " and extract the EID eid = eidOutput .split('\n') .find((line) => line.startsWith('EID: ')) ?.split(' ')[1]; if (!eid) { logAndPush('EID not found in the output'); return stepOutput(); } logAndPush(`EID: ${eid}`); status = 'success'; return stepOutput(); } catch (error) { logAndPush(`${os.EOL}Failed to retrieve EID: ${error.message}`); return stepOutput(); } } // Check for profiles that are exsting on the device async _checkForExistingProfiles(port) { let logs = []; let existingProfiles = []; let status = 'failed'; let timestamp = new Date().toISOString().replace(/:/g, '-'); const logAndPush = (message) => { logs.push(message); if (this.verbose) { console.log(message); } }; const stepOutput = () => ({ step: 'check_existing_profiles', timestamp: timestamp, status: status, details: { existingProfiles: existingProfiles, rawLogs: logs } }); try { logAndPush(`${os.EOL}Checking for existing profiles...`); existingProfiles = await this._listProfiles(port); if (existingProfiles.length > 0) { logAndPush(`${os.EOL}Existing profiles found on the device:`); existingProfiles.forEach((profile) => logAndPush(`\t${profile}`)); } else { logAndPush(`${os.EOL}No existing profiles found on the device`); } status = 'success'; return stepOutput(); } catch (error) { logAndPush(`${os.EOL}Failed to check for existing profiles: ${error.message}`); return stepOutput(); } } // Use lpa tool's listProfiles command to get the profiles on the device async _listProfiles(port) { const resProfiles = await execa(this.lpa, ['listProfiles', `--serial=${port}`]); const profilesOutput = resProfiles.stdout; const profilesList = profilesOutput .split('\n') .filter((line) => line.match(/^\d+:\[\d+,\s(?:enabled|disabled),\s?\]\r?$/)); return profilesList; } async _getIccidOnDevice(port) { const profiles = await this._listProfiles(port); const iccids = profiles.map((line) => line.split('[')[1].split(',')[0].trim()); return iccids; } // Get the next available profile from availableProvisioningData // Once a profile is fetched, remove it from the set so other devices don't get the same profile _getProfiles() { const logs = []; let profiles = []; let status = 'failed'; const timestamp = new Date().toISOString().replace(/:/g, '-'); const stepOutput = () => ({ step: 'expected_profiles', timestamp: timestamp, status, details: { profiles: profiles, rawLogs: logs } }); if (!this.availableProvisioningData.size) { const message = 'No more profiles to provision'; console.log(message); logs.push(message); return stepOutput(); } const [index] = this.availableProvisioningData; profiles = this.inputJsonData.provisioning_data[index].profiles; this.availableProvisioningData.delete(index); status = 'success'; return stepOutput(); } // Download profiles to the device // Profiles are flashed one after another. // If any profile download fails, the process stops and the device is marked as failed async _doDownload(profiles, port) { const logs = []; const downloadedProfiles = []; let overallSuccess = false; const logAndPush = (messages) => { const logMessages = Array.isArray(messages) ? messages : [messages]; logMessages.forEach((msg) => { logs.push(msg); if (this.verbose) { console.log(msg); } }); }; const stepOutput = () => ({ step: 'download_profiles', timestamp: new Date().toISOString().replace(/:/g, '-'), status: overallSuccess ? 'success' : 'failed', details: { downloadedProfiles: downloadedProfiles, rawLogs: logs } }); for (const [index, profile] of profiles.entries()) { /* eslint-disable-next-line camelcase */ const { iccid, provider, smdp, matching_id } = profile; /* eslint-disable-next-line camelcase */ const rspUrl = `1$${smdp}$${matching_id}`; const startTime = Date.now(); logAndPush(`${os.EOL}${index + 1}. Downloading ${provider} profile from ${rspUrl}`); let result, command; try { command = `${this.lpa} download ${rspUrl} --serial=${port}`; result = await execa(this.lpa, ['download', rspUrl, `--serial=${port}`]); const timeTaken = ((Date.now() - startTime) / 1000).toFixed(2); if (result?.stdout.includes('Profile successfully downloaded')) { logAndPush(`${os.EOL}\tProfile ${provider} successfully downloaded in ${timeTaken} sec`); // logAndPush('\n\t LPA command result: ' + result?.stdout); overallSuccess = true; downloadedProfiles.push({ status: 'success', iccid: iccid, provider: provider, duration: timeTaken, command: command }); } else { logAndPush(`${os.EOL}\tProfile download failed for ${provider}`); logAndPush(`${os.EOL}\t LPA command result: `, result?.stdout); downloadedProfiles.push({ status: 'failed', iccid: iccid, provider: provider, duration: timeTaken, command: command }); break; } } catch (error) { const timeTaken = ((Date.now() - startTime) / 1000).toFixed(2); logAndPush(`\n\tProfile download failed for ${provider} with error: ${error.message}`); if (error.message.includes('The EID is not the same between reservation and request')) { // add the profile to the cache to settled as used const provisionedProfiles = this.cache.get(this.cacheKey) || []; // TODO (hmontero): currently we add all the profiles to the cache if they belong to the same set (change it to add only the failed one) provisionedProfiles.push(profiles); this.cache.set(this.cacheKey, provisionedProfiles); } downloadedProfiles.push({ status: 'failed', iccid: iccid, provider: provider, duration: timeTaken, command: command }); break; } } return stepOutput(); } _getIccidToEnable({ iccidList } = {}) { // get the first available Twilio ICCID and if not found, get the first available profile const twilioIccid = iccidList.find((iccid) => iccid.startsWith(TWILIO_ICCID_PREFIX)); return twilioIccid || iccidList[0] || null; } async _enableProfile(port, iccid) { const res = { step: 'enable_profile', timestamp: new Date().toISOString().replace(/:/g, '-'), status: 'failed', details: { iccid: iccid, rawLogs: [] } }; try { const enableProfileCmd = `${this.lpa} enable ${iccid} --serial=${port}`; const enableProfileResp = await execa(this.lpa, ['enable', `${iccid}`, `--serial=${port}`]); res.details.rawLogs.push(enableProfileResp.stdout); res.status = enableProfileResp.stdout.includes('Profile successfully enabled') ? 'success' : 'failed'; res.details.command = enableProfileCmd; return res; } catch (error) { if (error.message.includes(LPA_PROFILE_ENABLE_ERROR)) { res.details.rawLogs.push(`Profile already enabled: ${iccid}`); // if the profile is already enabled, we can consider it a success res.status = 'success'; return res; } throw error; } } async _verifyIccidEnaled(port, iccid) { const res = { step: 'verify_iccid_enabled', timestamp: new Date().toISOString().replace(/:/g, '-'), status: 'failed', details: { iccid: iccid, rawLogs: [] } }; const profilesOnDeviceAfterEnable = await this._listProfiles(port); const iccidString = profilesOnDeviceAfterEnable.find((line) => line.includes(iccid)); if (iccidString) { // check that you see the string 'enabled' if (iccidString.includes('enabled')) { res.status = 'success'; res.details.rawLogs.push(`ICCID ${iccid} enabled successfully`); } else { res.details.rawLogs.push(`ICCID ${iccid} not enabled`); } } res.details.rawLogs.push(...profilesOnDeviceAfterEnable); return res; } // Add the output logs to the output JSON file // If previous data exists, append to it _addToJson(jsonFile, data) { try { // Read and parse existing JSON data let existingJson = []; if (fs.existsSync(jsonFile)) { const existing = fs.readFileSync(jsonFile, 'utf-8'); existingJson = JSON.parse(existing); if (!Array.isArray(existingJson)) { console.log('Existing JSON data is not an array'); return; } } existingJson.push(data); // Write updated JSON back to the file with indentation fs.writeFileSync(jsonFile, JSON.stringify(existingJson, null, 4)); } catch (error) { console.error(`Failed to append data to JSON file: ${error.message}`); } } // Sends a control request to change the LED state async _changeLed(device, state) { if (this.isTachyon) { return; } let outputLogs = []; let usbDevice; try { usbDevice = await usbUtils.getOneUsbDevice({ idOrName: device.deviceId }); await usbDevice.sendControlRequest(CTRL_REQUEST_APP_CUSTOM, JSON.stringify(state)); outputLogs.push('Led state changed to ' + state); return { success: true, output: outputLogs }; } catch (err) { outputLogs.push(`Failed to change LED state: ${err.message}`); return { success: false, output: outputLogs }; } finally { if (usbDevice?.isOpen) { await usbDevice.close(); } } } };