diff --git a/cli/cli.ts b/cli/cli.ts index 16d1d3776..dc18b0c98 100644 --- a/cli/cli.ts +++ b/cli/cli.ts @@ -13,6 +13,7 @@ import type { CommandDefinition } from './commands/CommandDefinition' import { configureDeviceCommand } from './commands/configure-device.js' import { configureCommand } from './commands/configure.js' import { createFakeNrfCloudAccountDeviceCredentials } from './commands/createFakeNrfCloudAccountDeviceCredentials.js' +import { importDevicesCommand } from './commands/import-devices.js' import { initializeNRFCloudAccountCommand } from './commands/initialize-nrfcloud-account.js' import { logsCommand } from './commands/logs.js' import { registerDeviceCommand } from './commands/register-device.js' @@ -101,6 +102,12 @@ const CLI = async ({ isCI }: { isCI: boolean }) => { db, devicesTableName: outputs.devicesTableName, }), + importDevicesCommand({ + db, + devicesTableName: outputs.devicesTableName, + ssm, + stackName: STACK_NAME, + }), ) } catch (error) { console.warn(chalk.yellow('⚠️'), chalk.yellow((error as Error).message)) diff --git a/cli/commands/import-devices.ts b/cli/commands/import-devices.ts new file mode 100644 index 000000000..97692322a --- /dev/null +++ b/cli/commands/import-devices.ts @@ -0,0 +1,110 @@ +import { type DynamoDBClient } from '@aws-sdk/client-dynamodb' +import type { SSMClient } from '@aws-sdk/client-ssm' +import chalk from 'chalk' +import { readFile } from 'node:fs/promises' +import os from 'node:os' +import { table } from 'table' +import { registerDevice } from '../../devices/registerDevice.js' +import { apiClient } from '../../nrfcloud/apiClient.js' +import { getAPISettings } from '../../nrfcloud/settings.js' +import type { CommandDefinition } from './CommandDefinition.js' + +export const importDevicesCommand = ({ + ssm, + db, + devicesTableName, + stackName, +}: { + ssm: SSMClient + db: DynamoDBClient + devicesTableName: string + stackName: string +}): CommandDefinition => ({ + command: 'import-devices ', + action: async (model, provisioningList) => { + const devices: [imei: string, fingerprint: string, publicKey: string][] = ( + await readFile(provisioningList, 'utf-8') + ) + .trim() + .split('\r\n') + .map((s) => + s.split(';').map((s) => s.replace(/^"/, '').replace(/"$/, '')), + ) + .slice(1) + .map( + ([imei, _, fingerprint, publicKey]) => + [imei, fingerprint, publicKey] as [string, string, string], + ) + + console.log( + table([ + ['Fingerprint', 'Device ID'], + ...devices.map(([imei, fingerprint]) => [ + chalk.green(fingerprint), + chalk.blue(imei), + ]), + ]), + ) + + const { apiKey, apiEndpoint } = await getAPISettings({ + ssm, + stackName, + })() + + const client = apiClient({ + endpoint: apiEndpoint, + apiKey, + }) + + const registration = await client.registerDevices( + devices.map(([imei, _, publicKey]) => { + const deviceId = `oob-${imei}` + const certPem = publicKey.replace(/\\n/g, os.EOL) + return { + deviceId, + subType: model.replace(/[^0-9a-z-]/gi, '-'), + tags: [model.replace(/[^0-9a-z-]/gi, ':')], + certPem, + } + }), + ) + + if ('error' in registration) { + console.error(registration.error.message) + process.exit(1) + } + + console.log(chalk.green(`Registered devices with nRF Cloud`)) + console.log( + chalk.yellow.dim(`Bulk ops ID:`), + chalk.yellow(registration.bulkOpsRequestId), + ) + + for (const [imei, fingerprint] of devices) { + const deviceId = `oob-${imei}` + + const res = await registerDevice({ + db, + devicesTableName, + })({ + id: deviceId, + model, + fingerprint, + }) + if ('error' in res) { + console.error( + chalk.red(`Failed to store ${deviceId} device fingerprint!`), + ) + console.error(res.error.message) + } else { + console.log( + chalk.green( + `Registered device ${deviceId} with fingerprint ${fingerprint}`, + ), + chalk.cyan(fingerprint), + ) + } + } + }, + help: 'Import factory provisioned devices', +}) diff --git a/cli/commands/register-simulator-device.ts b/cli/commands/register-simulator-device.ts index efb2f51d7..117ef8a6b 100644 --- a/cli/commands/register-simulator-device.ts +++ b/cli/commands/register-simulator-device.ts @@ -185,10 +185,11 @@ export const registerSimulatorDeviceCommand = ({ process.exit(1) } - if ('success' in registration && registration.success === false) { - console.error(chalk.red(`Registration failed`)) - process.exit(1) - } + console.log(chalk.green(`Registered devices with nRF Cloud`)) + console.log( + chalk.yellow.dim(`Bulk ops ID:`), + chalk.yellow(registration.bulkOpsRequestId), + ) console.log( chalk.green(`Registered device with nRF Cloud`), diff --git a/cli/commands/show-device.ts b/cli/commands/show-device.ts index 475c17aa7..06cc27fba 100644 --- a/cli/commands/show-device.ts +++ b/cli/commands/show-device.ts @@ -48,10 +48,22 @@ export const showDeviceCommand = ({ }) const maybeNrfCloudDevice = await client.getDevice(device.id) + const account = await client.account() + if ('error' in account) { + console.error(chalk.red('⚠️'), '', chalk.red(account.error.message)) + process.exit(1) + } console.log( table([ - ['Fingerprint', 'Device ID', 'Model', 'nRF Cloud', 'Connected'], + [ + 'Fingerprint', + 'Device ID', + 'Model', + 'nRF Cloud', + 'Connected', + 'nRF Cloud Account', + ], [ chalk.green(device.fingerprint), chalk.blue(device.id), @@ -60,8 +72,11 @@ export const showDeviceCommand = ({ 'device' in maybeNrfCloudDevice && maybeNrfCloudDevice.device?.state?.reported?.connection?.status === 'connected' - ? chalk.green('✅') - : chalk.red('⚠️'), + ? chalk.green('Yes') + : chalk.red('No'), + `${chalk.cyanBright(account.account.team.name)} ${chalk.cyan.dim( + account.account.team.tenantId, + )}`, ], ]), ) diff --git a/nrfcloud/apiClient.ts b/nrfcloud/apiClient.ts index 25063846c..feaaa560b 100644 --- a/nrfcloud/apiClient.ts +++ b/nrfcloud/apiClient.ts @@ -42,6 +42,10 @@ type Page = { type AccountInfo = { mqttEndpoint: string // e.g. 'mqtt.nrfcloud.com' mqttTopicPrefix: string // e.g. 'prod/a0673464-e4e1-4b87-bffd-6941a012067b/', + team: { + tenantId: string // e.g. 'bbfe6b73-a46a-43ad-94bd-8e4b4a7847ce', + name: string // e.g. 'hello.nrfcloud.com' + } } export const apiClient = ({ endpoint, @@ -76,7 +80,7 @@ export const apiClient = ({ // A unique ES256 X.509 certificate in PEM format, wrapped in double quotes (to allow for line breaks in CSV) /^-{5}BEGIN CERTIFICATE-{5}(\r\n|\r|\n)([^-]+)(\r\n|\r|\n)-{5}END CERTIFICATE-{5}(\r\n|\r|\n)$/ certPem: string }[], - ) => Promise<{ error: Error } | { success: boolean }> + ) => Promise<{ error: Error } | { bulkOpsRequestId: string }> account: () => Promise< | { error: Error } | { @@ -156,12 +160,17 @@ export const apiClient = ({ const res = await registrationResult.json() - if ('bulkOpsRequestId' in res) return { success: true } + if ('bulkOpsRequestId' in res) + return { bulkOpsRequestId: res.bulkOpsRequestId } if ('code' in res && 'message' in res) - return { error: new Error(`${res.message} (${res.code})`) } + return { + error: new Error( + `${res.message} (${res.code}): ${JSON.stringify(res)}`, + ), + } - return { success: false } + return { error: new Error(`Import failed: ${JSON.stringify(res)}`) } }, account: async () => fetch(`${slashless(endpoint)}/v1/account`, {