UNPKG

ring-client-api

Version:

Unofficial API for Ring doorbells, cameras, security alarm system and smart lighting

310 lines (309 loc) 14.3 kB
import { clientApi, deviceApi, RingRestClient } from "./rest-client.js"; import { Location } from "./location.js"; import { PushNotificationAction, RingDeviceType } from "./ring-types.js"; import { RingCamera } from "./ring-camera.js"; import { RingChime } from "./ring-chime.js"; import { combineLatest, EMPTY, merge, Subject } from 'rxjs'; import { debounceTime, startWith, switchMap, throttleTime, } from 'rxjs/operators'; import { clearTimeouts, enableDebug, logDebug, logError, logInfo, } from "./util.js"; import { setFfmpegPath } from "./ffmpeg.js"; import { Subscribed } from "./subscribed.js"; import { PushReceiver } from '@eneris/push-receiver'; import { RingIntercom } from "./ring-intercom.js"; import JSONbig from 'json-bigint'; export class RingApi extends Subscribed { restClient; onRefreshTokenUpdated; options; constructor(options) { super(); this.options = options; this.restClient = new RingRestClient(this.options); this.onRefreshTokenUpdated = this.restClient.onRefreshTokenUpdated.asObservable(); if (options.debug) { enableDebug(); } const { locationIds, ffmpegPath } = options; if (locationIds && !locationIds.length) { logError('Your Ring config has `"locationIds": []`, which means no locations will be used and no devices will be found.'); } if (ffmpegPath) { setFfmpegPath(ffmpegPath); } } async fetchRingDevices() { const { doorbots, chimes, authorized_doorbots: authorizedDoorbots, stickup_cams: stickupCams, base_stations: baseStations, beams_bridges: beamBridges, other: otherDevices, } = await this.restClient.request({ url: clientApi('ring_devices') }), onvifCameras = [], intercoms = [], thirdPartyGarageDoorOpeners = [], unknownDevices = []; otherDevices.forEach((device) => { switch (device.kind) { case RingDeviceType.OnvifCamera: onvifCameras.push(device); break; case RingDeviceType.IntercomHandsetAudio: intercoms.push(device); break; case RingDeviceType.ThirdPartyGarageDoorOpener: thirdPartyGarageDoorOpeners.push(device); break; default: unknownDevices.push(device); break; } }); return { doorbots, chimes, authorizedDoorbots, stickupCams, allCameras: [ ...doorbots, ...stickupCams, ...authorizedDoorbots, ...onvifCameras, ], baseStations, beamBridges, onvifCameras, thirdPartyGarageDoorOpeners, intercoms, unknownDevices, }; } listenForDeviceUpdates(cameras, chimes, intercoms) { const { cameraStatusPollingSeconds } = this.options; if (!cameraStatusPollingSeconds) { return; } const devices = [...cameras, ...chimes, ...intercoms], onDeviceRequestUpdate = merge(...devices.map((device) => device.onRequestUpdate)), onUpdateReceived = new Subject(), onPollForStatusUpdate = cameraStatusPollingSeconds ? onUpdateReceived.pipe(debounceTime(cameraStatusPollingSeconds * 1000)) : EMPTY, camerasById = cameras.reduce((byId, camera) => { byId[camera.id] = camera; return byId; }, {}), chimesById = chimes.reduce((byId, chime) => { byId[chime.id] = chime; return byId; }, {}), intercomsById = intercoms.reduce((byId, intercom) => { byId[intercom.id] = intercom; return byId; }, {}); if (!cameras.length && !chimes.length && !intercoms.length) { return; } this.addSubscriptions(merge(onDeviceRequestUpdate, onPollForStatusUpdate) .pipe(throttleTime(500), switchMap(() => this.fetchRingDevices().catch(() => null))) .subscribe((response) => { onUpdateReceived.next(null); if (!response) { return; } response.allCameras.forEach((data) => { const camera = camerasById[data.id]; if (camera) { camera.updateData(data); } }); response.chimes.forEach((data) => { const chime = chimesById[data.id]; if (chime) { chime.updateData(data); } }); response.intercoms.forEach((data) => { const intercom = intercomsById[data.id]; if (intercom) { intercom.updateData(data); } }); })); if (cameraStatusPollingSeconds) { onUpdateReceived.next(null); // kick off polling } } async registerPushReceiver(cameras, intercoms) { const credentials = this.restClient._internalOnly_pushNotificationCredentials?.config && this.restClient._internalOnly_pushNotificationCredentials, pushReceiver = new PushReceiver({ firebase: { apiKey: 'AIzaSyCv-hdFBmmdBBJadNy-TFwB-xN_H5m3Bk8', projectId: 'ring-17770', messagingSenderId: '876313859327', // for Ring android app. 703521446232 for ring-site appId: '1:876313859327:android:e10ec6ddb3c81f39', }, credentials, debug: false, }), devicesById = {}, sendToDevice = (id, notification) => { devicesById[id]?.processPushNotification(notification); }, onPushNotificationToken = new Subject(); for (const camera of cameras) { devicesById[camera.id] = camera; } for (const intercom of intercoms) { devicesById[intercom.id] = intercom; } pushReceiver.onCredentialsChanged(({ newCredentials }) => { // Store the new credentials in the rest client so that it can be used for subsequent restarts this.restClient._internalOnly_pushNotificationCredentials = newCredentials; // Send the new credentials to the server onPushNotificationToken.next(newCredentials.fcm.token); }); this.addSubscriptions(combineLatest([ onPushNotificationToken, this.restClient.onSession.pipe(startWith(undefined)), // combined but not used here, just to trigger another request when session is updated ]).subscribe(async ([token]) => { try { await this.restClient.request({ url: clientApi('device'), method: 'PATCH', json: { device: { metadata: { ...this.restClient.baseSessionMetadata, pn_dict_version: '2.0.0', pn_service: 'fcm', }, os: 'android', push_notification_token: token, }, }, }); } catch (e) { logError(e); } })); pushReceiver.on('ON_DISCONNECT', () => { pushReceiver.whenReady.catch((e) => { logError('Connection to the push notification server has failed unexpectedly'); logError('If this happens repeatedly, verify connections to TCP/5228 are not blocked by firewall or IDS/IPS policies, and that DNS Adblock rules allow mtalk.google.com'); logError(e.message); }); }); try { await pushReceiver.connect(); } catch (e) { logError('Failed to connect push notification receiver'); logError(e); } const startTime = Date.now(); pushReceiver.onNotification(({ message }) => { // Ignore messages received in the first two seconds after connecting // These are likely duplicates, and we aren't currently storying persistent ids anywhere to avoid re-processing them if (Date.now() - startTime < 2000) { logInfo('Ignoring push notification received in first two seconds after starting up'); return; } try { const messageData = {}; // Each message field is a JSON string, so we need to parse them each individually for (const p in message.data) { try { // If it's a JSON string, parse it into an object messageData[p] = JSONbig({ storeAsString: true }).parse(message.data[p]); } catch { // Otherwise just assign the value directly messageData[p] = message.data[p]; } } const notification = messageData; if ('android_config' in notification) { const deviceId = notification.data?.device?.id; if (deviceId) { sendToDevice(deviceId, notification); } const eventCategory = notification.android_config.category; if (eventCategory !== PushNotificationAction.Ding && eventCategory !== PushNotificationAction.Motion && eventCategory !== PushNotificationAction.IntercomDing) { logDebug('Received v2 push notification with unknown category: ' + eventCategory); logDebug(JSON.stringify(message)); } } else if ('data' in notification && 'gcmData' in notification.data && 'alarm_meta' in notification.data.gcmData) { const deviceId = notification.data.gcmData.alarm_meta.device_zid; if (deviceId) { sendToDevice(deviceId, notification); } } else { // This is not a v1 or v2 style notification, so we can't process it logDebug('Received push notification in unknown format'); logDebug(JSON.stringify(message)); return; } } catch (e) { logError(e); } }); // If we already have credentials and they haven't been changed during registration, use them immediately if (credentials && credentials?.fcm?.token === this.restClient._internalOnly_pushNotificationCredentials?.fcm?.token) { onPushNotificationToken.next(credentials.fcm.token); } } async fetchRawLocations() { const { user_locations: rawLocations } = await this.restClient.request({ url: deviceApi('locations') }); if (!rawLocations) { throw new Error('The Ring account which you used to generate a refresh token does not have any associated locations. Please use an account that has access to at least one location.'); } return rawLocations; } fetchAmazonKeyLocks() { return this.restClient.request({ url: 'https://api.ring.com/integrations/amazonkey/v2/devices/lock_associations', }); } async fetchAndBuildLocations() { const rawLocations = await this.fetchRawLocations(), { authorizedDoorbots, chimes, doorbots, allCameras, baseStations, beamBridges, intercoms, } = await this.fetchRingDevices(), locationIdsWithHubs = [...baseStations, ...beamBridges].map((x) => x.location_id), cameras = allCameras.map((data) => new RingCamera(data, doorbots.includes(data) || authorizedDoorbots.includes(data) || data.kind.startsWith('doorbell'), this.restClient, this.options.avoidSnapshotBatteryDrain || false)), ringChimes = chimes.map((data) => new RingChime(data, this.restClient)), ringIntercoms = intercoms.map((data) => new RingIntercom(data, this.restClient)), locations = rawLocations .filter((location) => { return (!Array.isArray(this.options.locationIds) || this.options.locationIds.includes(location.location_id)); }) .map((location) => new Location(location, cameras.filter((x) => x.data.location_id === location.location_id), ringChimes.filter((x) => x.data.location_id === location.location_id), ringIntercoms.filter((x) => x.data.location_id === location.location_id), { hasHubs: locationIdsWithHubs.includes(location.location_id), hasAlarmBaseStation: baseStations.some((station) => station.location_id === location.location_id), locationModePollingSeconds: this.options.locationModePollingSeconds, }, this.restClient)); this.listenForDeviceUpdates(cameras, ringChimes, ringIntercoms); this.registerPushReceiver(cameras, ringIntercoms).catch((e) => { logError(e); }); return locations; } locationsPromise; getLocations() { if (!this.locationsPromise) { this.locationsPromise = this.fetchAndBuildLocations(); } return this.locationsPromise; } async getCameras() { const locations = await this.getLocations(); return locations.reduce((cameras, location) => [...cameras, ...location.cameras], []); } getProfile() { return this.restClient.request({ url: clientApi('profile'), }); } disconnect() { this.unsubscribe(); if (!this.locationsPromise) { return; } this.getLocations() .then((locations) => locations.forEach((location) => location.disconnect())) .catch((e) => { logError(e); }); this.restClient.clearTimeouts(); clearTimeouts(); } }