UNPKG

snips-sam

Version:

The Snips Assistant Manager

431 lines (379 loc) 19 kB
import fsExtra = require('fs-extra'); import nodeSsh = require('node-ssh'); import os = require('os'); import path = require('path'); import shelljs = require('shelljs'); import { AudioDevice } from '../utils/audiodevice'; import { Credentials, Config } from '../utils/config'; const snipsRSAKeyName = 'id_rsa_snips'; const snipsRSAKeyFilePath = os.homedir() + '/.ssh/' + snipsRSAKeyName; export class SSHService { private ssh: any; credentials: Credentials; constructor() { this.ssh = new nodeSsh(); } public connect = async (): Promise<any> => { const credentials: Credentials | void = await Config.getCredentials() .catch((error) => { throw new Error('No credentials found, reason: ' + error.message); }); if (credentials === undefined) { throw new Error('No credentials found.'); } return this.connectInternal(credentials) .catch((error) => { this.disconnect(); throw new Error('Error connecting to your device, reason: ' + error.message); }); } public newConnection = async (credentials: Credentials, password: string, onConnected: () => void, onSSHCopyId: () => void, onCredentialsSaved: () => void): Promise<void> => { await this.connectInternal(credentials, password) .then(success => onConnected()) .catch((e) => { throw new Error('Failed to connect to your device, reason: ' + e.message); }); await this.sshCopyId(credentials.hostname, credentials.username) .then(s => onSSHCopyId()) .catch((e) => { this.disconnect(); throw new Error('Failed to copy your SSH public key, reason: ' + e.message); }); await Config.saveCredentialsOnDisk(credentials) .then(s => onCredentialsSaved()) .catch((e) => { this.disconnect(); throw new Error('Failed to save credentials: ' + e.message); }); return; } private connectInternal = async (credentials: Credentials, password?: string): Promise<any> => { this.credentials = credentials; if (password != null) { return this.ssh.connect({ password, host: this.credentials.hostname, username: this.credentials.username, }); } return this.ssh.connect({ host: this.credentials.hostname, username: this.credentials.username, privateKey: snipsRSAKeyFilePath, }); } public disconnect = async (): Promise<void> => { await this.ssh.dispose(); } public isAssistantInstalled = async (): Promise<boolean> => { return this.ssh.exec(`if [ -d /usr/share/snips/assistant ]; then echo 'true'; else echo 'false'; fi`) .then((success) => { return success === 'true' ? true : false; }); } public getOSVersion = async (): Promise<string> => { return this.ssh.exec('cat /etc/os-release') .then((success) => { const osVersion = success.split('\n')[0].replace('PRETTY_NAME="', '').replace('"', ''); if (osVersion === null || osVersion.length === 0) { return 'Unknown'; } return osVersion; }) .catch((err) => { return 'Unknown'; }); } public installSnipsPlatformDemo = async (listener: (output: string) => void): Promise<string> => { return this.sshCommandLog(`sudo apt-get install -y snips-platform-demo`, listener); } public speakerTest = async (): Promise<string> => { return this.ssh.exec(`speaker-test -c2 -l2 -twav`); } public static fetchBootHostname = async (ips: string[]): Promise<{ ip: string, hostname: string, snipsHostname: string }[]> => { return await Promise.all( ips.map(async (ip) => { const newConnection = new nodeSsh(); const hostnames = await newConnection.connect({ host: ip, username: 'pi', password: 'raspberry', }) .then(async (success) => { let snipsHostname: string = await newConnection.exec('cat /boot/hostname').catch(e => ''); const hostname: string = await newConnection.exec('cat /etc/hostname').catch(e => ''); if (snipsHostname.includes('No such file or directory')) { snipsHostname = ''; } return { hostname, snipsHostname }; }) .catch((failure) => { return { snipsHostname: '', hostname: '' }; }); return { ip, hostname: hostnames.hostname, snipsHostname: hostnames.snipsHostname }; }), ); } public getCaptureLevel = async (card: number | undefined = 1): Promise<string> => { return this.ssh.exec(`amixer -c ${card} cget name='Mic Capture Volume'`); } public setCaptureLevel = async (card: number | undefined = 1, level: number | undefined = 80): Promise<string> => { return this.ssh.exec(`amixer -c ${card} cset name='Mic Capture Volume' ${level}%`); } public getVolumeLevel = async (card: number | undefined = 0): Promise<string> => { return this.ssh.exec(`amixer -c ${card} cget name='PCM Playback Volume'`); } public setVolumeLevel = async (card: number | undefined = 0, level: number | undefined = 90): Promise<string> => { return this.ssh.exec(`amixer -c ${card} cset name='PCM Playback Volume' ${level}%`); } public recordAudio = async (name: string): Promise<string> => { return this.ssh.exec(`arecord -f cd ${name}.wav`); } public stopRecording = async (): Promise<string> => { return this.ssh.exec(`pkill arecord`); } public listCaptureDevices = async (): Promise<AudioDevice[]> => { return this.ssh.exec(`arecord -l | grep -E '^[^ ][^**]'`) .then((success) => { // card 0: ALSA [bcm2835 ALSA], device 0: bcm2835 ALSA [bcm2835 ALSA] const devices = String(success).split('\n'); if (devices.length === 0) { throw new Error('No capture devices found. Check if your mic is correctly plugged.'); } try { return devices.map(device => new AudioDevice(device)); } catch (e) { throw new Error('Error reading the information of the audio device: ' + e.message); } }); } public listOuputDevices = async (): Promise<AudioDevice[]> => { return this.ssh.exec(`aplay -l | grep -E '^[^ ][^**]'`) .then((success) => { const devices = String(success).split('\n'); if (devices.length === 0) { throw new Error('No output devices found. This should not happen, on your device, run \narecord -l'); } try { return devices.map(device => new AudioDevice(device)); } catch (e) { throw new Error('Error reading the information of the audio device: ' + e.message); } }); } public launchSnipsWatch = async (listener: (string) => void): Promise<string> => { return this.sshCommandLog(`snips-watch -v`, listener); } public journalctlLogs = async (listener: (string) => void): Promise<string> => { return this.sshCommandLog(`watch journalctl -f -u "snips*"`, listener); } public snipsServicesSystemctlStatus = async (): Promise<{ name: string, active: boolean }[]> => { const hotword = await this.ssh.exec(`systemctl is-active snips-hotword.service >/dev/null 2>&1 && echo YES || echo NO`) .then((success) => { return success === 'YES' ? true : false; }); const queries = await this.ssh.exec(`systemctl is-active snips-queries.service >/dev/null 2>&1 && echo YES || echo NO`) .then((success) => { return success === 'YES' ? true : false; }); const analytics = await this.ssh.exec(`systemctl is-active snips-analytics.service >/dev/null 2>&1 && echo YES || echo NO`) .then((success) => { return success === 'YES' ? true : false; }); const dialogue = await this.ssh.exec(`systemctl is-active snips-dialogue.service >/dev/null 2>&1 && echo YES || echo NO`) .then((success) => { return success === 'YES' ? true : false; }); const asr = await this.ssh.exec(`systemctl is-active snips-asr.service >/dev/null 2>&1 && echo YES || echo NO`) .then((success) => { return success === 'YES' ? true : false; }); const tts = await this.ssh.exec(`systemctl is-active snips-tts.service >/dev/null 2>&1 && echo YES || echo NO`) .then((success) => { return success === 'YES' ? true : false; }); const audioServer = await this.ssh.exec(`systemctl is-active snips-audio-server.service >/dev/null 2>&1 && echo YES || echo NO`) .then((success) => { return success === 'YES' ? true : false; }); return [ { name: 'snips-hotword', active: hotword }, { name: 'snips-queries', active: queries }, { name: 'snips-analytics', active: analytics }, { name: 'snips-dialogue', active: dialogue }, { name: 'snips-asr', active: asr }, { name: 'snips-tts', active: tts }, { name: 'snips-audio-server', active: audioServer }, ]; } public playAudio = async (name: string): Promise<string> => { // Playing audio throws back an error even though it executes the file. We ignore the error. return this.ssh.exec(`aplay ${name}.wav`).catch((e) => { }); } public removeAudioFile = async (name: string): Promise<string> => { return this.ssh.exec(`rm ${name}.wav`); } public setupAsoundConf = async (outputDevice: AudioDevice, captureDevice: AudioDevice): Promise<string> => { return this.ssh.exec(`(echo 'pcm.!default {'; \ echo ' type asym'; \ echo ' playback.pcm {'; \ echo ' type plug'; \ echo ' slave.pcm "hw:${outputDevice.card},${outputDevice.subdevice}"'; \ echo ' }'; \ echo ' capture.pcm {'; \ echo ' type plug'; \ echo ' slave.pcm "dsnoop:${captureDevice.card},${captureDevice.subdevice}"'; \ echo ' }'; \ echo '}') \ | sudo tee /etc/asound.conf > /dev/null`); } public copySnipsFile = async (snipsfile: string) => { return this.ssh.exec(`echo "${snipsfile}" | sudo tee ~/Snipsfile > /dev/null`); } public runSnipsmanagerInstall = async (listener: (string) => void) => { return this.sshCommandLog(`sudo snipsmanager install`, listener); } public runSnipsManager = async (listener: (string) => void) => { return this.sshCommandLog(`sudo snipsmanager run`, listener); } public installSnips = async (listener: (string) => void): Promise<string> => { listener('Updating your device aptitude repository'); return this.sshCommandLog('sudo apt-get update', listener) .then((success) => { return this.sshCommandLog('sudo apt-get install -y dirmngr', listener) .catch((err) => { throw new Error(`Couldn't install dirmngr`); }); }) .then((success) => { return this.sshCommandLog(`sudo bash -c 'echo "deb https://raspbian.snips.ai/$(lsb_release -cs) stable main" > /etc/apt/sources.list.d/snips.list'`, listener) .catch((err) => { throw new Error(`Couldn't update Snips apt sources on the device`); }); }) .then((success) => { let addSnipsKeyCommand = ''; try { const snipsPGPPubKey = fsExtra.readFileSync(__dirname + '/snips-pgp-key.asc', 'utf8'); addSnipsKeyCommand = `(echo "${snipsPGPPubKey}" > ~/snips-pgp-key.asc) && sudo apt-key add ~/snips-pgp-key.asc && rm snips-pgp-key.asc`; } catch (e) { addSnipsKeyCommand = `sudo apt-key adv --keyserver pgp.mit.edu --recv-keys D4F50CDCA10A2849`; } return this.ssh.exec(addSnipsKeyCommand) .catch((err) => { if (err.message.includes(`gpg: key D4F50CDCA10A2849: "Snips Raspbian distribution <infra@snips.ai>" not changed`) || err.message.includes(`Warning: apt-key output should not be parsed (stdout is not a terminal)`)) { return; } throw new Error(`Couldn't fetch the PGP certificate to authenticate Snips APT repository.\nTry again and if the problem persists, please contact us`); }); }) .then((success) => { listener('Installing Snips Platform'); return this.sshCommandLog(`sudo apt-get update && sudo apt-get install -y snips-platform-voice snips-watch snipsmanager`, listener) .catch((err) => { throw new Error(`There was a problem during Snips Platform installation`); }); }); } public packagesVersion = async (): Promise<string> => { return this.ssh.exec(`dpkg -l | grep snips | awk '{printf("%- 30s %- 10s\\n", $2,$3)}'`); } public updateWifi = async (name: string, passphrase: string): Promise<string> => { return this.ssh.exec(`(echo ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev; \ echo update_config=1; \ echo ; \ wpa_passphrase \"${name}\" \"${passphrase}\") \ | sudo tee /etc/wpa_supplicant/wpa_supplicant.conf > /dev/null`) .then((success) => { return this.ssh.exec('sudo wpa_cli reconfigure'); }); } public statusWifi = async (): Promise<string> => { return this.ssh.exec('sudo wpa_cli status') .then((response) => { let ssid: string | undefined; let status: string | undefined; response.split('\n') .map(line => line.split('=')) .forEach((lineComponents) => { const key = lineComponents[0]; const value = lineComponents[1]; if (key === 'ssid') { ssid = value; } else if (key === 'wpa_state') { // TODO add wpa_state status = value; } }); return [ssid, status]; }); } public reconnectWifi = async (): Promise<string> => { return this.ssh.exec('sudo wpa_cli reconnect'); } public disconnectWifi = async (): Promise<string> => { return this.ssh.exec('sudo wpa_cli disconnect'); } private sshCommandLog = async (command: string, listener: (string) => void): Promise<string> => { return this.ssh.execCommand(command, { onStdout(chunk) { listener(chunk.toString('utf8')); }, onStderr(chunk) { listener(chunk.toString('utf8')); }, }); } /*----------------------------------------------------------------------*/ // RSA Key Generation public sshCopyId = async (hostname: string, username: string): Promise<string> => { let sshPubKeyFile: string; if (fsExtra.existsSync(snipsRSAKeyFilePath) === false) { sshPubKeyFile = await this.sshKeygen(); this.add_snips_rsa_key_to_ssh_config_if_needed(); } else { sshPubKeyFile = fsExtra.readFileSync(snipsRSAKeyFilePath + '.pub', 'utf8'); this.add_snips_rsa_key_to_ssh_config_if_needed(); } // The ssh-copy-id script doesn't exist on Windows, the Unix script essentially copies the keys on the remote server with ssh // You can check the original code on your own system : cat /usr/bin/ssh-copy-id // The command below is the same as the osx version. const command = `echo "${sshPubKeyFile}" | exec sh -c 'cd ; umask 077 ; mkdir -p .ssh && cat >> .ssh/authorized_keys || exit 1 ; if type restorecon >/dev/null 2>&1 ; then restorecon -F .ssh .ssh/authorized_keys ; fi'`; return this.ssh.exec(command); } private ssh_keygen_binPath(): string { if (process.platform !== 'win32') { return 'ssh-keygen'; } switch (process.arch) { case 'ia32': return path.join(__dirname, '..', 'bin', 'ssh-keygen-32.exe'); case 'x64': return path.join(__dirname, '..', 'bin', 'ssh-keygen-64.exe'); } throw new Error('Unsupported platform'); } private sshKeygen = async (): Promise<string> => { const command = `${this.ssh_keygen_binPath()} -t rsa -b 2048 -C "Snips RSA key" -N "" -f ${snipsRSAKeyFilePath}`; shelljs.exec(command, { async: false, silent: false }); try { return fsExtra.readFileSync(snipsRSAKeyFilePath + '.pub', 'utf8'); } catch (e) { throw new Error(`Snips RSA Key couldn't be found`); } } private add_snips_rsa_key_to_ssh_config_if_needed() { const sshConfigPath = os.homedir() + '/.ssh/config'; const identityFilePath = ' IdentityFile ~/.ssh/' + snipsRSAKeyName; let sshConfigFile: string | undefined; try { const sshConfigFileLines = fsExtra.readFileSync(sshConfigPath, 'utf8').split('\n'); if (sshConfigFileLines.indexOf(identityFilePath) >= 0) { return; } const hostStarIndex = sshConfigFileLines.indexOf('Host *'); if (hostStarIndex >= 0) { sshConfigFileLines.splice(hostStarIndex + 1, 0, identityFilePath); } else { sshConfigFileLines.push('Host *'); sshConfigFileLines.push(identityFilePath); } sshConfigFile = sshConfigFileLines.join('\n'); } catch (e) { sshConfigFile = `Host *\n UseKeychain yes\n AddKeysToAgent yes\n${identityFilePath}`; } fsExtra.writeFileSync(sshConfigPath, sshConfigFile); } }