#!/usr/bin/env node import { SerialConnector } from './serial-connector'; import TypedEmitter from "typed-emitter"; import EiSerialProtocol, { EiSerialDeviceConfig, EiSerialWifiNetwork, EiSerialWifiSecurity, EiStartSamplingResponse, EiSerialDone, EiSerialDoneBuffer, EiSnapshotResponse } from '../shared/daemon/ei-serial-protocol'; import inquirer from 'inquirer'; import request from 'request-promise'; import { MgmtInterfaceSampleRequestSample } from '../shared/MgmtInterfaceTypes'; import { Config, EdgeImpulseConfig } from './config'; import { findSerial } from './find-serial'; import { canFlashSerial } from './can-flash-serial'; import jpegjs from 'jpeg-js'; import { RemoteMgmt, RemoteMgmtDevice, RemoteMgmtDeviceSampleEmitter } from '../shared/daemon/remote-mgmt-service'; import { EventEmitter } from "tsee"; import { getCliVersion, initCliApp, setupCliApp } from './init-cli-app'; import { Mutex } from 'async-mutex'; import WebSocket from 'ws'; import encodeLabel from '../shared/encoding'; import { upload } from './make-image'; const TCP_PREFIX = '\x1b[32m[WS ]\x1b[0m'; const SERIAL_PREFIX = '\x1b[33m[SER]\x1b[0m'; const versionArgv = process.argv.indexOf('--version') > -1; const cleanArgv = process.argv.indexOf('--clean') > -1; const silentArgv = process.argv.indexOf('--silent') > -1; const devArgv = process.argv.indexOf('--dev') > -1; const verboseArgv = process.argv.indexOf('--verbose') > -1; const apiKeyArgvIx = process.argv.indexOf('--api-key'); const apiKeyArgv = apiKeyArgvIx !== -1 ? process.argv[apiKeyArgvIx + 1] : undefined; const baudRateArgvIx = process.argv.indexOf('--baud-rate'); const baudRateArgv = baudRateArgvIx !== -1 ? process.argv[baudRateArgvIx + 1] : undefined; const whichDeviceArgvIx = process.argv.indexOf('--which-device'); const whichDeviceArgv = whichDeviceArgvIx !== -1 ? Number(process.argv[whichDeviceArgvIx + 1]) : undefined; let configFactory: Config; let serial: SerialConnector | undefined; const cliOptions = { appName: 'Edge Impulse serial daemon', apiKeyArgv: apiKeyArgv, cleanArgv: cleanArgv, devArgv: devArgv, hmacKeyArgv: undefined, silentArgv: silentArgv, verboseArgv: verboseArgv, connectProjectMsg: 'To which project do you want to connect this device?', getProjectFromConfig: async (deviceId: string | undefined) => { if (!deviceId) return undefined; return await configFactory.getDaemonDevice(deviceId); } }; class SerialDevice extends (EventEmitter as new () => TypedEmitter<{ snapshot: (buffer: Buffer, filename: string) => void }>) implements RemoteMgmtDevice { private _config: EdgeImpulseConfig; private _serial: SerialConnector; private _serialProtocol: EiSerialProtocol; private _deviceConfig: EiSerialDeviceConfig; private _snapshotStream: { ee: TypedEmitter<{ snapshot: (b: Buffer, w: number, h: number) => void, error: (err: string) => void }>, stop: () => Promise<void> } | undefined; private _lastSnapshot: Date = new Date(0); private _snapshotMutex = new Mutex(); private _snapshotId = 0; private _waitingForSnapshotToStart = false; constructor(config: EdgeImpulseConfig, serialConnector: SerialConnector, serialProtocol: EiSerialProtocol, deviceConfig: EiSerialDeviceConfig) { super(); this._config = config; this._serial = serialConnector; this._serialProtocol = serialProtocol; this._deviceConfig = deviceConfig; } connected() { return this._serial.isConnected(); } async getDeviceId() { return this._deviceConfig.info.id; } getDeviceType() { return this._deviceConfig.info.type; } getSensors() { let sensors = Array.from(this._deviceConfig.sensors); // copy sensors so we don't modify in place if (this._deviceConfig.snapshot.hasSnapshot) { for (let s of this._deviceConfig.snapshot.resolutions) { sensors.push({ name: 'Camera (' + s.width + 'x' + s.height + ')', frequencies: [], maxSampleLengthS: 60000 }); } } return sensors; } isSnapshotStreaming() { return !!this._snapshotStream; } supportsSnapshotStreaming() { return this._deviceConfig.snapshot.supportsStreaming; } supportsSnapshotStreamingWhileCapturing() { return false; } async stopSnapshotStreamFromSignal() { if (!this._waitingForSnapshotToStart && !this._snapshotStream) { return; } if (this._waitingForSnapshotToStart) { // max 5 sec let max = Date.now() + 5000; while (1) { if (Date.now() > max) { return; } await new Promise((resolve) => setTimeout(resolve, 200)); if (this._snapshotStream) { break; } } } try { // wait for 1 snapshot to make sure we are fully attached await new Promise((resolve, reject) => { if (!this._snapshotStream) { throw new Error('No snapshot stream'); } this._snapshotStream.ee.once('snapshot', resolve); this._snapshotStream.ee.once('error', reject); }); await this.stopSnapshotStreaming(); } catch (ex2) { console.log(SERIAL_PREFIX, 'stopSnapshotStreamFromSignal failed', ex2); } } async startSnapshotStreaming() { if (this._snapshotStream) { throw new Error('Snapshot stream already in progress'); } console.log(SERIAL_PREFIX, 'Entering snapshot stream mode...'); this._waitingForSnapshotToStart = true; try { this._snapshotStream = await this._serialProtocol.startSnapshotStream('low'); } finally { this._waitingForSnapshotToStart = false; } this._snapshotStream.ee.on('error', err => { console.warn(SERIAL_PREFIX, 'Snapshot stream error:', err); this._snapshotStream = undefined; }); this._snapshotStream.ee.on('snapshot', async (buffer, width, height) => { const id = ++this._snapshotId; const release = await this._snapshotMutex.acquire(); // limit to 5 frames a second & no new frames should have come in... try { if (Date.now() - +this._lastSnapshot > 200 && id === this._snapshotId) { let depth = buffer.length / (width * height); if (depth !== 1 && depth !== 3) { throw new Error('Invalid length for snapshot, expected ' + (width * height) + ' or ' + (width * height * 3) + ' values, but got ' + buffer.length); } let frameData = Buffer.alloc(width * height * 4); let frameDataIx = 0; for (let ix = 0; ix < buffer.length; ix += depth) { if (depth === 1) { frameData[frameDataIx++] = buffer[ix]; // r frameData[frameDataIx++] = buffer[ix]; // g frameData[frameDataIx++] = buffer[ix]; // b frameData[frameDataIx++] = 255; } else { frameData[frameDataIx++] = buffer[ix + 0]; // r frameData[frameDataIx++] = buffer[ix + 1]; // g frameData[frameDataIx++] = buffer[ix + 2]; // b frameData[frameDataIx++] = 255; } } let jpegImageData = jpegjs.encode({ data: frameData, width: width, height: height, }, 80); this.emit('snapshot', jpegImageData.data, ''); this._lastSnapshot = new Date(); } } catch (ex) { console.warn('Failed to handle snapshot', ex); } finally { release(); } }); await new Promise((resolve, reject) => { if (!this._snapshotStream) { throw new Error('No snapshot stream'); } this._snapshotStream.ee.once('snapshot', resolve); this._snapshotStream.ee.once('error', reject); }); } async stopSnapshotStreaming() { if (!this._snapshotStream) { return; } console.log(SERIAL_PREFIX, 'Stopping snapshot stream mode...'); try { await this._snapshotStream.stop(); this._snapshotStream = undefined; console.log(SERIAL_PREFIX, 'Stopped snapshot stream mode'); } catch (ex) { console.log(SERIAL_PREFIX, 'Stopped snapshot stream mode failed', ex); throw ex; } } async beforeConnect() { // if our connection dropped because the device is now connected over wifi // (only 1 device ID can connect at the same time) // then we don't want to reconnect naturally this._deviceConfig = await this._serialProtocol.getConfig(); if (this._deviceConfig.management.connected) { console.log(SERIAL_PREFIX, 'Device is connected over WiFi to remote management API, ' + 'no need to run the daemon. Exiting...'); process.exit(1); } else { if (!this._deviceConfig.upload.apiKey || !this._deviceConfig.info.id || !this._deviceConfig.info.type) { console.error(SERIAL_PREFIX, 'Cannot connect to remote management API using daemon, failed to read ' + 'apiKey, deviceId or deviceType from device. Restarting your development board might help. ' + 'Retrying in 5 seconds...'); setTimeout(serial_connect, 5000); return; } console.log(SERIAL_PREFIX, 'Device is not connected to remote management API, ' + 'will use daemon'); } } setDeviceConfig(deviceConfig: EiSerialDeviceConfig) { this._deviceConfig = deviceConfig; } async sampleRequest(data: MgmtInterfaceSampleRequestSample, ee: RemoteMgmtDeviceSampleEmitter) { let s = data; if (!this._deviceConfig.upload.apiKey) { throw new Error('Device does not have API key'); } if (s.sensor?.startsWith('Camera (')) { let [ width, height ] = s.sensor.replace('Camera (', '') .replace(')', '').split('x').map(n => Number(n)); if (isNaN(width) || isNaN(height)) { throw new Error('Could not parse camera resolution ' + s.sensor); } console.log(SERIAL_PREFIX, 'Taking snapshot...'); let sampleReq = await this._serialProtocol.takeSnapshot(width, height); function waitForSamplingDone(ee2: EiSnapshotResponse): Promise<Buffer> { return new Promise<Buffer>((resolve, reject) => { ee2.on('done', (ev) => resolve(ev)); ee2.on('error', reject); }); } sampleReq.on('started', () => { ee.emit('started'); }); sampleReq.on('readingFromDevice', progressPercentage => { ee.emit('reading', progressPercentage); }); let snapshot = await waitForSamplingDone(sampleReq); ee.emit('processing'); let depth = snapshot.length / (width * height); if (depth !== 1 && depth !== 3) { throw new Error('Invalid length for snapshot, expected ' + (width * height) + ' or ' + (width * height * 3) + ' values, but got ' + snapshot.length); } let frameData = Buffer.alloc(width * height * 4); let frameDataIx = 0; for (let ix = 0; ix < snapshot.length; ix += depth) { if (depth === 1) { frameData[frameDataIx++] = snapshot[ix]; // r frameData[frameDataIx++] = snapshot[ix]; // g frameData[frameDataIx++] = snapshot[ix]; // b frameData[frameDataIx++] = 255; } else { frameData[frameDataIx++] = snapshot[ix + 0]; // r frameData[frameDataIx++] = snapshot[ix + 1]; // g frameData[frameDataIx++] = snapshot[ix + 2]; // b frameData[frameDataIx++] = 255; } } let jpegImageData = jpegjs.encode({ data: frameData, width: width, height: height, }, 100); let url = this._config.endpoints.internal.ingestion + s.path; console.log(SERIAL_PREFIX, 'Uploading to', url); ee.emit('uploading'); await upload({ filename: s.label + '.jpg', allowDuplicates: false, apiKey: this._deviceConfig.upload.apiKey, buffer: jpegImageData.data, category: s.path.indexOf('training') ? 'training' : 'testing', config: this._config, label: { label: s.label, type: 'label' }, boundingBoxes: [], }); console.log(SERIAL_PREFIX, 'Uploading to', url, 'OK'); } else { await this._serialProtocol.setSampleSettings(s.label, s.interval, s.length, s.hmacKey); await this._serialProtocol.setUploadSettings(this._deviceConfig.upload.apiKey, s.path); console.log(SERIAL_PREFIX, 'Configured upload settings'); function waitForSamplingDone(ee2: EiStartSamplingResponse): Promise<EiSerialDone> { return new Promise((resolve, reject) => { ee2.on('done', (ev) => resolve(ev)); ee2.on('error', reject); }); } let sampleReq = this._serialProtocol.startSampling(s.sensor, s.length + 3000); sampleReq.on('samplingStarted', () => { ee.emit('started'); }); sampleReq.on('processing', () => { ee.emit('processing'); }); sampleReq.on('readingFromDevice', (progressPercentage) => { ee.emit('reading', progressPercentage); }); sampleReq.on('uploading', () => { ee.emit('uploading'); }); let deviceResponse = await waitForSamplingDone(sampleReq); if (deviceResponse && deviceResponse.file) { ee.emit('uploading'); let url = this._config.endpoints.internal.ingestion + s.path; try { console.log(SERIAL_PREFIX, 'Uploading to', url); if (deviceResponse.file && deviceResponse.file.indexOf(Buffer.from('Ref-BINARY-', 'ascii')) > -1) { let dr = <EiSerialDoneBuffer>deviceResponse; await request.post(url, { headers: { 'x-api-key': this._deviceConfig.upload.apiKey, 'x-file-name': encodeLabel(deviceResponse.filename), 'x-label': encodeLabel(dr.label), 'Content-Type': 'application/octet-stream' }, body: deviceResponse.file, encoding: 'binary' }); } else { await request.post(url, { headers: { 'x-api-key': this._deviceConfig.upload.apiKey, 'x-file-name': encodeLabel(deviceResponse.filename), 'Content-Type': 'application/cbor' }, body: deviceResponse.file, encoding: 'binary' }); await this._serialProtocol.unlink(deviceResponse.onDeviceFileName); } console.log(SERIAL_PREFIX, 'Uploading to', url, 'OK'); } catch (ex2) { let ex = <Error>ex2; console.error(SERIAL_PREFIX, 'Failed to upload to', url, ex); } } } } } // tslint:disable-next-line:no-floating-promises (async () => { try { if (versionArgv) { console.log(getCliVersion()); process.exit(0); } let baudRate = baudRateArgv ? Number(baudRateArgv) : 115200; if (isNaN(baudRate)) { console.error('Invalid value for --baud-rate (should be a number)'); process.exit(1); } const initRes = await initCliApp(cliOptions); configFactory = initRes.configFactory; const config = initRes.config; console.log('Endpoints:'); console.log(' Websocket:', config.endpoints.internal.ws); console.log(' API: ', config.endpoints.internal.api); console.log(' Ingestion:', config.endpoints.internal.ingestion); console.log(''); let deviceId = await findSerial(whichDeviceArgv); await connectToSerial(config, deviceId, baudRate, (cleanArgv || apiKeyArgv) ? true : false); } catch (ex) { console.error('Failed to set up serial daemon', ex); } })(); function sleep(ms: number) { return new Promise((res) => setTimeout(res, ms)); } async function connectToSerial(eiConfig: EdgeImpulseConfig, deviceId: string, baudRate: number, clean: boolean) { // if this is set it means we have a connection let config: EiSerialDeviceConfig | undefined; let remoteMgmt: RemoteMgmt | undefined; serial = new SerialConnector(deviceId, baudRate, cliOptions.verboseArgv); const serialProtocol = new EiSerialProtocol(serial); serial.on('error', err => { console.log(SERIAL_PREFIX, 'Serial error - retrying in 5 seconds', err); if (remoteMgmt) { remoteMgmt.disconnect(); } setTimeout(serial_connect, 5000); }); serial.on('close', () => { console.log(SERIAL_PREFIX, 'Serial closed - retrying in 5 seconds'); if (remoteMgmt) { remoteMgmt.disconnect(); } setTimeout(serial_connect, 5000); }); // serial.on('data', data => { // console.log(SERIAL_PREFIX, 'serial data', data, data.toString('ascii')); // // client.write(data); // }); async function connectLogic() { if (!serial || !serial.isConnected()) return setTimeout(serial_connect, 5000); config = undefined; console.log(SERIAL_PREFIX, 'Serial is connected, trying to read config...'); try { await serialProtocol.onConnected(); if (clean) { console.log(SERIAL_PREFIX, 'Clearing configuration'); await serialProtocol.clearConfig(); console.log(SERIAL_PREFIX, 'Clearing configuration OK'); } config = await serialProtocol.getConfig(); console.log(SERIAL_PREFIX, 'Retrieved configuration'); console.log(SERIAL_PREFIX, 'Device is running AT command version ' + config.info.atCommandVersion.major + '.' + config.info.atCommandVersion.minor + '.' + config.info.atCommandVersion.patch); // we support devices with version 1.7.x and lower if (config.info.atCommandVersion.major > 1 || config.info.atCommandVersion.minor > 7) { console.error(SERIAL_PREFIX, 'Unsupported AT command version running on this device. Supported version is 1.7.x and lower, ' + 'but found ' + config.info.atCommandVersion.major + '.' + config.info.atCommandVersion.minor + '.' + config.info.atCommandVersion.patch + '.'); console.error(SERIAL_PREFIX, 'Update the Edge Impulse CLI tools (via `npm update edge-impulse-cli -g`) ' + 'to continue.'); process.exit(1); } let serialId = await serial.getMACAddress() || undefined; const { projectId, devKeys } = await setupCliApp(configFactory, eiConfig, cliOptions, serialId); if (serialId) { await configFactory.storeDaemonDevice(serialId, { projectId: projectId }); } let setupRes = silentArgv ? { hasWifi: true, setupOK: true, didSetMgmt: false } : await setupWizard(eiConfig, serialProtocol, config, devKeys); if (setupRes.setupOK) { if (((!config.management.connected) || setupRes.didSetMgmt) && setupRes.hasWifi && !silentArgv) { console.log(SERIAL_PREFIX, 'Verifying whether device can connect to remote management API...'); await sleep(5000); // Q: is this enough? } config = await serialProtocol.getConfig(); if (config.management.connected) { console.log(SERIAL_PREFIX, 'Device is connected over WiFi to remote management API, ' + 'no need to run the daemon. Exiting...'); process.exit(1); } } else { config = await serialProtocol.getConfig(); } if (config.management.lastError) { console.log(SERIAL_PREFIX, 'Remote management connection error', config.management.lastError); } if (!remoteMgmt) { const device = new SerialDevice(eiConfig, serial, serialProtocol, config); remoteMgmt = new RemoteMgmt(projectId, devKeys, eiConfig, device, url => new WebSocket(url), async (currName) => { let nameDevice = <{ nameDevice: string }>await inquirer.prompt([{ type: 'input', message: 'What name do you want to give this device?', name: 'nameDevice', default: currName }]); return nameDevice.nameDevice; }); let firstExit = true; const onSignal = async () => { if (!firstExit) { process.exit(1); } else { console.log(SERIAL_PREFIX, 'Received stop signal, stopping application... ' + 'Press CTRL+C again to force quit.'); firstExit = false; try { await device.stopSnapshotStreamFromSignal(); process.exit(0); } catch (ex2) { let ex = <Error>ex2; console.log(SERIAL_PREFIX, 'Failed to stop snapshot streaming', ex.message); } process.exit(1); } }; process.on('SIGHUP', onSignal); process.on('SIGINT', onSignal); } await remoteMgmt.connect(); } catch (ex) { console.error(SERIAL_PREFIX, 'Failed to get info off device', ex); if (await canFlashSerial(deviceId)) { // flashed... console.log(SERIAL_PREFIX, 'Waiting for board to restart...'); if (process.platform === 'linux') { setTimeout(connectLogic, 20000); } else if (process.platform !== 'darwin') { setTimeout(connectLogic, 2000); } // macOS does it themselves } else { setTimeout(connectLogic, 5000); } } } serial.on('connected', connectLogic); console.log(SERIAL_PREFIX, 'Connecting to', deviceId); // tslint:disable-next-line:no-floating-promises serial_connect(); } async function serial_connect() { if (!serial) return; try { await serial.connect(); } catch (ex2) { let ex = <Error>ex2; console.error(SERIAL_PREFIX, 'Failed to connect to', serial.getPath(), 'retrying in 5 seconds', ex.message || ex); if (ex.message && ex.message.indexOf('Permission denied')) { console.error(SERIAL_PREFIX, 'You might need `sudo` or set up the right udev rules'); } setTimeout(serial_connect, 5000); } } let setupWizardRan = false; async function setupWizard(eiConfig: EdgeImpulseConfig, serialProtocol: EiSerialProtocol, deviceConfig: EiSerialDeviceConfig, devKeys: { apiKey: string, hmacKey: string }) : Promise<{ setupOK: boolean, hasWifi: boolean, didSetMgmt: boolean }> { if (!serial) { throw new Error('serial is null'); } let credentials: { token?: string, askWifi?: boolean } = { }; if (deviceConfig.wifi.connected && deviceConfig.management.connected && deviceConfig.upload.apiKey && deviceConfig.sampling.hmacKey && deviceConfig.management.url === eiConfig.endpoints.device.ws && deviceConfig.upload.host === eiConfig.endpoints.device.ingestion) { return { hasWifi: true, setupOK: true, didSetMgmt: false }; } let ret = { setupOK: false, hasWifi: deviceConfig.wifi.connected, didSetMgmt: false }; try { // empty mac address and AT command version >= 1.4? if (deviceConfig.info.id === '00:00:00:00:00:00' && deviceConfig.info.atCommandVersion.major >= 1 && deviceConfig.info.atCommandVersion.minor >= 4 && serial) { let mac = await serial.getMACAddress(); if (mac) { process.stdout.write('Setting device ID...'); await serialProtocol.setDeviceID(mac); process.stdout.write(' OK\n'); deviceConfig.info.id = mac; } } if (deviceConfig.upload.host !== eiConfig.endpoints.device.ingestion) { if (eiConfig.setDeviceUpload) { process.stdout.write('Setting upload host in device...'); await serialProtocol.setUploadHost(eiConfig.endpoints.device.ingestion); process.stdout.write(' OK\n'); } } if (eiConfig.setDeviceUpload && deviceConfig.management.url !== eiConfig.endpoints.device.ws) { process.stdout.write('Configuring remote management settings...'); await serialProtocol.setRemoteManagement(eiConfig.endpoints.device.ws); process.stdout.write(' OK\n'); ret.didSetMgmt = true; } if (deviceConfig.upload.apiKey !== devKeys.apiKey) { process.stdout.write('Configuring API key in device...'); let uploadEndpoint = '/api/training/data'; if (deviceConfig.upload.path !== '' && deviceConfig.upload.path !== 'b') { uploadEndpoint = deviceConfig.upload.path; } await serialProtocol.setUploadSettings(devKeys.apiKey || '', uploadEndpoint); process.stdout.write(' OK\n'); } if ((!deviceConfig.sampling.hmacKey || deviceConfig.sampling.hmacKey === 'please-set-me') && devKeys.hmacKey) { process.stdout.write('Configuring HMAC key in device...'); await serialProtocol.setSampleSettings(deviceConfig.sampling.label, deviceConfig.sampling.interval, deviceConfig.sampling.length, devKeys.hmacKey); process.stdout.write(' OK\n'); } if (!deviceConfig.wifi.connected && credentials.askWifi !== false && deviceConfig.wifi.present && !setupWizardRan) { let inqSetup = await inquirer.prompt([{ type: 'confirm', message: 'WiFi is not connected, do you want to set up a WiFi network now?', default: true, name: 'setupWifi' }]); if (inqSetup.setupWifi) { process.stdout.write('Scanning WiFi networks...'); let wifi = await serialProtocol.scanWifi(); process.stdout.write(' OK\n'); let inqWifi = await inquirer.prompt([{ type: 'list', choices: wifi.map(w => ({ name: w.line, value: w })), message: 'Select WiFi network', name: 'wifi', pageSize: 20 }]); let network = <EiSerialWifiNetwork>inqWifi.wifi; let pass = ''; if (network.security !== EiSerialWifiSecurity.EI_SECURITY_NONE) { let inqPass = <{ wifiPass: string }>await inquirer.prompt([{ type: 'input', message: 'Enter password for network "' + network.ssid + '"', name: 'wifiPass' }]); pass = inqPass.wifiPass; } process.stdout.write('Connecting to "' + network.ssid + '"...'); await serialProtocol.setWifi(network.ssid, pass, network.security); process.stdout.write(' OK\n'); ret.hasWifi = true; } } setupWizardRan = true; ret.setupOK = true; } catch (ex) { console.error('Error while setting up device', ex); } return ret; }