snips-sam
Version:
The Snips Assistant Manager
431 lines (379 loc) • 19 kB
text/typescript
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);
}
}