arlo-cameras
Version:
A node.js library for Arlo Cameras and base station
1,760 lines (1,540 loc) • 56 kB
JavaScript
/* eslint-disable no-undef */
const DebugModule = require('debug');
const extend = require('util');
const EventEmitter = require('events');
const { v4 } = require('uuid');
const crypto = require('crypto');
const moment = require('moment');
const { default: axios } = require('axios');
const { CookieJar } = require('tough-cookie');
const { wrapper } = require('axios-cookiejar-support');
const https = require('https');
const fs = require('fs');
const path = require('path');
const forge = require('node-forge');
// eslint-disable-next-line import/no-unresolved
const setTimeout = require('timers/promises');
/**
* Import internal libraries
*/
const mfa = require('./mfa');
const debug = new DebugModule('Arlo:main');
const debugComm = new DebugModule('Arlo:axios');
const debugEvent = new DebugModule('Arlo:event');
// URL's
const API_DOMAIN = 'myapi.arlo.com';
const ARLO_URLS = {};
ARLO_URLS.API_ROOT = `https://${API_DOMAIN}`;
ARLO_URLS.BASE_URL = 'my.arlo.com';
ARLO_URLS.WEB = `${ARLO_URLS.API_ROOT}/hmsweb`;
ARLO_URLS.LOGOUT = `${ARLO_URLS.WEB}/logout`;
ARLO_URLS.WEB_CLIENT = `${ARLO_URLS.WEB}/client`;
ARLO_URLS.SUBSCRIBE = `${ARLO_URLS.WEB_CLIENT}/subscribe`;
ARLO_URLS.UNSUBSCRIBE = `${ARLO_URLS.WEB_CLIENT}/unsubscribe`;
ARLO_URLS.WEB_USERS = `${ARLO_URLS.WEB}/users`;
ARLO_URLS.DEVICES_V2 = `${ARLO_URLS.WEB}/v2/users/devices`;
ARLO_URLS.DEVICES = `${ARLO_URLS.WEB_USERS}/devices`;
ARLO_URLS.DEVICE = `${ARLO_URLS.WEB_USERS}/device`;
ARLO_URLS.AUTOMATIONACTIVE = `${ARLO_URLS.DEVICES}/automation/active`;
ARLO_URLS.SERVICE_LEVEL_SETTINGS = `${ARLO_URLS.WEB_USERS}/serviceLevel/settings`;
ARLO_URLS.SERVICE_LEVELS = `${ARLO_URLS.WEB_USERS}/serviceLevel/v4`;
ARLO_URLS.CAPABILITIES = `${ARLO_URLS.WEB_USERS}/capabilities`;
ARLO_URLS.FEATURES = `${ARLO_URLS.WEB_USERS}/subscription/smart/features`;
ARLO_URLS.EMERGENCY_LOCATIONS = `${ARLO_URLS.WEB_USERS}/emergency/locations`;
ARLO_URLS.NOTIFY = `${ARLO_URLS.DEVICES}/notify`;
ARLO_URLS.START_STREAM = `${ARLO_URLS.DEVICES}/startStream`;
ARLO_URLS.STOP_STREAM = `${ARLO_URLS.DEVICES}/stopStream`;
ARLO_URLS.SNAPSHOT = `${ARLO_URLS.DEVICES}/fullFrameSnapshot`;
ARLO_URLS.LIBRARY_SUMMARY = `${ARLO_URLS.WEB_USERS}/library/metadata`;
ARLO_URLS.LIBRARY = `${ARLO_URLS.WEB_USERS}/library`;
ARLO_URLS.START_NEW_SESSION = `https://${API_DOMAIN}/hmsweb/users/session/v2`;
// Events
const EVENT_LOGGED_IN = 'logged_in';
const EVENT_MESSAGE = 'message';
const EVENT_CONNECTED = 'connected';
const EVENT_FF_SNAPSHOT_AVAILABLE = 'fullFrameSnapshotAvailable';
const EVENT_MEDIA_UPLOAD = 'mediaUploadNotification';
const EVENT_FOUND = 'device_found';
const EVENT_GOT_DEVICES = 'got_all_devices';
const EVENT_MODE = 'activeAutomations';
const EVENT_SIREN = 'siren';
const EVENT_DEVICES = 'devices';
const EVENT_BATTERY = 'batteryLevel';
const EVENT_DEVICE_UPDATE = 'deviceUpdate';
const EVENT_LOGOUT = 'logout';
const EVENT_RATLS = 'storage/ratls';
const EVENT_PROPERTIES = 'properties_updated';
// Device Types
const TYPE_ARLOQS = 'arloqs';
const TYPE_ARLOQ = 'arloq';
const TYPE_BASESTATION = 'basestation';
const TYPE_CAMERA = 'camera';
/**
* Arlo class
*
* @class Arlo
*/
class Arlo extends EventEmitter {
/**
* Creates an instance of Arlo.
*
* @param {Object} options
* @memberof Arlo
* Params:
* arloUser // Arlo account email address
* arloPassword // Arlo account password
* updatePropertiesEvery // Update device information every x minutes
* emailUser // Your email address registered to receive MFA
* emailPassword // Your email password
* emailServer // Email server
* mfa // True is MFA via email
* // False if mobile app token
* token // (optional) Previous login token
* localAppID // Random chars - needs to conform to
* // '********-****-****-****-************'
* mobilePayload // Extracted from Arlo mobile app using
* // a proxy tool like proxyman
*/
constructor(options) {
super();
if (!options) {
debug('No options passed in');
this._fatal();
}
this.config = options;
this.loggedIn = false;
this.connected = false;
this.cameras = [];
this.timers = [];
this.RATLS = {};
// Check constructor params
// Default login is via seed token from mobile app
if (typeof this.config.mfa === 'undefined') this.config.mfa = false;
if (!this.config.mfa) {
debug('Checking mobile app auth params');
if (typeof this.config.localAppID === 'undefined')
// eslint-disable-next-line no-constructor-return
return Error('No Arlo mobile app ID param');
if (typeof this.config.mobilePayload === 'undefined')
// eslint-disable-next-line no-constructor-return
return Error('No Arlo mobile payload param');
this.localAppID = this.config.localAppID;
this.mobilePayload = this.config.mobilePayload;
} else {
debug('Checking MFA auth params');
if (typeof this.config.emailUser === 'undefined')
// eslint-disable-next-line no-constructor-return
return Error('No email user');
if (typeof this.config.emailPassword === 'undefined')
// eslint-disable-next-line no-constructor-return
return Error('No email password');
if (typeof this.config.emailServer === 'undefined')
// eslint-disable-next-line no-constructor-return
return Error('No email server');
}
// Arlo account login params
if (typeof this.config.arloUser === 'undefined')
// eslint-disable-next-line no-constructor-return
return Error('No Arlo user param');
if (typeof this.config.arloPassword === 'undefined')
// eslint-disable-next-line no-constructor-return
return Error('No Arlo password param');
// Convert arlo password to base64 if requesting MFA
if (this.config.mfa) {
const buff = Buffer.from(this.config.arloPassword);
this.config.arloPassword = buff.toString('base64');
}
// Set defaults if no override
if (typeof this.config.updatePropertiesEvery !== 'undefined')
this.updatePropertiesTimer = this.config.updatePropertiesEvery * 60000;
if (typeof this.config.token === 'undefined') this.config.token = '';
const jar = new CookieJar();
this.axiosClient = wrapper(axios.create({ jar }));
}
/** *************************
* Public functions
************************* */
/**
* Login to Arlo
*/
async login() {
// Default headers
this.headers = {
'Content-Type': 'application/json',
Connection: 'keep-alive',
Accept: '*/*',
'Accept-Language': 'en-gb',
'Accept-Encoding': 'gzip, deflate, br',
Pragma: 'no-cache',
'X-DreamFactory-Api-Key':
'8c6b41f20897aa6b3f852a1ca3aded0471888e2e119da2737de2a9c797a8ae8d',
accessToken: this.config.token,
};
// Login specific headers
if (!this.config.mfa) {
this.headers.host = 'ocapi.arlo.com';
this.headers['User-Agent'] = 'Arlo/2967 CFNetwork/1312 Darwin/21.0.0';
} else {
this.headers['User-Agent'] =
'Mozilla/5.0 (iPhone; CPU iPhone OS 11_1_2 like Mac OS X) AppleWebKit/604.3.5 (KHTML, like Gecko) Mobile/15B202 NETGEAR/v1 (iOS Vuezone)';
this.headers.DNT = '1';
this.headers.schemaVersion = '1';
this.headers['Auth-Version'] = '2';
this.headers.origin = `https://${ARLO_URLS.BASE_URL}`;
this.headers.referer = `https://${ARLO_URLS.BASE_URL}/`;
this.headers.source = 'arloCamWeb';
}
// if (!proceed) {
debug('Logging in');
this.headers.authorization = null; // Clear out old token;
let proceed = await mfa._getAuthToken.call(this);
if (!proceed) return false;
proceed = await mfa._getFactors.call(this);
if (!proceed) return false;
// If MFA then get code from email server
if (this.config.mfa) {
proceed = await mfa._getMFACodeFromEmail.call(this);
if (!proceed) return false;
proceed = await mfa._submitMFACode.call(this);
if (!proceed) return false;
proceed = await mfa._verifyAuthToken.call(this);
if (!proceed) return false;
proceed = await mfa._newSession.call(this);
if (!proceed) return false;
} else {
proceed = await mfa._requestMFACode.call(this);
if (!proceed) return false;
proceed = await mfa._validateToken.call(this);
if (!proceed) return false;
}
debug('Logged in');
this.loggedIn = true;
this.emit(EVENT_LOGGED_IN, this.serialNumber);
// Set timer to log out when token expires
setTimeout(() => this._logOut.call(this), this.tokenExpires);
// Reset headers
this.headers = {
accept: 'application/json',
'content-type': 'application/json;charset=UTF-8',
'auth-version': 2,
'accept-encoding': 'gzip, deflate, br',
'user-agent': '(iPhone13,3 14_7_1) iOS Arlo 3.5',
'accept-language': 'en-GB',
authorization: this.token,
};
// Setup event stream listner
await this._subscribe();
return true;
}
/**
* Arm base station/camera
*/
arm(deviceID) {
try {
debug('Arm base station/camera');
let device;
if (deviceID === this.baseStation.deviceId) device = this.baseStation;
else {
const deviceIndex = this.cameras.findIndex(
(d) => d.deviceId === deviceID
);
if (deviceIndex < 0) {
const err = new Error('No device found');
debug(err);
return err;
}
device = this.cameras[deviceIndex];
}
// Set new mode
this._notify(
{
action: 'set',
resource: 'modes',
publishResponse: true,
properties: { active: 'mode1' },
},
device
);
return true;
} catch (err) {
debug(err.message);
return err;
}
}
/**
* Disarm base station/camera
*/
disarm(deviceID) {
try {
debug('Disarm base station/camera');
let device;
if (deviceID === this.baseStation.deviceId) device = this.baseStation;
else {
const deviceIndex = this.cameras.findIndex(
(d) => d.deviceId === deviceID
);
if (deviceIndex < 0) {
const err = new Error('No device found');
debug(err);
return err;
}
device = this.cameras[deviceIndex];
}
// Set new mode
this._notify(
{
action: 'set',
resource: 'modes',
publishResponse: true,
properties: { active: 'mode0' },
},
device
);
return true;
} catch (err) {
debug(err.message);
return err;
}
}
/**
* Turn on/off camera
*/
async setPrivacyActive(deviceID, privacy) {
debug(`[${deviceID}] Turn camera ${privacy ? 'off' : 'on'}`);
const deviceIndex = this.cameras.findIndex((d) => d.deviceId === deviceID);
if (deviceIndex < 0) {
const err = new Error('No device found');
debug(err);
return err;
}
let device;
switch (this.cameras[deviceIndex].deviceType) {
case TYPE_ARLOQS:
case TYPE_ARLOQ:
device = this.cameras[deviceIndex];
break;
case TYPE_CAMERA:
device = this.baseStation;
break;
default:
return false;
}
await this._notify(
{
action: 'set',
resource: `cameras/${deviceID}`,
publishResponse: true,
properties: { privacyActive: privacy },
},
device
);
// Request device properties refresh
await this._requestDeviceEvents.call(this, device);
return true;
}
/**
* Turn on the siren
*/
async sirenOn(deviceID) {
debug(`[${deviceID}] Turn siren on`);
await this._notify(
{
action: 'set',
resource: `siren/${deviceID}`,
publishResponse: true,
properties: {
sirenState: 'on',
duration: 300,
volume: 8,
pattern: 'alarm',
},
},
deviceID
);
// Request device properties refresh
await this._refreshDeviceProperties(deviceID);
return true;
}
/**
* Turn off the siren
*/
async sirenOff(deviceID) {
debug(`[${deviceID}] Turn siren off`);
await this._notify(
{
action: 'set',
resource: `siren/${deviceID}`,
publishResponse: true,
properties: {
sirenState: 'off',
duration: 300,
volume: 8,
pattern: 'alarm',
},
},
deviceID
);
// Request device properties refresh
await this._refreshDeviceProperties(deviceID);
return true;
}
/**
* Start camera video stream
*/
async startStream(deviceID) {
try {
const deviceIndex = this.cameras.findIndex(
(d) => d.deviceId === deviceID
);
if (deviceIndex < 0) {
const err = new Error('No device found');
debug(err);
return err;
}
const device = this.cameras[deviceIndex];
// Return existing stream url if stream already active
if (device.streamActive) return device.streamURL;
// Do not start stream if camera is in privacy mode
if (device.properties.privacyActive) {
// deepcode ignore ExceptionIsNotThrown:
const err = new Error('Camera not active, unable to start stream');
debug(`[${deviceID}] ${err}`);
return err;
}
debug(`[${deviceID}] Camera is on, requesting stream`);
const body = {
from: `${this.userId}`,
to: this.baseStation.deviceId,
action: 'set',
resource: `cameras/${deviceID}`,
publishResponse: true,
transId: this._genTransID(),
properties: {
smartZoom: {
topleftx: 0,
toplefty: 0,
bottomrightx: 3840,
bottomrighty: 2160,
},
activityState: 'startUserStream',
cameraId: deviceID,
},
};
this._notify(body, device);
const url = ARLO_URLS.START_STREAM;
const response = await this._post(url, body, {
xCloudId: this.baseStation.xCloudId,
});
if (response instanceof Error || typeof response === 'undefined') {
debug(response.message);
return response;
}
if (!response.success) {
debug(response.data.message);
return new Error(response.data.message);
}
if (
response.data.url === null ||
typeof response.data.url === 'undefined'
) {
const err = new Error(`Error getting stream for device: ${deviceID}`);
debug(err.message);
return err;
}
const rtnURL = response.data.url.replace('rtsp://', 'rtsps://');
this.cameras[deviceIndex].streamURL = rtnURL;
this.cameras[deviceIndex].streamActive = true;
debug(`[${deviceID}] Stream URL: ${rtnURL}`);
return rtnURL;
} catch (err) {
debug(err);
return err;
}
}
/**
* Stop camera video stream
*/
async stopStream(deviceID) {
try {
const deviceIndex = this.cameras.findIndex(
(d) => d.deviceId === deviceID
);
if (deviceIndex < 0) {
const err = new Error('No device found');
debug(err);
return err;
}
debug(`[${deviceID}] Stop stream`);
const body = {
from: `${this.userId}`,
to: this.baseStation.deviceId,
action: 'set',
resource: `cameras/${deviceID}`,
publishResponse: true,
transId: this._genTransID(),
properties: {
activityState: 'stopUserStream',
cameraId: deviceID,
},
};
const url = ARLO_URLS.STOP_STREAM;
const response = await this._post(url, body, {
xCloudId: this.baseStation.xCloudId,
});
if (response instanceof Error) debug(response.message);
debug(`[${deviceID}] Stream stopped`);
this.cameras[deviceIndex].streamURL = '';
this.cameras[deviceIndex].streamActive = false;
return true;
} catch (err) {
debug(err);
return err;
}
}
/**
* Return the latest snapShot image URL
*/
getSnapshotURL(deviceID) {
try {
debug(`[${deviceID}] Return snapshot URL`);
const deviceIndex = this.cameras.findIndex(
(d) => d.deviceId === deviceID
);
if (deviceIndex < 0) {
const err = new Error('No device found');
debug(err);
return err;
}
const url = {
presignedLastImageUrl: this.cameras[deviceIndex].presignedLastImageUrl,
presignedFullFrameSnapshotUrl:
this.cameras[deviceIndex].presignedFullFrameSnapshotUrl,
};
return url;
} catch (err) {
debug(err);
return err;
}
}
/**
* Take new snapshot
*/
async getNewSnapshot(deviceID) {
try {
const deviceIndex = this.cameras.findIndex(
(d) => d.deviceId === deviceID
);
if (deviceIndex < 0) {
const err = new Error('Get FF snapshot: No device found');
debug(err);
return err;
}
const device = this.cameras[deviceIndex];
// Do not take snapshot if app just launched
if (typeof device.properties.privacyActive === 'undefined') return false;
// Do not take snapshot if camera is in privacy mode
if (device.properties.privacyActive) {
debug(`[${deviceID}] Camera not active, unable to take FF snapshot`);
return false;
}
debug(`[${deviceID}] Get new FF snapshot`);
const url = ARLO_URLS.SNAPSHOT;
const body = {};
body.from = `${this.userId}`;
body.to = this.baseStation.deviceId;
body.transId = this._genTransID();
body.resource = `cameras/${deviceID}`;
body.action = 'set';
body.publishResponse = true;
body.properties = { activityState: 'fullFrameSnapshot' };
const response = await this._post(url, body, {
xCloudId: device.xCloudId,
});
if (response instanceof Error) {
debug('Error getting FF snapshot');
debug(response.message);
return response;
}
if (!response.success) {
debug(response.data.message);
const err = new Error(response.data.message);
throw err;
}
return true;
} catch (err) {
debug(err);
return err;
}
}
/**
* Get media library summary data
*/
async getMediaLibrarySummary(from) {
try {
debug(`Get media library summary data`);
const url = ARLO_URLS.LIBRARY_SUMMARY;
const body = {
dateFrom: from || moment().format('yyyyMMDD'),
dateTo: moment().format('yyyyMMDD'),
};
const response = await this._post(url, body, {});
if (response instanceof Error) {
debug(response.message);
return response;
}
if (!response || !response.success)
throw new Error('Error getting media library summary data');
return response.data;
} catch (err) {
debug(err);
return err;
}
}
/**
* Get media library data
*/
async getMediaLibrary(from) {
try {
debug(`Get media library data`);
const url = ARLO_URLS.LIBRARY;
const body = {
dateFrom: from || moment().format('yyyyMMDD'),
dateTo: moment().format('yyyyMMDD'),
};
const response = await this._post(url, body, {});
if (response instanceof Error) {
const err = new Error('Error getting media library data');
debug(response.message);
throw err;
}
return response.data;
} catch (err) {
debug(err);
return err;
}
}
/**
* Download local media file
*/
async downloadLocalMediaFile(filePath, outputLocationPath) {
const url = `https://${this.RATLS.ip}:${this.RATLS.port}/hmsls/download/${filePath}`;
// Bind certs to http agent
const httpsAgent = new https.Agent({
ca: this.RATLS.icaCert,
cert: this.RATLS.peerCert,
key: this.RATLS.privateKey,
rejectUnauthorized: false, // Not able to get issuer cert if not set
});
// Set headers
const headers = {
authorization: `Bearer ${this.RATLS.token}`,
'user-agent': this.headers['user-agent'],
};
const options = {
method: 'GET',
responseType: 'stream',
// deepcode ignore Ssrf:
url,
httpsAgent,
headers,
};
try {
// Call local storage account and download recording
debug('download file');
// deepcode ignore Ssrf:, deepcode ignore PromiseNotCaughtGeneral:
const processFile = await axios(options).then((response) => {
const writer = fs.createWriteStream(`${outputLocationPath}`);
return new Promise((resolve, reject) => {
response.data.pipe(writer);
let error = null;
writer.on('error', (err) => {
error = err;
writer.close();
reject(err);
});
writer.on('close', () => {
if (!error) {
resolve(true);
}
});
});
});
return processFile;
} catch (err) {
debug(err);
return err;
}
}
async reSetBaseStation() {
const directory = 'certs';
debug(`Removing certs for: ${this.baseStation.deviceId}`);
fs.readdir(directory, (err, files) => {
if (err) throw err;
// eslint-disable-next-line no-restricted-syntax
for (const file of files) {
fs.unlink(path.join(directory, file), (error) => {
if (error) throw error;
});
}
});
// Re-start basestation
/*
const reStartUrl = `${ARLO_URLS.DEVICES}/restart`;
const body = {
deviceId: this.baseStation.deviceId,
};
debug('Restarting base station');
await this._post(reStartUrl, body);
*/
}
async getLocalMediaLibrary(from) {
// Check if certs exist
const peerCrtPath = `./certs/peer.crt`;
if (!fs.existsSync(peerCrtPath)) {
// Generate local cert if not exists
debug('Peer certs does not exists, Check if public pem exists');
const publicPemPath = `./certs/public.pem`;
if (fs.existsSync(publicPemPath)) {
debug('Cert already exists, reading file');
this.RATLS.publicKey = fs.readFileSync('./certs/public.pem', {
encoding: 'utf8',
});
this.RATLS.privateKey = fs.readFileSync('./certs/private.pem', {
encoding: 'utf8',
});
} else {
try {
debug('Generating new local certs');
// Generate key pairs
const keys = forge.pki.rsa.generateKeyPair(2048);
// PEM serialize
this.RATLS.privateKey = forge.pki.privateKeyToPem(keys.privateKey);
this.RATLS.publicKey = forge.pki.publicKeyToPem(keys.publicKey);
// Save certs
fs.writeFileSync('./certs/private.pem', this.RATLS.privateKey);
fs.writeFileSync('./certs/public.pem', this.RATLS.publicKey);
} catch (err) {
debug('Unable to save certs');
throw err;
}
}
debug('Strip return, header and footer from cert');
let publicKey = this.RATLS.publicKey.replace(/(\r\n|\n|\r)/gm, '');
publicKey = publicKey.replace('-----BEGIN PUBLIC KEY-----', '');
publicKey = publicKey.replace('-----END PUBLIC KEY-----', '');
debug('Get RATLS certs');
const url = `${ARLO_URLS.DEVICES}/v2/security/cert/create`;
const body = {
uuid: this.localAppID,
publicKey,
uniqueIds: [`${this.userId}_${this.baseStation.deviceId}`],
};
const response = await this._post(url, body);
if (response instanceof Error) {
debug(response.message);
throw new Error('Error getting media library data');
}
if (!response.success) {
const err = new Error(response.data.message);
debug(err.message);
return err;
}
debug('Saving RATLS certs');
this.RATLS.peerCert = this._formatToPem(
response.data.certsData[0].peerCert
);
fs.writeFileSync('./certs/peer.crt', this.RATLS.peerCert);
this.RATLS.deviceCert = this._formatToPem(
response.data.certsData[0].deviceCert
);
fs.writeFileSync('./certs/device.crt', this.RATLS.deviceCert);
this.RATLS.icaCert = this._formatToPem(response.data.icaCert);
fs.writeFileSync('./certs/ica.crt', this.RATLS.icaCert);
this.RATLS.combined = `${this.RATLS.peerCert}\n${this.RATLS.icaCert}`;
fs.writeFileSync('./certs/combined.crt', this.RATLS.combined);
} else {
debug('Loading RATLS certs');
this.RATLS.privateKey = fs.readFileSync('./certs/private.pem', {
encoding: 'utf8',
});
this.RATLS.icaCert = fs.readFileSync('./certs/ica.crt', {
encoding: 'utf8',
});
this.RATLS.peerCert = fs.readFileSync('./certs/peer.crt', {
encoding: 'utf8',
});
}
// Connect to local storage device
const dateFrom = from || moment().format('yyyyMMDD');
const dateTo = moment().format('yyyyMMDD');
const url = `https://${this.RATLS.ip}:${this.RATLS.port}/hmsls/list/${dateFrom}/${dateTo}`;
// Bind certs to http agent
const httpsAgent = new https.Agent({
ca: this.RATLS.icaCert,
cert: this.RATLS.peerCert,
key: this.RATLS.privateKey,
rejectUnauthorized: false, // Not able to get issuer cert if not set
});
// Set headers
const headers = {
authorization: `Bearer ${this.RATLS.token}`,
accept: 'application/json',
'user-agent': this.headers['user-agent'],
};
const options = {
method: 'GET',
// deepcode ignore Ssrf:
url,
httpsAgent,
headers,
};
// Call local storage account to get recordings
debug('Getting local storage recording data');
let response;
try {
// deepcode ignore Ssrf:
response = await axios(options);
} catch (err) {
debug(err.message);
response = err;
}
if (response instanceof Error) {
await this.reSetBaseStation.call(this);
return Error('Error getting media library data');
}
if (!response || !response.data.success) {
await this.reSetBaseStation.call(this);
return Error('Error getting local media library data');
}
let recordsFound = 0;
if (response.data.data) recordsFound = response.data.data.length;
debug(`Found ${recordsFound} recordings`);
return response.data.data || [];
}
/**
* Request access to RATLS
*/
async openLocalMediaLibrary() {
try {
debug('Request local storage activation');
// Get RATLS token
const url = `${ARLO_URLS.DEVICE}/ratls/token/${this.baseStation.deviceId}`;
const response = await this._get(url);
if (response instanceof Error || !response.success) {
const err = new Error('Error getting media library data');
debug(response.message);
throw err;
}
this.RATLS.token = response.data.ratlsToken;
const body = {
from: `${this.userId}`,
to: this.baseStation.deviceId,
action: 'open',
resource: `storage/ratls`,
publishResponse: false,
transId: this._genTransID(),
};
await this._notify(body, this.baseStation);
debug('Requested local storage activation');
} catch (err) {
debug(err.message);
return err;
}
return true;
}
/**
* Check if camera privacy mode is enabled
*/
async isPrivacyEnabled(deviceID) {
try {
const deviceIndex = this.cameras.findIndex(
(d) => d.deviceId === deviceID
);
if (deviceIndex < 0) {
const err = new Error('Is cam Privacy Enabled: No device found');
debug(err);
return err;
}
const device = this.cameras[deviceIndex];
if (device.properties.privacyActive) {
debug(`[${deviceID}] Camera privacy mode active`);
return true;
}
debug(`[${deviceID}] Camera privacy mode in-active`);
return false;
} catch (err) {
debug(err);
return err;
}
}
/**
* Check if base station is armed
*/
async isArmed() {
debug(`Base station is ${this.baseStation.armed ? 'armed' : 'disarmed'}`);
return this.baseStation.armed;
}
/**
* Get service level settings from api
*/
async getServiceLevelSettings() {
debug('Getting service level settings data');
try {
const url = ARLO_URLS.SERVICE_LEVEL_SETTINGS;
const response = await this._get(url);
if (response instanceof Error) {
debug(response.message);
return response;
}
if (response.length === 0)
throw new Error(`Error getting service level settings`);
return response;
} catch (err) {
debug(err);
return err;
}
}
/**
* Get capabilities from api
*/
async getCapabilities() {
debug('Getting capabilities data');
try {
const url = ARLO_URLS.CAPABILITIES;
const response = await this._post(url);
if (response instanceof Error) {
debug(response.message);
return response;
}
if (response.length === 0) throw new Error(`Error getting capabilities`);
return response;
} catch (err) {
debug(err);
return err;
}
}
/**
* Get features from api
*/
async getFeatures() {
debug('Getting features data');
try {
const xTransID = `FE!${v4()}&time=${Date.now()}`;
this.headers['x-transaction-id'] = xTransID;
const url = `${ARLO_URLS.FEATURES}?eventId=${xTransID}`;
const response = await this._get(url);
if (response instanceof Error) {
debug(response.message);
return response;
}
if (response.length === 0) throw new Error(`Error getting features`);
return response;
} catch (err) {
debug(err);
return err;
}
}
/**
* Get emergency locations from api
*/
async getEmergencyLocations() {
debug('Getting emergency location data');
try {
const xTransID = `FE!${v4()}&time=${Date.now()}`;
this.headers['x-transaction-id'] = xTransID;
const url = `${ARLO_URLS.EMERGENCY_LOCATIONS}?eventId=${xTransID}`;
const response = await this._get(url);
if (response instanceof Error) debug(response);
return response;
} catch (err) {
debug(err);
return err;
}
}
/**
* Get service levels from api
*/
async getServiceLevels() {
debug('Getting service levels data');
try {
const xTransID = `FE!${v4()}&time=${Date.now()}`;
this.headers['x-transaction-id'] = xTransID;
const url = `${ARLO_URLS.SERVICE_LEVELS}?eventId=${xTransID}`;
const response = await this._get(url);
if (response instanceof Error) {
debug(response.message);
return response;
}
if (response.length === 0)
throw new Error(`Error getting service levels`);
return response;
} catch (err) {
debug(err);
return err;
}
}
/** *************************
* Private functions
************************* */
/**
* Get device armed status
*/
async _getArmedStatus() {
debug('Getting armed status data');
try {
const url = ARLO_URLS.AUTOMATIONACTIVE;
const response = await this._get(url);
if (response instanceof Error) {
debug(response.message);
return response;
}
if (response.length === 0)
throw new Error(`Error getting armed status settings`);
const baseStationArmedData = response.data.filter(
(device) => device.gatewayId === this.baseStation.deviceId
);
if (baseStationArmedData[0].activeModes[0] === 'mode0')
this.baseStation.armed = false;
else this.baseStation.armed = true;
return this.baseStation.armed;
} catch (err) {
debug(err);
return err;
}
}
/**
* Get local media library data
*/
// eslint-disable-next-line class-methods-use-this
_formatToPem(cert) {
const begin = '-----BEGIN CERTIFICATE-----\n';
const end = '\n-----END CERTIFICATE-----';
const newFormat = cert; // .replace(/.{64}/g, '$&' + '\n');
return `${begin}${newFormat}${end}`;
}
/**
* Get hmsweb version
*/
async _getHmswebVersion() {
debug('Get hmsweb version');
const url = `${ARLO_URLS.WEB}/version`;
const response = await this._get(url);
if (response instanceof Error || typeof response === 'undefined') {
debug(response);
return false;
}
this.hmsweb = response.version;
return true;
}
/**
* Request device properties refresh
*/
async _refreshDeviceProperties(deviceID) {
const deviceIndex = this.cameras.findIndex((d) => d.deviceId === deviceID);
if (deviceIndex < 0) {
const err = new Error('No device found');
debug(err);
return err;
}
await this._requestDeviceEvents.call(this, this.cameras[deviceIndex]);
return true;
}
/**
* Generate a unique string to use as transtion
* key across event responses
*/
// eslint-disable-next-line class-methods-use-this
_genTransID() {
const id1 = crypto.randomBytes(10).toString('hex').substr(1, 8);
const id2 = crypto.randomBytes(10).toString('hex').substr(1, 6);
const trandsID = `iOS!${id1}.${id2}!${Date.now()}`;
return trandsID;
}
/**
* Logout and reset so can log back in if needed
*/
async _logOut() {
this.connected = false;
this.loggedIn = false;
// Remove pooling timers
this.timers.forEach((timer) => clearTimeout(timer));
const url = ARLO_URLS.LOGOUT;
const response = await this._put(url, {}, {});
if (!response.success) debug(response);
// Clear headers
this.headers = {};
this.subscribeHeaders = {};
delete this.cookieJar;
// End device event stream
this.subscribeCancelToken.cancel('Logged out of arlo so cancel stream');
// Emit logged out event
this.emit(EVENT_LOGOUT, {});
}
/**
* Send notify requests to Arlo api
*/
async _notify(body, device) {
try {
if (!this.connected) return;
const postBody = body;
postBody.from = `${this.userId}`;
postBody.to = device.deviceId;
postBody.transId = this._genTransID();
// Set url
const url = `${ARLO_URLS.NOTIFY}/${device.deviceId}`;
// Issue request
const response = await this._post(url, postBody, {
xCloudId: device.xCloudId,
'Content-Type': 'application/json; charset=utf-8',
});
if (response instanceof Error) {
debugEvent(response);
return;
}
} catch (err) {
debugEvent(err);
}
}
/**
* Request device events
*/
async _requestDeviceEvents(device) {
if (!this.connected) return;
let body;
if (device.deviceType === TYPE_ARLOQS || device.deviceType === TYPE_ARLOQ) {
debugEvent(`[${device.deviceId}] Request Q camera events`);
try {
const from = `${this.userId}`;
const to = device.deviceId;
body = [
{
from,
to,
action: 'get',
resource: 'basestation',
transId: this._genTransID(),
publishResponse: false,
},
{
from,
to,
action: 'get',
resource: 'cameras',
transId: this._genTransID(),
publishResponse: false,
},
{
from,
to,
action: 'get',
resource: 'wifi/ap',
transId: this._genTransID(),
publishResponse: false,
},
];
} catch (err) {
debugEvent(err);
return;
}
}
if (device.deviceType === TYPE_BASESTATION) {
debugEvent(`[${device.deviceId}] Request smart hub state update`);
try {
const from = `${this.userId}`;
const to = device.deviceId;
body = [
{
from,
to,
action: 'get',
resource: 'devices',
transId: this._genTransID(),
publishResponse: false,
},
{
from,
to,
action: 'get',
resource: 'storage',
transId: this._genTransID(),
publishResponse: false,
},
];
const cams = this.cameras.filter((d) => d.deviceType === TYPE_CAMERA);
cams.map((c) => {
body.push({
from,
to,
action: 'get',
resource: `siren/${c.deviceId}`,
transId: this._genTransID(),
publishResponse: false,
});
return true;
});
} catch (err) {
debugEvent(err);
return;
}
}
try {
// Issue request
const url = `${ARLO_URLS.NOTIFY}/${device.deviceId}`;
const response = await this._post(url, body, {
xCloudId: device.xCloudId,
});
if (response instanceof Error) debugEvent(response);
} catch (err) {
debugEvent(err);
}
}
/**
* Subscribe a device to events
*/
async _subscribeToEvents(device) {
if (!this.connected) return;
if (!device.isSubscribed)
debugEvent(`[${device.deviceId}] Subscribe device to receieve events`);
const body = {
action: 'set',
properties: { devices: [device.deviceId] },
resource: `subscriptions/${this.userId}`,
publishResponse: false,
};
await this._notify(body, device);
// Keep event stream open by subscribing base station every 20 seconds
if (device.deviceType === TYPE_BASESTATION)
setTimeout(() => this._subscribeToEvents.call(this, device), 20000);
}
/**
* Subscribe devices to events
*/
async _subscribeDevices() {
// Base station
await this._subscribeToEvents.call(this, this.baseStation);
// Q Cameras
const devices = this.cameras.filter(
(d) => d.deviceType === TYPE_ARLOQS || d.deviceType === TYPE_ARLOQ
);
if (devices.length === 0) return new Error('No Q device found');
// eslint-disable-next-line no-restricted-syntax
for (const device of devices) {
this._subscribeToEvents.call(this, device);
}
return true;
}
/**
* Get Arlo devices
*/
async _getDevices() {
debug('Getting devices');
try {
const url = ARLO_URLS.DEVICES_V2;
const response = await this._get(url);
if (response instanceof Error) {
debug(response);
return false;
}
const body = response.data;
// Setup base station
const baseStationData = body.filter(
(d) => d.deviceType === TYPE_BASESTATION
);
if (baseStationData.length === 0) {
debug('No base station found');
return false;
}
// Process base station data
[this.baseStation] = baseStationData;
this.userId = baseStationData[0].userId;
debug(`Found base station: ${this.baseStation.deviceId}`);
this.emit(EVENT_FOUND, {
id: this.baseStation.deviceId,
type: TYPE_BASESTATION,
name: this.baseStation.deviceName,
});
this.cameras = [];
// Process remaining devices
body.forEach(async (device) => {
// Camera
if (device.deviceType === TYPE_CAMERA) {
debug(`Found camera: ${device.deviceId}`);
this.cameras.push(device);
this.emit(EVENT_FOUND, {
id: device.deviceId,
type: TYPE_CAMERA,
name: device.deviceName,
});
}
// Arlo Q
if (
device.deviceType === TYPE_ARLOQS ||
device.deviceType === TYPE_ARLOQ
) {
debug(`Found Q camera: ${device.deviceId}`);
this.cameras.push(device);
this.emit(EVENT_FOUND, {
id: device.deviceId,
type: TYPE_ARLOQ,
name: device.deviceName,
});
}
});
debug('Found all devices');
this.emit(EVENT_GOT_DEVICES, this.cameras);
await this._subscribeDevices.call(this);
return true;
} catch (err) {
debug(err);
}
return true;
}
/**
* Get devices and their properties
*/
async _updateDevicesAndProperties() {
const getDevices = await this._getDevices.call(this);
if (!getDevices) {
debugEvent('Unable to get all devices');
this._fatal();
}
// Update Base station properties
debugEvent(`[${this.baseStation.deviceId}] Request device properties`);
this._requestDeviceEvents.call(this, this.baseStation);
// Update Q Camera properties
const devices = this.cameras.filter(
(d) => d.deviceType === TYPE_ARLOQS || d.deviceType === TYPE_ARLOQ
);
if (devices.length === 0) return new Error('No Q device found');
// eslint-disable-next-line no-restricted-syntax
for (const device of devices) {
// eslint-disable-next-line no-await-in-loop
debugEvent(`[${device.deviceId}] Request device properties`);
this._requestDeviceEvents.call(this, device);
}
await this._getArmedStatus.call(this);
this.emit(EVENT_PROPERTIES, {});
return true;
}
/**
* Process event messages
*/
async _processEventMessage(eventData) {
try {
// Connect to event stream
if (eventData.status === EVENT_CONNECTED) {
debugEvent('Connected to event notification stream');
this.connected = true;
this.emit(EVENT_CONNECTED, eventData);
// Get devices
this._updateDevicesAndProperties.call(this);
// Set interval for devices and properties refresh
if (this.updatePropertiesTimer) {
const timer = setInterval(async () => {
this._updateDevicesAndProperties.call(this);
}, this.updatePropertiesTimer);
this.timers.push(timer);
}
return;
}
// Full frame snapshot event
if (eventData.action === EVENT_FF_SNAPSHOT_AVAILABLE) {
const deviceID = eventData.resource.substr(8);
debugEvent(`[${deviceID}] New full frame snapshot available`);
const { presignedFullFrameSnapshotUrl } = eventData.properties;
// Update device
const deviceIndex = this.cameras.findIndex(
(d) => d.deviceId === deviceID
);
this.cameras[deviceIndex].presignedFullFrameSnapshotUrl =
presignedFullFrameSnapshotUrl;
this.emit(EVENT_FF_SNAPSHOT_AVAILABLE, {
id: deviceID,
data: { presignedFullFrameSnapshotUrl },
});
return;
}
// Local storage open event
if (eventData.resource === EVENT_RATLS) {
debugEvent(
`[${this.baseStation.deviceId}] Local storage open for 5 minutes`
);
this.RATLS.ip = eventData.properties.privateIP;
this.RATLS.port = eventData.properties.port;
this.emit(EVENT_RATLS);
}
// Media upload event
if (eventData.resource === EVENT_MEDIA_UPLOAD) {
const deviceID = eventData.deviceId;
debugEvent(`[${deviceID}] New media upload event`);
const { presignedContentUrl } = eventData;
const { presignedThumbnailUrl } = eventData;
const { presignedLastImageUrl } = eventData;
// Get device
const deviceIndex = this.cameras.findIndex(
(d) => d.deviceId === deviceID
);
if (deviceIndex < 0) {
debugEvent('No device found');
return;
}
// If stream was active it's now finished
if (this.cameras[deviceIndex].streamActive) {
this.cameras[deviceIndex].streamActive = false;
this.cameras[deviceIndex].streamURL = '';
}
// Update device image properties
const rtnData = {};
if (presignedContentUrl) {
this.cameras[deviceIndex].presignedContentUrl = presignedContentUrl;
rtnData.presignedContentUrl = presignedContentUrl;
}
if (presignedThumbnailUrl) {
this.cameras[deviceIndex].presignedThumbnailUrl =
presignedThumbnailUrl;
rtnData.presignedThumbnailUrl = presignedThumbnailUrl;
}
if (presignedLastImageUrl) {
this.cameras[deviceIndex].presignedLastImageUrl =
presignedLastImageUrl;
rtnData.presignedLastImageUrl = presignedLastImageUrl;
}
this.emit(EVENT_MEDIA_UPLOAD, {
id: deviceID,
data: rtnData,
});
return;
}
// Arm / disarm event
if (eventData.resource === EVENT_MODE) {
const id = Object.keys(eventData)[0];
const description =
eventData[id].activeModes[0] === 'mode0' ? 'disarmed' : 'armed';
if (eventData[id].activeModes[0] === 'mode0')
this.baseStation.armed = false;
else this.baseStation.armed = true;
debugEvent(`[${id}] Mode change event`);
debugEvent(
`Device ${id} is ${
!this.baseStation.armed ? 'dis' : ''
}armed - Cam motion recording is ${
this.baseStation.armed ? '' : 'not '
}active`
);
this.emit('mode', {
id: Object.keys(eventData)[1],
data: {
mode: eventData[id].activeModes[0],
description,
armed: this.baseStation.armed,
},
});
}
// Q Cemera wifi event
if (eventData.resource === 'wifi/ap') {
const deviceID = eventData.from;
debugEvent(`[${deviceID}] Wifi update event`);
// Get device
const deviceIndex = this.cameras.findIndex(
(d) => d.deviceId === deviceID
);
if (deviceIndex < 0) {
debugEvent('No device found');
return;
}
debugEvent(`[${deviceID}] Update wifi properties`);
this.cameras[deviceIndex].wifi = eventData.properties;
return;
}
// Other events
if (eventData.action === 'is') {
const subscription = /subscriptions\/(.+)$/;
const siren = /siren\/(.+)$/;
// Subscribed event
if (subscription.test(eventData.resource)) {
const deviceID = eventData.properties.devices[0];
if (deviceID === this.baseStation.deviceId) {
this.baseStation.isSubscribed = true;
} else {
const deviceIndex = this.cameras.findIndex(
(d) => d.deviceId === deviceID
);
if (deviceIndex < 0) {
debugEvent('No device found');
return;
}
this.cameras[deviceIndex].isSubscribed = true;
}
return;
}
// Siren state event
if (siren.test(eventData.resource)) {
const deviceID = eventData.resource.substring(6);
debugEvent(`[${deviceID}] Update siren properties`);
// Get device
const deviceIndex = this.cameras.findIndex(
(d) => d.deviceId === deviceID
);
if (deviceIndex < 0) {
debugEvent('No device found');
return;
}
this.cameras[deviceIndex].siren = eventData.properties;
this.emit(EVENT_SIREN, this.cameras[deviceIndex].siren);
return;
}
// Smart hub devices update event
if (eventData.resource === EVENT_DEVICES) {
const { devices } = eventData;
Object.keys(devices).forEach((deviceID) => {
if (deviceID === this.baseStation.deviceId) {
debugEvent(`[${deviceID}] Update base station properties`);
this.baseStation.properties = devices[deviceID].properties;
} else {
const deviceIndex = this.cameras.findIndex(
(d) => d.deviceId === deviceID
);
if (deviceIndex < 0) {
debugEvent('No device found');
return false;
}
debugEvent(`[${deviceID}] Update camera properties`);
this.cameras[deviceIndex].properties =
devices[deviceID].properties;
// Emit battery event
this.emit(EVENT_BATTERY, {
id: deviceID,
data: {
batteryLevel:
this.cameras[deviceIndex].properties.batteryLevel,
chargingState:
this.cameras[deviceIndex].properties.chargingState,
signalStrength:
this.cameras[deviceIndex].properties.signalStrength,
},
});
// Emit device updated event
this.emit(EVENT_DEVICE_UPDATE, {
id: deviceID,
data: this.cameras[deviceIndex].properties,
});
}
return true;
});
return;
}
// Q Camera base station event
if (eventData.resource === 'basestation') {
const deviceID = eventData.from;
debugEvent(`[${deviceID}] Q base station update event`);
// Get device
const deviceIndex = this.cameras.findIndex(
(d) => d.deviceId === deviceID
);
if (deviceIndex < 0) {
debugEvent('No device found');
return;
}
debugEvent(`[${deviceID}] Update Q base station properties`);
this.cameras[deviceIndex].baseStation = {};
this.cameras[deviceIndex].baseStation.properties